alchemy 2.0.0-beta.64 → 2.0.0-beta.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/AWS/EC2/InternetGateway.d.ts.map +1 -1
- package/lib/AWS/EC2/InternetGateway.js +8 -3
- package/lib/AWS/EC2/InternetGateway.js.map +1 -1
- package/lib/AWS/EC2/LingeringEnis.d.ts +26 -0
- package/lib/AWS/EC2/LingeringEnis.d.ts.map +1 -0
- package/lib/AWS/EC2/LingeringEnis.js +128 -0
- package/lib/AWS/EC2/LingeringEnis.js.map +1 -0
- package/lib/AWS/EC2/SecurityGroup.d.ts.map +1 -1
- package/lib/AWS/EC2/SecurityGroup.js +14 -14
- package/lib/AWS/EC2/SecurityGroup.js.map +1 -1
- package/lib/AWS/EC2/Subnet.d.ts.map +1 -1
- package/lib/AWS/EC2/Subnet.js +13 -21
- package/lib/AWS/EC2/Subnet.js.map +1 -1
- package/lib/AWS/ECR/Image.d.ts +1 -0
- package/lib/AWS/ECR/Image.d.ts.map +1 -1
- package/lib/AWS/EKS/Addon.d.ts.map +1 -1
- package/lib/AWS/EKS/Addon.js +15 -3
- package/lib/AWS/EKS/Addon.js.map +1 -1
- package/lib/AWS/EKS/Deployment.d.ts +10 -1
- package/lib/AWS/EKS/Deployment.d.ts.map +1 -1
- package/lib/AWS/EKS/Deployment.js +10 -4
- package/lib/AWS/EKS/Deployment.js.map +1 -1
- package/lib/AWS/EKS/HyperPod.d.ts +57 -0
- package/lib/AWS/EKS/HyperPod.d.ts.map +1 -0
- package/lib/AWS/EKS/HyperPod.js +46 -0
- package/lib/AWS/EKS/HyperPod.js.map +1 -0
- package/lib/AWS/EKS/Job.d.ts +10 -1
- package/lib/AWS/EKS/Job.d.ts.map +1 -1
- package/lib/AWS/EKS/Job.js +16 -6
- package/lib/AWS/EKS/Job.js.map +1 -1
- package/lib/AWS/EKS/internal/client.d.ts +4 -3
- package/lib/AWS/EKS/internal/client.d.ts.map +1 -1
- package/lib/AWS/EKS/internal/client.js +33 -2
- package/lib/AWS/EKS/internal/client.js.map +1 -1
- package/lib/AWS/Lambda/Function.d.ts.map +1 -1
- package/lib/AWS/Lambda/Function.js +1 -1
- package/lib/AWS/Lambda/Function.js.map +1 -1
- package/lib/AWS/Providers.d.ts.map +1 -1
- package/lib/AWS/Providers.js +4 -1
- package/lib/AWS/Providers.js.map +1 -1
- package/lib/AWS/SageMaker/Cluster.d.ts +327 -0
- package/lib/AWS/SageMaker/Cluster.d.ts.map +1 -0
- package/lib/AWS/SageMaker/Cluster.js +469 -0
- package/lib/AWS/SageMaker/Cluster.js.map +1 -0
- package/lib/AWS/SageMaker/ClusterSchedulerConfig.d.ts +96 -0
- package/lib/AWS/SageMaker/ClusterSchedulerConfig.d.ts.map +1 -0
- package/lib/AWS/SageMaker/ClusterSchedulerConfig.js +270 -0
- package/lib/AWS/SageMaker/ClusterSchedulerConfig.js.map +1 -0
- package/lib/AWS/SageMaker/ComputeQuota.d.ts +106 -0
- package/lib/AWS/SageMaker/ComputeQuota.d.ts.map +1 -0
- package/lib/AWS/SageMaker/ComputeQuota.js +268 -0
- package/lib/AWS/SageMaker/ComputeQuota.js.map +1 -0
- package/lib/AWS/SageMaker/index.d.ts +3 -0
- package/lib/AWS/SageMaker/index.d.ts.map +1 -1
- package/lib/AWS/SageMaker/index.js +3 -0
- package/lib/AWS/SageMaker/index.js.map +1 -1
- package/lib/Bundle/PurePlugin.d.ts +29 -7
- package/lib/Bundle/PurePlugin.d.ts.map +1 -1
- package/lib/Bundle/PurePlugin.js +56 -33
- package/lib/Bundle/PurePlugin.js.map +1 -1
- package/lib/Cli/commands/nuke.d.ts.map +1 -1
- package/lib/Cli/commands/nuke.js +231 -37
- package/lib/Cli/commands/nuke.js.map +1 -1
- package/lib/Cloudflare/StateStore/Api.d.ts +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts +26 -8
- package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Assets.js +46 -1
- package/lib/Cloudflare/Workers/Assets.js.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +2 -2
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +39 -6
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
- package/lib/Cloudflare/Workers/Worker.d.ts +7 -5
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +0 -1
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBinding.d.ts +2 -2
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts +20 -1
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBundle.js +11 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerProvider.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/WorkerProvider.js +10 -4
- package/lib/Cloudflare/Workers/WorkerProvider.js.map +1 -1
- package/lib/Cloudflare/Workflows/WorkflowBridge.d.ts +2 -1
- package/lib/Cloudflare/Workflows/WorkflowBridge.d.ts.map +1 -1
- package/lib/Cloudflare/Workflows/WorkflowBridge.js +5 -5
- package/lib/Cloudflare/Workflows/WorkflowBridge.js.map +1 -1
- package/lib/Cloudflare/Workflows/index.d.ts +1 -1
- package/lib/Cloudflare/Workflows/index.d.ts.map +1 -1
- package/lib/Cloudflare/Workflows/index.js +1 -1
- package/lib/Cloudflare/Workflows/index.js.map +1 -1
- package/lib/Docker/Container.d.ts +26 -0
- package/lib/Docker/Container.d.ts.map +1 -1
- package/lib/Docker/Container.js +20 -1
- package/lib/Docker/Container.js.map +1 -1
- package/lib/Docker/Docker.d.ts +2 -0
- package/lib/Docker/Docker.d.ts.map +1 -1
- package/lib/Docker/Docker.js.map +1 -1
- package/lib/Output.d.ts +4 -1
- package/lib/Output.d.ts.map +1 -1
- package/lib/Output.js.map +1 -1
- package/lib/Provider.d.ts +20 -0
- package/lib/Provider.d.ts.map +1 -1
- package/lib/Provider.js.map +1 -1
- package/lib/Random.d.ts +1 -1
- package/lib/Random.d.ts.map +1 -1
- package/lib/Resource.d.ts +14 -1
- package/lib/Resource.d.ts.map +1 -1
- package/lib/Resource.js.map +1 -1
- package/lib/SQL/D1.d.ts +1 -1
- package/lib/SQL/D1.js +1 -1
- package/lib/SQL/Postgres.d.ts +1 -1
- package/lib/SQL/Postgres.js +1 -1
- package/lib/State/HttpStateApi.d.ts +11 -11
- package/lib/Test/Alchemy.d.ts +1 -1
- package/lib/Test/Alchemy.js +1 -1
- package/lib/Test/Vitest.d.ts +58 -0
- package/lib/Test/Vitest.d.ts.map +1 -0
- package/lib/Test/Vitest.js +114 -0
- package/lib/Test/Vitest.js.map +1 -0
- package/lib/Util/proxy-chain.d.ts +5 -0
- package/lib/Util/proxy-chain.d.ts.map +1 -1
- package/lib/Util/proxy-chain.js +52 -4
- package/lib/Util/proxy-chain.js.map +1 -1
- package/package.json +23 -15
- package/src/AWS/EC2/InternetGateway.ts +8 -3
- package/src/AWS/EC2/LingeringEnis.ts +169 -0
- package/src/AWS/EC2/SecurityGroup.ts +24 -28
- package/src/AWS/EC2/Subnet.ts +23 -34
- package/src/AWS/EKS/Addon.ts +17 -4
- package/src/AWS/EKS/Deployment.ts +26 -5
- package/src/AWS/EKS/HyperPod.ts +97 -0
- package/src/AWS/EKS/Job.ts +32 -7
- package/src/AWS/EKS/internal/client.ts +40 -3
- package/src/AWS/Lambda/Function.ts +5 -3
- package/src/AWS/Providers.ts +6 -0
- package/src/AWS/SageMaker/Cluster.ts +775 -0
- package/src/AWS/SageMaker/ClusterSchedulerConfig.ts +430 -0
- package/src/AWS/SageMaker/ComputeQuota.ts +426 -0
- package/src/AWS/SageMaker/index.ts +3 -0
- package/src/Bundle/PurePlugin.ts +92 -39
- package/src/Cli/commands/nuke.ts +295 -56
- package/src/Cloudflare/Workers/Assets.ts +53 -1
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +46 -7
- package/src/Cloudflare/Workers/Worker.ts +7 -5
- package/src/Cloudflare/Workers/WorkerBundle.ts +31 -1
- package/src/Cloudflare/Workers/WorkerProvider.ts +18 -4
- package/src/Cloudflare/Workflows/WorkflowBridge.ts +5 -5
- package/src/Cloudflare/Workflows/index.ts +1 -1
- package/src/Docker/Container.ts +30 -1
- package/src/Docker/Docker.ts +2 -0
- package/src/Output.ts +19 -11
- package/src/Provider.ts +20 -0
- package/src/Resource.ts +18 -1
- package/src/SQL/D1.ts +1 -1
- package/src/SQL/Postgres.ts +1 -1
- package/src/Test/Alchemy.ts +1 -1
- package/src/Test/Vitest.ts +244 -0
- package/src/Util/proxy-chain.ts +73 -4
- package/lib/SQL/index.d.ts +0 -4
- package/lib/SQL/index.d.ts.map +0 -1
- package/lib/SQL/index.js +0 -4
- package/lib/SQL/index.js.map +0 -1
- package/src/SQL/index.ts +0 -3
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import * as sagemaker from "@distilled.cloud/aws/sagemaker";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schedule from "effect/Schedule";
|
|
5
|
+
import * as EffectStream from "effect/Stream";
|
|
6
|
+
import { Unowned } from "../../AdoptPolicy.ts";
|
|
7
|
+
import { isResolved } from "../../Diff.ts";
|
|
8
|
+
import type { Input } from "../../Input.ts";
|
|
9
|
+
import * as Output from "../../Output.ts";
|
|
10
|
+
import { createPhysicalName } from "../../PhysicalName.ts";
|
|
11
|
+
import * as Provider from "../../Provider.ts";
|
|
12
|
+
import { Resource } from "../../Resource.ts";
|
|
13
|
+
import {
|
|
14
|
+
createInternalTags,
|
|
15
|
+
diffTags,
|
|
16
|
+
hasAlchemyTags,
|
|
17
|
+
type Tags,
|
|
18
|
+
} from "../../Tags.ts";
|
|
19
|
+
import type { Providers } from "../Providers.ts";
|
|
20
|
+
|
|
21
|
+
export type ClusterStatus = sagemaker.ClusterStatus;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An instance group surfaced on the cluster's attributes — the value EKS
|
|
25
|
+
* workloads reference via `hyperpod.instanceGroup` to pin themselves to
|
|
26
|
+
* the group.
|
|
27
|
+
*/
|
|
28
|
+
export interface ClusterInstanceGroupRef {
|
|
29
|
+
/** The group's name (the `sagemaker.amazonaws.com/instance-group-name` node label). */
|
|
30
|
+
InstanceGroupName: string;
|
|
31
|
+
/** The group's instance type (e.g. `ml.g5.xlarge`). */
|
|
32
|
+
InstanceType: string | undefined;
|
|
33
|
+
/** Instances currently in service. */
|
|
34
|
+
CurrentCount: number | undefined;
|
|
35
|
+
/** Instances the group is converging toward. */
|
|
36
|
+
TargetCount: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One instance group's specification, sans name — the group's name is the
|
|
41
|
+
* key in {@link ClusterProps.instanceGroups}.
|
|
42
|
+
*/
|
|
43
|
+
export type ClusterInstanceGroup = Omit<
|
|
44
|
+
sagemaker.ClusterInstanceGroupSpecification,
|
|
45
|
+
"InstanceGroupName"
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One restricted instance group's specification, sans name — the group's
|
|
50
|
+
* name is the key in {@link ClusterProps.restrictedInstanceGroups}.
|
|
51
|
+
*/
|
|
52
|
+
export type ClusterRestrictedInstanceGroup = Omit<
|
|
53
|
+
sagemaker.ClusterRestrictedInstanceGroupSpecification,
|
|
54
|
+
"InstanceGroupName"
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
export interface ClusterProps {
|
|
58
|
+
/**
|
|
59
|
+
* Name of the HyperPod cluster. Maximum 63 characters, alphanumeric and
|
|
60
|
+
* hyphens.
|
|
61
|
+
* @default ${app}-${stage}-${id}
|
|
62
|
+
*/
|
|
63
|
+
clusterName?: string;
|
|
64
|
+
/**
|
|
65
|
+
* The instance groups of the cluster, keyed by group name. Each group
|
|
66
|
+
* specifies its instance type, instance count, lifecycle config (S3 URI
|
|
67
|
+
* + `on_create` script) and execution role.
|
|
68
|
+
*
|
|
69
|
+
* Groups are updated in place; removing a key deletes the group from the
|
|
70
|
+
* cluster. The keys carry through to the cluster's `instanceGroups`
|
|
71
|
+
* attribute, so `hyperpod.instanceGroups.workers` is typed per key.
|
|
72
|
+
*/
|
|
73
|
+
instanceGroups?: Record<string, ClusterInstanceGroup>;
|
|
74
|
+
/**
|
|
75
|
+
* Restricted instance groups for HyperPod clusters running Amazon-managed
|
|
76
|
+
* workloads (e.g. Nova model customization), keyed by group name.
|
|
77
|
+
*/
|
|
78
|
+
restrictedInstanceGroups?: Record<string, ClusterRestrictedInstanceGroup>;
|
|
79
|
+
/**
|
|
80
|
+
* Shared environment configuration (e.g. FSx for Lustre) for restricted
|
|
81
|
+
* instance groups.
|
|
82
|
+
*/
|
|
83
|
+
restrictedInstanceGroupsConfig?: sagemaker.ClusterRestrictedInstanceGroupsConfig;
|
|
84
|
+
/**
|
|
85
|
+
* VPC to launch the cluster's nodes into. Changing the VPC replaces the
|
|
86
|
+
* cluster.
|
|
87
|
+
*/
|
|
88
|
+
vpcConfig?: sagemaker.VpcConfig;
|
|
89
|
+
/**
|
|
90
|
+
* The cluster's orchestrator. Set `{ Eks: { ClusterArn } }` to attach the
|
|
91
|
+
* HyperPod cluster to an EKS control plane; defaults to Slurm when
|
|
92
|
+
* omitted.
|
|
93
|
+
*/
|
|
94
|
+
orchestrator?: sagemaker.ClusterOrchestrator;
|
|
95
|
+
/**
|
|
96
|
+
* Whether HyperPod automatically replaces faulty nodes.
|
|
97
|
+
* @default "Automatic"
|
|
98
|
+
*/
|
|
99
|
+
nodeRecovery?: sagemaker.ClusterNodeRecovery;
|
|
100
|
+
/**
|
|
101
|
+
* Managed tiered storage (memory + local NVMe) configuration for
|
|
102
|
+
* checkpointing.
|
|
103
|
+
*/
|
|
104
|
+
tieredStorageConfig?: sagemaker.ClusterTieredStorageConfig;
|
|
105
|
+
/**
|
|
106
|
+
* Set to `"Continuous"` to enable continuous node provisioning, where
|
|
107
|
+
* HyperPod provisions capacity as it becomes available instead of
|
|
108
|
+
* all-or-nothing.
|
|
109
|
+
*/
|
|
110
|
+
nodeProvisioningMode?: sagemaker.ClusterNodeProvisioningMode;
|
|
111
|
+
/**
|
|
112
|
+
* ARN of the IAM role HyperPod assumes for cluster-level operations
|
|
113
|
+
* (required for continuous provisioning and autoscaling).
|
|
114
|
+
*/
|
|
115
|
+
clusterRole?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Cluster autoscaling configuration (e.g. Karpenter) for EKS-orchestrated
|
|
118
|
+
* clusters.
|
|
119
|
+
*/
|
|
120
|
+
autoScaling?: sagemaker.ClusterAutoScalingConfig;
|
|
121
|
+
/**
|
|
122
|
+
* Tags to associate with the cluster. Merged with internal Alchemy tags.
|
|
123
|
+
*/
|
|
124
|
+
tags?: Record<string, string>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface Cluster extends Resource<
|
|
128
|
+
"AWS.SageMaker.Cluster",
|
|
129
|
+
ClusterProps,
|
|
130
|
+
{
|
|
131
|
+
/**
|
|
132
|
+
* The cluster's name.
|
|
133
|
+
*/
|
|
134
|
+
clusterName: string;
|
|
135
|
+
/**
|
|
136
|
+
* ARN of the cluster.
|
|
137
|
+
*/
|
|
138
|
+
clusterArn: string;
|
|
139
|
+
/**
|
|
140
|
+
* The cluster's status (`InService` once reconciled).
|
|
141
|
+
*/
|
|
142
|
+
clusterStatus: ClusterStatus;
|
|
143
|
+
/**
|
|
144
|
+
* ARN of the orchestrating EKS cluster, when EKS-orchestrated.
|
|
145
|
+
*/
|
|
146
|
+
orchestratorEksClusterArn: string | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* The cluster's instance groups, keyed by group name. Reference one
|
|
149
|
+
* from an EKS workload's `hyperpod.instanceGroup` to pin the workload
|
|
150
|
+
* to the group through the resource graph:
|
|
151
|
+
* `hyperpod.instanceGroups.workers`.
|
|
152
|
+
*/
|
|
153
|
+
instanceGroups: Record<string, ClusterInstanceGroupRef>;
|
|
154
|
+
},
|
|
155
|
+
never,
|
|
156
|
+
Providers
|
|
157
|
+
> {}
|
|
158
|
+
|
|
159
|
+
const ClusterResource = Resource<Cluster>("AWS.SageMaker.Cluster");
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A `Cluster` narrowed to the instance-group keys declared in its props —
|
|
163
|
+
* `hyperpod.instanceGroups.workers` is typed per key, and a typo'd key is
|
|
164
|
+
* a compile error.
|
|
165
|
+
*/
|
|
166
|
+
export type ClusterOf<Groups> = Omit<Cluster, "instanceGroups"> & {
|
|
167
|
+
instanceGroups: Output.ObjectExpr<
|
|
168
|
+
{ [K in keyof Groups]: ClusterInstanceGroupRef },
|
|
169
|
+
never
|
|
170
|
+
>;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* An Amazon SageMaker HyperPod cluster — a resilient, persistent cluster of
|
|
175
|
+
* ML compute for distributed training and inference, orchestrated by Slurm
|
|
176
|
+
* or EKS, with automatic faulty-node recovery and deep health checks.
|
|
177
|
+
*
|
|
178
|
+
* Provisioning a HyperPod cluster takes 10–25 minutes; instance groups are
|
|
179
|
+
* updated in place and removing a group from `instanceGroups` deletes it
|
|
180
|
+
* from the cluster.
|
|
181
|
+
* @resource
|
|
182
|
+
* @section Creating Clusters
|
|
183
|
+
* @example Slurm-Orchestrated Cluster
|
|
184
|
+
* ```typescript
|
|
185
|
+
* import * as AWS from "alchemy/AWS";
|
|
186
|
+
*
|
|
187
|
+
* const cluster = yield* AWS.SageMaker.Cluster("TrainingCluster", {
|
|
188
|
+
* instanceGroups: {
|
|
189
|
+
* controller: {
|
|
190
|
+
* InstanceType: "ml.t3.medium",
|
|
191
|
+
* InstanceCount: 1,
|
|
192
|
+
* ExecutionRole: role.roleArn,
|
|
193
|
+
* LifeCycleConfig: {
|
|
194
|
+
* SourceS3Uri: `s3://${bucket.bucketName}/lifecycle`,
|
|
195
|
+
* OnCreate: "on_create.sh",
|
|
196
|
+
* },
|
|
197
|
+
* },
|
|
198
|
+
* },
|
|
199
|
+
* });
|
|
200
|
+
* ```
|
|
201
|
+
*
|
|
202
|
+
* @example EKS-Orchestrated Cluster
|
|
203
|
+
* ```typescript
|
|
204
|
+
* // The EKS cluster must use the `API` (or `API_AND_CONFIG_MAP`)
|
|
205
|
+
* // authentication mode — pass `accessConfig` explicitly, EKS's own
|
|
206
|
+
* // CONFIG_MAP default is rejected. LifeCycleConfig is required for
|
|
207
|
+
* // EKS-orchestrated instance groups too.
|
|
208
|
+
* const hyperpod = yield* AWS.SageMaker.Cluster("EksHyperPod", {
|
|
209
|
+
* orchestrator: { Eks: { ClusterArn: eksCluster.clusterArn } },
|
|
210
|
+
* vpcConfig: {
|
|
211
|
+
* SecurityGroupIds: [securityGroupId],
|
|
212
|
+
* Subnets: network.privateSubnetIds,
|
|
213
|
+
* },
|
|
214
|
+
* instanceGroups: {
|
|
215
|
+
* workers: {
|
|
216
|
+
* InstanceType: "ml.g5.xlarge",
|
|
217
|
+
* InstanceCount: 2,
|
|
218
|
+
* ExecutionRole: role.roleArn,
|
|
219
|
+
* LifeCycleConfig: {
|
|
220
|
+
* SourceS3Uri: `s3://${bucket.bucketName}/lifecycle`,
|
|
221
|
+
* OnCreate: "on_create.sh",
|
|
222
|
+
* },
|
|
223
|
+
* },
|
|
224
|
+
* },
|
|
225
|
+
* nodeRecovery: "Automatic",
|
|
226
|
+
* });
|
|
227
|
+
*
|
|
228
|
+
* // The keys carry through to the attributes — typed per key:
|
|
229
|
+
* const workers = hyperpod.instanceGroups.workers;
|
|
230
|
+
* ```
|
|
231
|
+
*
|
|
232
|
+
* @section Running Workloads (Slurm)
|
|
233
|
+
* @example Submit jobs from the login node over SSM
|
|
234
|
+
* ```sh
|
|
235
|
+
* # Slurm jobs are submitted on the cluster itself. Each node is an SSM
|
|
236
|
+
* # target named sagemaker-cluster:<cluster-id>_<instance-group>-<instance-id>
|
|
237
|
+
* # (list nodes with `aws sagemaker list-cluster-nodes`).
|
|
238
|
+
* aws ssm start-session \
|
|
239
|
+
* --target sagemaker-cluster:6wl4at0i68c6_controller-i-0123456789abcdef0
|
|
240
|
+
* # then, on the node:
|
|
241
|
+
* sbatch --nodes=4 train.sbatch
|
|
242
|
+
* ```
|
|
243
|
+
*
|
|
244
|
+
* @section Running Workloads (EKS)
|
|
245
|
+
* @example Low level: apply any Kubernetes manifest to the orchestrator
|
|
246
|
+
* ```typescript
|
|
247
|
+
* // HyperPod nodes are ordinary EKS nodes — target them from a raw
|
|
248
|
+
* // manifest (a PyTorchJob CRD, a batch/v1 Job, ...) with the well-known
|
|
249
|
+
* // node labels.
|
|
250
|
+
* const job = yield* AWS.EKS.Manifest("RawTrainJob", {
|
|
251
|
+
* cluster: eksCluster,
|
|
252
|
+
* manifest: {
|
|
253
|
+
* apiVersion: "batch/v1",
|
|
254
|
+
* kind: "Job",
|
|
255
|
+
* metadata: { name: "raw-train", namespace: "default" },
|
|
256
|
+
* spec: {
|
|
257
|
+
* template: {
|
|
258
|
+
* spec: {
|
|
259
|
+
* nodeSelector: {
|
|
260
|
+
* "sagemaker.amazonaws.com/node-health-status": "Schedulable",
|
|
261
|
+
* "sagemaker.amazonaws.com/instance-group-name": "workers",
|
|
262
|
+
* },
|
|
263
|
+
* containers: [{ name: "train", image: "ghcr.io/acme/train:v3" }],
|
|
264
|
+
* restartPolicy: "Never",
|
|
265
|
+
* },
|
|
266
|
+
* },
|
|
267
|
+
* },
|
|
268
|
+
* },
|
|
269
|
+
* });
|
|
270
|
+
* ```
|
|
271
|
+
*
|
|
272
|
+
* @example High level: an effectful Job pinned to HyperPod nodes
|
|
273
|
+
* ```typescript
|
|
274
|
+
* // AWS.EKS.Job / AWS.EKS.Deployment run on HyperPod via the orchestrating
|
|
275
|
+
* // EKS cluster; the `hyperpod` prop derives the node selector, namespace,
|
|
276
|
+
* // and Kueue labels — pass the ComputeQuota resource to submit through
|
|
277
|
+
* // task governance.
|
|
278
|
+
* const evaluate = yield* AWS.EKS.Job(
|
|
279
|
+
* "Evaluate",
|
|
280
|
+
* {
|
|
281
|
+
* cluster: eksCluster,
|
|
282
|
+
* main: import.meta.url,
|
|
283
|
+
* hyperpod: {
|
|
284
|
+
* instanceGroup: "workers",
|
|
285
|
+
* quota,
|
|
286
|
+
* priorityClass: "training",
|
|
287
|
+
* },
|
|
288
|
+
* },
|
|
289
|
+
* Effect.gen(function* () {
|
|
290
|
+
* const putItem = yield* AWS.DynamoDB.PutItem(resultsTable);
|
|
291
|
+
* return {
|
|
292
|
+
* run: Effect.gen(function* () {
|
|
293
|
+
* // evaluation logic; bindings land IAM on the pod-identity role
|
|
294
|
+
* }),
|
|
295
|
+
* };
|
|
296
|
+
* }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)),
|
|
297
|
+
* );
|
|
298
|
+
* ```
|
|
299
|
+
*
|
|
300
|
+
* @section Task Governance
|
|
301
|
+
* @example Prioritize workloads with a scheduler policy and team quotas
|
|
302
|
+
* ```typescript
|
|
303
|
+
* // Requires the amazon-sagemaker-hyperpod-taskgovernance EKS add-on.
|
|
304
|
+
* const policy = yield* AWS.SageMaker.ClusterSchedulerConfig("Scheduler", {
|
|
305
|
+
* clusterArn: hyperpod.clusterArn,
|
|
306
|
+
* schedulerConfig: {
|
|
307
|
+
* PriorityClasses: [{ Name: "training", Weight: 90 }],
|
|
308
|
+
* FairShare: "Enabled",
|
|
309
|
+
* },
|
|
310
|
+
* });
|
|
311
|
+
*
|
|
312
|
+
* // Creates the hyperpod-ns-research namespace + Kueue LocalQueue that
|
|
313
|
+
* // `hyperpod: { quota }` on an EKS Job/Deployment submits into.
|
|
314
|
+
* const quota = yield* AWS.SageMaker.ComputeQuota("ResearchQuota", {
|
|
315
|
+
* clusterArn: hyperpod.clusterArn,
|
|
316
|
+
* computeQuotaTarget: { TeamName: "research", FairShareWeight: 10 },
|
|
317
|
+
* computeQuotaConfig: {
|
|
318
|
+
* ComputeQuotaResources: [
|
|
319
|
+
* { InstanceType: "ml.g5.xlarge", Count: 1 },
|
|
320
|
+
* ],
|
|
321
|
+
* },
|
|
322
|
+
* });
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export const Cluster: {
|
|
326
|
+
<
|
|
327
|
+
const Props extends {
|
|
328
|
+
[prop in keyof ClusterProps]: Input<ClusterProps[prop]>;
|
|
329
|
+
},
|
|
330
|
+
>(
|
|
331
|
+
id: string,
|
|
332
|
+
props: Props | Effect.Effect<Props>,
|
|
333
|
+
): Effect.Effect<
|
|
334
|
+
ClusterOf<NonNullable<Props["instanceGroups"]>>,
|
|
335
|
+
never,
|
|
336
|
+
Providers
|
|
337
|
+
>;
|
|
338
|
+
} & typeof ClusterResource = ClusterResource as never;
|
|
339
|
+
|
|
340
|
+
const createClusterName = (
|
|
341
|
+
id: string,
|
|
342
|
+
props: { clusterName?: string | undefined },
|
|
343
|
+
) =>
|
|
344
|
+
props.clusterName
|
|
345
|
+
? Effect.succeed(props.clusterName)
|
|
346
|
+
: createPhysicalName({ id, maxLength: 63 });
|
|
347
|
+
|
|
348
|
+
const describeClusterOrUndefined = (name: string) =>
|
|
349
|
+
sagemaker
|
|
350
|
+
.describeCluster({ ClusterName: name })
|
|
351
|
+
.pipe(Effect.catchTag("ResourceNotFound", () => Effect.succeed(undefined)));
|
|
352
|
+
|
|
353
|
+
const fetchClusterTags = Effect.fn(function* (arn: string) {
|
|
354
|
+
const response = yield* sagemaker
|
|
355
|
+
.listTags({ ResourceArn: arn })
|
|
356
|
+
.pipe(
|
|
357
|
+
Effect.catchTag("AccessDeniedException", () => Effect.succeed(undefined)),
|
|
358
|
+
);
|
|
359
|
+
return Object.fromEntries(
|
|
360
|
+
(response?.Tags ?? []).flatMap((tag) =>
|
|
361
|
+
tag.Key !== undefined ? [[tag.Key, tag.Value ?? ""]] : [],
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const toAttrs = (
|
|
367
|
+
described: sagemaker.DescribeClusterResponse,
|
|
368
|
+
): Cluster["Attributes"] => ({
|
|
369
|
+
clusterName: described.ClusterName ?? "",
|
|
370
|
+
clusterArn: described.ClusterArn,
|
|
371
|
+
clusterStatus: described.ClusterStatus,
|
|
372
|
+
orchestratorEksClusterArn: described.Orchestrator?.Eks?.ClusterArn,
|
|
373
|
+
instanceGroups: Object.fromEntries(
|
|
374
|
+
(described.InstanceGroups ?? []).flatMap((group) =>
|
|
375
|
+
group.InstanceGroupName !== undefined
|
|
376
|
+
? [
|
|
377
|
+
[
|
|
378
|
+
group.InstanceGroupName,
|
|
379
|
+
{
|
|
380
|
+
InstanceGroupName: group.InstanceGroupName,
|
|
381
|
+
InstanceType: group.InstanceType,
|
|
382
|
+
CurrentCount: group.CurrentCount,
|
|
383
|
+
TargetCount: group.TargetCount,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
]
|
|
387
|
+
: [],
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The cluster is still transitioning toward the awaited state — retried by
|
|
394
|
+
* the bounded wait schedule.
|
|
395
|
+
*/
|
|
396
|
+
class ClusterNotReady extends Data.TaggedError("ClusterNotReady")<{
|
|
397
|
+
readonly clusterName: string;
|
|
398
|
+
readonly status: string | undefined;
|
|
399
|
+
}> {}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The cluster converged to the terminal `Failed` status.
|
|
403
|
+
*/
|
|
404
|
+
export class ClusterFailed extends Data.TaggedError("ClusterFailed")<{
|
|
405
|
+
readonly clusterName: string;
|
|
406
|
+
readonly message: string | undefined;
|
|
407
|
+
}> {}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The cluster is stuck in `Deleting` because SageMaker's internal teardown
|
|
411
|
+
* cannot proceed — e.g. the instance group's execution role (which HyperPod
|
|
412
|
+
* assumes to delete node ENIs) was deleted or lost its
|
|
413
|
+
* `ec2:DeleteNetworkInterface` / `ec2:DeleteNetworkInterfacePermission`
|
|
414
|
+
* permissions mid-teardown. Retrying `deleteCluster` can never fix this;
|
|
415
|
+
* the role and its permissions must be restored out-of-band, after which
|
|
416
|
+
* SageMaker's own retry completes the deletion within a minute.
|
|
417
|
+
*/
|
|
418
|
+
export class ClusterTeardownBlocked extends Data.TaggedError(
|
|
419
|
+
"ClusterTeardownBlocked",
|
|
420
|
+
)<{
|
|
421
|
+
readonly clusterName: string;
|
|
422
|
+
readonly message: string;
|
|
423
|
+
}> {}
|
|
424
|
+
|
|
425
|
+
// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
|
|
426
|
+
// lifecycle code leaks `Retry.Return`'s conditional type into declaration
|
|
427
|
+
// emit and widens the provider layer to `unknown` for every consumer of
|
|
428
|
+
// `AWS.providers()`.
|
|
429
|
+
const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
|
|
430
|
+
self: Effect.Effect<A, E, R>,
|
|
431
|
+
): Effect.Effect<A, E, R> =>
|
|
432
|
+
Effect.retry(self, {
|
|
433
|
+
while: (e) => e._tag === "ClusterNotReady",
|
|
434
|
+
// HyperPod cluster provisioning routinely takes 10–25 minutes.
|
|
435
|
+
// Poll 15s up to ~35 min.
|
|
436
|
+
schedule: Schedule.max([
|
|
437
|
+
Schedule.spaced("15 seconds"),
|
|
438
|
+
Schedule.recurs(140),
|
|
439
|
+
]),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// A freshly created execution role isn't assumable by SageMaker for a few
|
|
443
|
+
// seconds (IAM propagation) — createCluster rejects it with a
|
|
444
|
+
// ValidationException until it is.
|
|
445
|
+
const retryWhileRoleUnassumable = <
|
|
446
|
+
A,
|
|
447
|
+
E extends { readonly _tag: string; readonly message?: string },
|
|
448
|
+
R,
|
|
449
|
+
>(
|
|
450
|
+
self: Effect.Effect<A, E, R>,
|
|
451
|
+
): Effect.Effect<A, E, R> =>
|
|
452
|
+
Effect.retry(self, {
|
|
453
|
+
while: (e): boolean =>
|
|
454
|
+
e._tag === "ValidationException" &&
|
|
455
|
+
(e.message?.includes("cannot assume the execution role") ?? false),
|
|
456
|
+
schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(12)]),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const waitForCluster = (name: string, target: "InService" | "Gone") =>
|
|
460
|
+
retryWhileNotReady(
|
|
461
|
+
Effect.gen(function* () {
|
|
462
|
+
const described = yield* describeClusterOrUndefined(name);
|
|
463
|
+
if (target === "Gone") {
|
|
464
|
+
if (described === undefined) return;
|
|
465
|
+
// SageMaker's internal teardown deletes node ENIs by assuming the
|
|
466
|
+
// instance group's execution role. If that role (or its EC2
|
|
467
|
+
// permissions) was deleted mid-teardown, every internal retry
|
|
468
|
+
// fails and the cluster stays `Deleting` forever — surface the
|
|
469
|
+
// node's permission failure immediately instead of polling the
|
|
470
|
+
// full wait budget on a delete that can never finish.
|
|
471
|
+
const nodes = yield* sagemaker
|
|
472
|
+
.listClusterNodes({ ClusterName: name })
|
|
473
|
+
.pipe(
|
|
474
|
+
Effect.catchTag("ResourceNotFound", () =>
|
|
475
|
+
Effect.succeed(undefined),
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
const blocked = (nodes?.ClusterNodeSummaries ?? []).find((node) =>
|
|
479
|
+
node.InstanceStatus?.Message?.includes(
|
|
480
|
+
"does not have permission to perform",
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
if (blocked !== undefined) {
|
|
484
|
+
return yield* Effect.fail(
|
|
485
|
+
new ClusterTeardownBlocked({
|
|
486
|
+
clusterName: name,
|
|
487
|
+
message: blocked.InstanceStatus?.Message ?? "",
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return yield* Effect.fail(
|
|
492
|
+
new ClusterNotReady({
|
|
493
|
+
clusterName: name,
|
|
494
|
+
status: described.ClusterStatus,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
if (described?.ClusterStatus === "InService") return;
|
|
499
|
+
if (described?.ClusterStatus === "Failed") {
|
|
500
|
+
return yield* Effect.fail(
|
|
501
|
+
new ClusterFailed({
|
|
502
|
+
clusterName: name,
|
|
503
|
+
message: described.FailureMessage,
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return yield* Effect.fail(
|
|
508
|
+
new ClusterNotReady({
|
|
509
|
+
clusterName: name,
|
|
510
|
+
status: described?.ClusterStatus,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The subset of an instance group's specification that `updateCluster`
|
|
518
|
+
* can change and `describeCluster` reports back — used to diff desired
|
|
519
|
+
* groups against observed groups.
|
|
520
|
+
*/
|
|
521
|
+
const observedGroupIdentity = (
|
|
522
|
+
group: sagemaker.DescribeClusterResponse["InstanceGroups"][number],
|
|
523
|
+
) => ({
|
|
524
|
+
count: group.TargetCount ?? group.CurrentCount,
|
|
525
|
+
type: group.InstanceType,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const desiredGroupIdentity = (group: ClusterInstanceGroup) => ({
|
|
529
|
+
count: group.InstanceCount,
|
|
530
|
+
type: group.InstanceType,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
/** Fold the keyed props form into the API's named-array form. */
|
|
534
|
+
const toGroupSpecs = <G extends { InstanceGroupName?: string }>(
|
|
535
|
+
groups: Record<string, Omit<G, "InstanceGroupName">> | undefined,
|
|
536
|
+
): G[] | undefined =>
|
|
537
|
+
groups === undefined
|
|
538
|
+
? undefined
|
|
539
|
+
: Object.entries(groups).map(
|
|
540
|
+
([name, group]) => ({ InstanceGroupName: name, ...group }) as G,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
export const ClusterProvider = () =>
|
|
544
|
+
Provider.effect(
|
|
545
|
+
Cluster,
|
|
546
|
+
Effect.gen(function* () {
|
|
547
|
+
return {
|
|
548
|
+
stables: ["clusterName", "clusterArn"],
|
|
549
|
+
// HyperPod's internal teardown deletes node ENIs by assuming the
|
|
550
|
+
// instance group's execution role inside the cluster's VPC —
|
|
551
|
+
// deleting the role or network mid-teardown wedges the cluster in
|
|
552
|
+
// `Deleting` permanently, so nuke must fully delete clusters
|
|
553
|
+
// before touching these types.
|
|
554
|
+
nuke: {
|
|
555
|
+
dependsOn: [
|
|
556
|
+
"AWS.IAM.Role",
|
|
557
|
+
"AWS.IAM.Policy",
|
|
558
|
+
"AWS.IAM.InstanceProfile",
|
|
559
|
+
"AWS.EC2.Vpc",
|
|
560
|
+
"AWS.EC2.Subnet",
|
|
561
|
+
"AWS.EC2.SecurityGroup",
|
|
562
|
+
],
|
|
563
|
+
},
|
|
564
|
+
list: () =>
|
|
565
|
+
Effect.gen(function* () {
|
|
566
|
+
const summaries = yield* sagemaker.listClusters.pages({}).pipe(
|
|
567
|
+
EffectStream.runCollect,
|
|
568
|
+
Effect.map((chunk) =>
|
|
569
|
+
Array.from(chunk).flatMap(
|
|
570
|
+
(page) => page.ClusterSummaries ?? [],
|
|
571
|
+
),
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
return summaries.flatMap((s) =>
|
|
575
|
+
s.ClusterName !== undefined && s.ClusterArn !== undefined
|
|
576
|
+
? [
|
|
577
|
+
{
|
|
578
|
+
clusterName: s.ClusterName,
|
|
579
|
+
clusterArn: s.ClusterArn,
|
|
580
|
+
clusterStatus: s.ClusterStatus ?? "InService",
|
|
581
|
+
orchestratorEksClusterArn: undefined,
|
|
582
|
+
instanceGroups: {},
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
: [],
|
|
586
|
+
);
|
|
587
|
+
}),
|
|
588
|
+
read: Effect.fn(function* ({ id, olds, output }) {
|
|
589
|
+
const name =
|
|
590
|
+
output?.clusterName ?? (yield* createClusterName(id, olds ?? {}));
|
|
591
|
+
const described = yield* describeClusterOrUndefined(name);
|
|
592
|
+
if (!described || described.ClusterStatus === "Deleting") {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
const attrs = toAttrs(described);
|
|
596
|
+
const tags = yield* fetchClusterTags(attrs.clusterArn);
|
|
597
|
+
return (yield* hasAlchemyTags(id, tags as Tags))
|
|
598
|
+
? attrs
|
|
599
|
+
: Unowned(attrs);
|
|
600
|
+
}),
|
|
601
|
+
diff: Effect.fn(function* ({ id, news, olds }) {
|
|
602
|
+
if (!isResolved(news)) return;
|
|
603
|
+
if (olds === undefined) return;
|
|
604
|
+
const oldName = yield* createClusterName(id, olds);
|
|
605
|
+
const newName = yield* createClusterName(id, news);
|
|
606
|
+
// The VPC is fixed at creation — changing it (or the name)
|
|
607
|
+
// replaces the cluster. Everything else updates in place.
|
|
608
|
+
if (
|
|
609
|
+
oldName !== newName ||
|
|
610
|
+
JSON.stringify(olds.vpcConfig) !== JSON.stringify(news.vpcConfig)
|
|
611
|
+
) {
|
|
612
|
+
return { action: "replace" } as const;
|
|
613
|
+
}
|
|
614
|
+
}),
|
|
615
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
616
|
+
if (!news) {
|
|
617
|
+
return yield* Effect.fail(
|
|
618
|
+
new Error("SageMaker HyperPod Cluster requires props"),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const name =
|
|
622
|
+
output?.clusterName ?? (yield* createClusterName(id, news));
|
|
623
|
+
const internalTags = yield* createInternalTags(id);
|
|
624
|
+
const desiredTags = { ...internalTags, ...news.tags };
|
|
625
|
+
|
|
626
|
+
// Observe.
|
|
627
|
+
let described = yield* describeClusterOrUndefined(name);
|
|
628
|
+
|
|
629
|
+
// Ensure — create if missing; tolerate the already-exists race.
|
|
630
|
+
if (described === undefined) {
|
|
631
|
+
yield* sagemaker
|
|
632
|
+
.createCluster({
|
|
633
|
+
ClusterName: name,
|
|
634
|
+
InstanceGroups: toGroupSpecs(news.instanceGroups),
|
|
635
|
+
RestrictedInstanceGroups: toGroupSpecs(
|
|
636
|
+
news.restrictedInstanceGroups,
|
|
637
|
+
),
|
|
638
|
+
RestrictedInstanceGroupsConfig:
|
|
639
|
+
news.restrictedInstanceGroupsConfig,
|
|
640
|
+
VpcConfig: news.vpcConfig,
|
|
641
|
+
Orchestrator: news.orchestrator,
|
|
642
|
+
NodeRecovery: news.nodeRecovery,
|
|
643
|
+
TieredStorageConfig: news.tieredStorageConfig,
|
|
644
|
+
NodeProvisioningMode: news.nodeProvisioningMode,
|
|
645
|
+
ClusterRole: news.clusterRole,
|
|
646
|
+
AutoScaling: news.autoScaling,
|
|
647
|
+
Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
|
|
648
|
+
Key,
|
|
649
|
+
Value,
|
|
650
|
+
})),
|
|
651
|
+
})
|
|
652
|
+
.pipe(
|
|
653
|
+
retryWhileRoleUnassumable,
|
|
654
|
+
Effect.catchTag("ResourceInUse", () => Effect.void),
|
|
655
|
+
);
|
|
656
|
+
yield* session.note(
|
|
657
|
+
`Creating HyperPod cluster ${name} (typically 10-25 minutes)...`,
|
|
658
|
+
);
|
|
659
|
+
} else {
|
|
660
|
+
// Sync — diff observed instance groups + mutable settings
|
|
661
|
+
// against desired and apply one update call for the delta.
|
|
662
|
+
const observedGroups = new Map(
|
|
663
|
+
(described.InstanceGroups ?? []).flatMap((g) =>
|
|
664
|
+
g.InstanceGroupName !== undefined
|
|
665
|
+
? [[g.InstanceGroupName, observedGroupIdentity(g)]]
|
|
666
|
+
: [],
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
const desiredGroups = new Map(
|
|
670
|
+
Object.entries(news.instanceGroups ?? {}).map(
|
|
671
|
+
([groupName, g]) => [groupName, desiredGroupIdentity(g)],
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
const groupsToDelete = [...observedGroups.keys()].filter(
|
|
675
|
+
(groupName) => !desiredGroups.has(groupName),
|
|
676
|
+
);
|
|
677
|
+
const groupsChanged =
|
|
678
|
+
groupsToDelete.length > 0 ||
|
|
679
|
+
[...desiredGroups].some(
|
|
680
|
+
([groupName, identity]) =>
|
|
681
|
+
JSON.stringify(observedGroups.get(groupName)) !==
|
|
682
|
+
JSON.stringify(identity),
|
|
683
|
+
);
|
|
684
|
+
const settingsChanged =
|
|
685
|
+
(news.nodeRecovery !== undefined &&
|
|
686
|
+
news.nodeRecovery !== described.NodeRecovery) ||
|
|
687
|
+
(news.nodeProvisioningMode !== undefined &&
|
|
688
|
+
news.nodeProvisioningMode !== described.NodeProvisioningMode) ||
|
|
689
|
+
(news.tieredStorageConfig !== undefined &&
|
|
690
|
+
JSON.stringify(news.tieredStorageConfig) !==
|
|
691
|
+
JSON.stringify(described.TieredStorageConfig)) ||
|
|
692
|
+
(news.autoScaling !== undefined &&
|
|
693
|
+
news.autoScaling.Mode !== described.AutoScaling?.Mode);
|
|
694
|
+
|
|
695
|
+
if (groupsChanged || settingsChanged) {
|
|
696
|
+
yield* sagemaker
|
|
697
|
+
.updateCluster({
|
|
698
|
+
ClusterName: name,
|
|
699
|
+
InstanceGroups: toGroupSpecs(news.instanceGroups),
|
|
700
|
+
RestrictedInstanceGroups: toGroupSpecs(
|
|
701
|
+
news.restrictedInstanceGroups,
|
|
702
|
+
),
|
|
703
|
+
RestrictedInstanceGroupsConfig:
|
|
704
|
+
news.restrictedInstanceGroupsConfig,
|
|
705
|
+
TieredStorageConfig: news.tieredStorageConfig,
|
|
706
|
+
NodeRecovery: news.nodeRecovery,
|
|
707
|
+
InstanceGroupsToDelete:
|
|
708
|
+
groupsToDelete.length > 0 ? groupsToDelete : undefined,
|
|
709
|
+
NodeProvisioningMode: news.nodeProvisioningMode,
|
|
710
|
+
ClusterRole: news.clusterRole,
|
|
711
|
+
AutoScaling: news.autoScaling,
|
|
712
|
+
})
|
|
713
|
+
.pipe(Effect.catchTag("ResourceNotFound", () => Effect.void));
|
|
714
|
+
yield* session.note(`Updating HyperPod cluster ${name}...`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Converge to InService (provisioning is asynchronous).
|
|
719
|
+
yield* waitForCluster(name, "InService");
|
|
720
|
+
described = yield* describeClusterOrUndefined(name);
|
|
721
|
+
if (described === undefined) {
|
|
722
|
+
return yield* Effect.fail(
|
|
723
|
+
new Error(`failed to read reconciled HyperPod cluster ${name}`),
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const attrs = toAttrs(described);
|
|
727
|
+
|
|
728
|
+
// Sync tags — diff against OBSERVED cloud tags.
|
|
729
|
+
const currentTags = yield* fetchClusterTags(attrs.clusterArn);
|
|
730
|
+
const { removed, upsert } = diffTags(currentTags, desiredTags);
|
|
731
|
+
if (removed.length > 0) {
|
|
732
|
+
yield* sagemaker.deleteTags({
|
|
733
|
+
ResourceArn: attrs.clusterArn,
|
|
734
|
+
TagKeys: removed,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (upsert.length > 0) {
|
|
738
|
+
yield* sagemaker.addTags({
|
|
739
|
+
ResourceArn: attrs.clusterArn,
|
|
740
|
+
Tags: upsert.map(({ Key, Value }) => ({ Key, Value })),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
yield* session.note(attrs.clusterArn);
|
|
745
|
+
return attrs;
|
|
746
|
+
}),
|
|
747
|
+
delete: Effect.fn(function* ({ output }) {
|
|
748
|
+
const described = yield* describeClusterOrUndefined(
|
|
749
|
+
output.clusterName,
|
|
750
|
+
);
|
|
751
|
+
if (described === undefined) return;
|
|
752
|
+
// A cluster mid-create/update rejects deletion with a Conflict —
|
|
753
|
+
// wait for it to settle first, then delete. A cluster stuck in
|
|
754
|
+
// `Failed` (or one that never converges) is still deletable. One
|
|
755
|
+
// already `Deleting` (a previous attempt) skips straight to the
|
|
756
|
+
// Gone wait instead of burning the full InService wait budget.
|
|
757
|
+
if (described.ClusterStatus !== "Deleting") {
|
|
758
|
+
yield* waitForCluster(output.clusterName, "InService").pipe(
|
|
759
|
+
Effect.catchTag(
|
|
760
|
+
["ClusterFailed", "ClusterNotReady"],
|
|
761
|
+
() => Effect.void,
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
yield* sagemaker
|
|
765
|
+
.deleteCluster({ ClusterName: output.clusterName })
|
|
766
|
+
.pipe(
|
|
767
|
+
Effect.catchTag("ResourceNotFound", () => Effect.void),
|
|
768
|
+
Effect.catchTag("ConflictException", () => Effect.void),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
yield* waitForCluster(output.clusterName, "Gone");
|
|
772
|
+
}),
|
|
773
|
+
};
|
|
774
|
+
}),
|
|
775
|
+
);
|