Coverage for gco / slurm_config.py: 100.00%
418 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Schema, defaults and rendering for Slurm on GCO (``helm.slurm``).
3GCO runs Slurm on each regional EKS cluster through the Slinky slurm-operator
4when ``cdk.json`` ``helm.slurm.enabled`` is true (off by default;
5``helm_enabled_overrides=slurm`` forces it on for one deploy). The block is
6the whole description of the cluster an operator gets, the Slurm equivalent of
7an AWS PCS cluster definition:
9- ``partitions`` are the compute node groups. Each one becomes a Slinky
10 ``NodeSet`` (a pool of identically sized ``slurmd`` pods scheduled onto one
11 of GCO's Karpenter NodePools) and the Slurm partition that spans it. Pod
12 size is node size: Slinky sizes the Slurm node from the pod's resources and
13 Karpenter picks an EC2 instance that fits it, so ``cpu``/``memory``/``gpus``
14 stand in for PCS's instance type.
15- ``min_nodes``/``max_nodes`` are the scaling bounds. A partition whose bounds
16 differ is autoscaled on Slurm's own queue metrics by a KEDA ``ScaledObject``
17 the chart renders through ``extraObjects`` (:func:`slurm_chart_values`),
18 scaling the NodeSet to zero when idle; Karpenter then consolidates the
19 nodes.
20- ``accounting`` is slurmdbd with an in-cluster MariaDB (or an external
21 database the operator already runs), ``metrics`` is slurmctld's native
22 OpenMetrics endpoint, ``login`` is the sackd login pod ``gco slurm shell``
23 execs into, and ``slurm_conf``/``slurmdbd_conf`` pass extra configuration
24 through to the daemons.
26This module owns the block's shape so the config loader, the regional stack,
27the kubectl-applier manifests, the ``gco slurm`` CLI and the live validation
28harness agree on names and defaults. Like :mod:`gco.argocd_config` it imports
29nothing from ``aws_cdk``: the CLI runs without the CDK toolchain installed. It
30raises :class:`SlurmConfigError` (a ``ValueError``); the loader re-raises with
31its own exception type.
32"""
34from __future__ import annotations
36import copy
37import re
38from collections.abc import Iterable, Mapping
39from typing import Any
41from gco.resource_governance import parse_k8s_quantity
43# ---------------------------------------------------------------------------
44# Fixed names shared by the chart values, the manifests, the API and the CLI
45# ---------------------------------------------------------------------------
47#: ``cdk.json`` ``helm`` block key (and ``helm_enabled_overrides`` name).
48SLURM_HELM_KEY = "slurm"
50#: charts.yaml entries (the Helm release names).
51SLURM_OPERATOR_CHART_NAME = "slinky-slurm-operator"
52SLURM_CLUSTER_CHART_NAME = "slinky-slurm"
54#: The Slurm cluster lives with the user workloads; the operator has its own
55#: namespace (charts.yaml ``slinky-slurm-operator.namespace``).
56SLURM_NAMESPACE = "gco-jobs"
57SLURM_OPERATOR_NAMESPACE = "slurm-operator"
59#: Objects the ``slurm`` chart and the operator derive from the release name
60#: ``slinky-slurm``: the Controller CR is the release name, its StatefulSet and
61#: Service are ``<release>-controller``, the RestApi Deployment and Service are
62#: ``<release>-restapi``, NodeSets are ``<release>-worker-<key>`` and the
63#: LoginSet Deployment/Service is ``<release>-login-<key>``. The two auth
64#: Secrets hold the cluster key (``slurm.key``) and the JWT signing key
65#: (``jwt.key``).
66SLURM_CONTROLLER_NAME = "slinky-slurm-controller"
67SLURM_RESTAPI_NAME = "slinky-slurm-restapi"
68SLURM_ACCOUNTING_NAME = "slinky-slurm-accounting"
69SLURM_LOGIN_SET_KEY = "gco"
70SLURM_LOGIN_NAME = f"{SLURM_CLUSTER_CHART_NAME}-login-{SLURM_LOGIN_SET_KEY}"
71SLURM_JWT_SECRET = "slinky-slurm-auth-jwt" # nosec B105 # Kubernetes Secret name, not its contents
72SLURM_KEY_SECRET = "slinky-slurm-auth-slurm" # nosec B105 # Kubernetes Secret name, not its contents
74#: Daemon ports (Slurm defaults; the operator does not change them). slurmctld
75#: also serves the OpenMetrics endpoints on its own port.
76SLURM_CONTROLLER_PORT = 6817
77SLURM_SLURMD_PORT = 6818
78SLURM_SLURMDBD_PORT = 6819
79SLURM_RESTAPI_PORT = 6820
81#: Pod labels the operator stamps (``app.kubernetes.io/name`` per component;
82#: every Slurm pod also carries the chart's ``app.kubernetes.io/part-of: slurm``).
83SLURM_COMPONENT_LABEL = "app.kubernetes.io/name"
84SLURM_CONTROLLER_COMPONENT = "slurmctld"
85SLURM_RESTAPI_COMPONENT = "slurmrestd"
86SLURM_WORKER_COMPONENT = "slurmd"
87SLURM_ACCOUNTING_COMPONENT = "slurmdbd"
88SLURM_LOGIN_COMPONENT = "login"
89SLURM_PART_OF_LABEL = "app.kubernetes.io/part-of"
90SLURM_PART_OF_VALUE = "slurm"
92#: The in-cluster accounting database (29-slurm-accounting-db.yaml):
93#: one StatefulSet, Service and generated-password Secret of this name, and
94#: the slurmdbd connection constants the chart values point at it with.
95SLURM_ACCOUNTING_DB_NAME = "slinky-slurm-mariadb"
96SLURM_ACCOUNTING_DB_SECRET = SLURM_ACCOUNTING_DB_NAME # nosec B105 # Kubernetes Secret name
97SLURM_ACCOUNTING_DB_PASSWORD_KEY = "password" # nosec B105 # Secret data key, not a password
98SLURM_ACCOUNTING_DB_ROOT_PASSWORD_KEY = "root-password" # nosec B105 # Secret data key
99SLURM_ACCOUNTING_DB_PORT = 3306
100SLURM_ACCOUNTING_DATABASE = "slurm_acct_db"
101SLURM_ACCOUNTING_USER = "slurm"
103#: Where the accounting backup CronJob writes its dumps in the regional shared
104#: bucket (``<prefix><cluster name>/<UTC timestamp>.sql.gz``); the regional
105#: stack's lifecycle rule expires objects under this prefix.
106SLURM_ACCOUNTING_BACKUP_PREFIX = "slurm/accounting-backups/"
108#: Block storage for slurmctld's state directory and the accounting database:
109#: a gp3 StorageClass shipped by the gated base manifest 28-storage-slurm-gp3.yaml.
110SLURM_STORAGE_CLASS = "gco-slurm-gp3"
112#: Shared filesystems mounted into worker and login pods. The EFS claim is the
113#: always-present ``gco-jobs`` claim; ``/home`` is a subdirectory of it so
114#: Slurm home directories never mix with job output written to the claim root.
115#: The FSx claim exists only when ``fsx_lustre`` is enabled.
116SLURM_EFS_CLAIM = "gco-shared-storage"
117SLURM_HOME_SUBPATH = "slurm/home"
118SLURM_HOME_MOUNT_PATH = "/home"
119SLURM_FSX_CLAIM = "gco-fsx-storage"
120SLURM_SCRATCH_MOUNT_PATH = "/scratch"
122#: The control-plane pods (slurmctld, slurmrestd, slurmdbd) run at the
123#: platform priority (05-priority-classes.yaml) so they preempt user pods
124#: under pressure instead of being starved; workers deliberately do not.
125SLURM_PLATFORM_PRIORITY_CLASS = "gco-platform-critical"
127#: Where the KEDA Prometheus scaler reads Slurm's metrics (kube-prometheus-stack).
128SLURM_PROMETHEUS_URL = "http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090"
130#: The Karpenter NodePools a partition may target (lambda/kubectl-applier-simple/
131#: manifests/4x-nodepool-*.yaml): the node label that selects the pool, the
132#: taints its nodes carry (the slurmd pods tolerate exactly those), and what
133#: accelerators its instances have. ``neuron`` is deliberately absent: Slurm's
134#: gres plugin has no Trainium support.
135NODE_POOLS: dict[str, dict[str, Any]] = {
136 "cpu": {"selector": {"workload-type": "cpu"}, "taints": (), "gpu": False, "efa": False},
137 "gpu": {
138 "selector": {"workload-type": "gpu"},
139 "taints": ("nvidia.com/gpu",),
140 "gpu": True,
141 "efa": False,
142 },
143 "gpu-efa": {
144 "selector": {"workload-type": "gpu-efa"},
145 "taints": ("nvidia.com/gpu", "vpc.amazonaws.com/efa"),
146 "gpu": True,
147 "efa": True,
148 },
149}
151#: ``karpenter.sh/capacity-type`` choices; ``any`` leaves the pool's own mix.
152CAPACITY_TYPES: tuple[str, ...] = ("any", "spot", "on-demand")
153CAPACITY_TYPE_LABEL = "karpenter.sh/capacity-type"
155#: Slurm's ``MaxNodeCount`` as the operator configures it; a NodeSet cannot
156#: usefully grow past it.
157MAX_NODES_LIMIT = 1024
159#: Partition names double as NodeSet keys: the operator names pods
160#: ``<name>-<ordinal>`` and the NodeSet ``slinky-slurm-worker-<name>``, so the
161#: name must be a DNS label short enough for both.
162PARTITION_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$")
163#: The chart's own ``partitions.all`` key (GCO disables it, see values).
164RESERVED_PARTITION_NAMES: frozenset[str] = frozenset({"all"})
166#: Slurm time limits: minutes, min:sec, hours:min:sec, days-hours,
167#: days-hours:min, days-hours:min:sec, or unlimited.
168SLURM_TIME_PATTERN = re.compile(
169 r"^(UNLIMITED|INFINITE|\d+|\d+:\d{2}|\d+:\d{2}:\d{2}|\d+-\d{1,2}|\d+-\d{1,2}:\d{2}|\d+-\d{1,2}:\d{2}:\d{2})$"
170)
172#: Kubernetes label values: ``features`` become Slurm node Features and are
173#: also the only free-form text that reaches slurm.conf from a partition.
174FEATURE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$")
176#: Slurm ``ClusterName``: letters, digits, ``_`` and ``-``; slurmdbd uses it
177#: as a table-name prefix, hence the conservative character set and length.
178CLUSTER_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$")
179#: Keys an operator may set in ``slurm_conf`` / ``slurmdbd_conf``.
180CONF_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,63}$")
182#: slurm.conf keys the operator writes itself; overriding them through
183#: ``extraConf`` either breaks the operator's reconciliation (auth, ports,
184#: hosts) or duplicates a key Slurm rejects. The webhook rejects whole files
185#: named slurm.conf/slurmdbd.conf; this is the per-key equivalent.
186OPERATOR_OWNED_SLURM_CONF_KEYS: frozenset[str] = frozenset(
187 {
188 "ClusterName",
189 "SlurmUser",
190 "SlurmdUser",
191 "SlurmctldHost",
192 "SlurmctldPort",
193 "SlurmdPort",
194 "SlurmdSpoolDir",
195 "StateSaveLocation",
196 "SlurmctldLogFile",
197 "SlurmSchedLogFile",
198 "SlurmdLogFile",
199 "AuthType",
200 "CredType",
201 "AuthAltTypes",
202 "MetricsType",
203 "AccountingStorageType",
204 "AccountingStorageHost",
205 "AccountingStoragePort",
206 "Include",
207 "NodeName",
208 "NodeSet",
209 "PartitionName",
210 "Prolog",
211 "Epilog",
212 "PrologSlurmctld",
213 "EpilogSlurmctld",
214 }
215)
216OPERATOR_OWNED_SLURMDBD_CONF_KEYS: frozenset[str] = frozenset(
217 {
218 "DbdHost",
219 "DbdPort",
220 "SlurmUser",
221 "StorageType",
222 "StorageHost",
223 "StoragePort",
224 "StorageLoc",
225 "StorageUser",
226 "StoragePass",
227 "AuthType",
228 "AuthAltTypes",
229 "AuthAltParameters",
230 "LogFile",
231 "PidFile",
232 }
233)
235#: Per-partition defaults for keys an operator leaves out.
236PARTITION_DEFAULTS: dict[str, Any] = {
237 "enabled": True,
238 "node_pool": "cpu",
239 "cpu": "4",
240 "memory": "16Gi",
241 "gpus": 0,
242 "efa_interfaces": 0,
243 "capacity_type": "any",
244 "min_nodes": 0,
245 "max_nodes": 4,
246 "default": False,
247 "max_time": "UNLIMITED",
248 "exclusive": False,
249 "features": [],
250}
252#: The block with every knob at its default. Slurm is off; when it is on, the
253#: shipped partitions give a CPU partition and a single-GPU partition that
254#: both scale from zero, plus a disabled template for multi-node EFA training
255#: (p5-class: eight GPUs, 32 EFA interfaces, whole-node exclusive jobs).
256SLURM_DEFAULTS: dict[str, Any] = {
257 "enabled": False,
258 # Slurm's ClusterName; also the accounting cluster record.
259 "cluster_name": "gco-slurm",
260 "controller": {
261 # slurmctld StateSaveLocation: a dedicated gp3 volume (the chart
262 # creates the claim on SLURM_STORAGE_CLASS).
263 "state_storage_size": "20Gi",
264 },
265 "restapi": {
266 # Two slurmrestd replicas behind one Service, with a PDB keeping one
267 # up through node drains: the GCO API's path into Slurm.
268 "replicas": 2,
269 },
270 "accounting": {
271 "enabled": True,
272 # In-cluster MariaDB (29-slurm-accounting-db.yaml).
273 "storage_size": "20Gi",
274 "backup": {
275 # Nightly mariadb-dump of slurm_acct_db to the regional shared
276 # bucket under slurm/accounting-backups/<cluster>/.
277 "enabled": True,
278 "schedule": "0 3 * * *",
279 "retention_days": 30,
280 },
281 # Point slurmdbd at a database the operator already runs (RDS for
282 # MariaDB, Aurora MySQL, ...) instead of the in-cluster one. host set
283 # = external; password_secret names a Secret in gco-jobs carrying the
284 # password under the given key.
285 "external": {
286 "host": "",
287 "port": SLURM_ACCOUNTING_DB_PORT,
288 "database": SLURM_ACCOUNTING_DATABASE,
289 "username": SLURM_ACCOUNTING_USER,
290 "password_secret": "", # nosec B105 # a Secret *name*; empty = in-cluster database
291 "password_key": SLURM_ACCOUNTING_DB_PASSWORD_KEY,
292 },
293 },
294 "metrics": {
295 # slurmctld OpenMetrics (MetricsType=metrics/openmetrics); scraped by
296 # the gco-slurm-controller ServiceMonitor when observability is on and
297 # read by the autoscaler. Only turn off for a cluster that has no
298 # autoscaled partition.
299 "enabled": True,
300 },
301 "login": {
302 # One sackd login pod (ClusterIP, no load balancer): the target of
303 # `gco slurm shell`, with /home mounted like the workers.
304 "enabled": True,
305 "replicas": 1,
306 },
307 "autoscaling": {
308 # Pending jobs per additional node (KEDA threshold for the pending-jobs
309 # trigger; 1 = one node per pending job).
310 "pending_jobs_threshold": 1,
311 # Idle time before an autoscaled partition is scaled to min_nodes (and
312 # to zero when min_nodes is 0).
313 "cooldown_seconds": 300,
314 "polling_interval_seconds": 30,
315 },
316 "partitions": {
317 "cpu": {
318 "node_pool": "cpu",
319 "cpu": "8",
320 "memory": "32Gi",
321 "gpus": 0,
322 "capacity_type": "any",
323 "min_nodes": 0,
324 "max_nodes": 8,
325 "default": True,
326 "max_time": "UNLIMITED",
327 "exclusive": False,
328 },
329 "gpu": {
330 "node_pool": "gpu",
331 "cpu": "8",
332 "memory": "32Gi",
333 "gpus": 1,
334 "capacity_type": "any",
335 "min_nodes": 0,
336 "max_nodes": 4,
337 "default": False,
338 "max_time": "2-00:00:00",
339 "exclusive": True,
340 },
341 "gpu-efa": {
342 "enabled": False,
343 "node_pool": "gpu-efa",
344 "cpu": "180",
345 "memory": "1800Gi",
346 "gpus": 8,
347 "efa_interfaces": 32,
348 "capacity_type": "on-demand",
349 "min_nodes": 0,
350 "max_nodes": 2,
351 "default": False,
352 "max_time": "7-00:00:00",
353 "exclusive": True,
354 },
355 },
356 "storage": {
357 # /home for workers and login pods: "efs" (the shared claim) or "none".
358 "home": "efs",
359 # /scratch: "auto" (FSx Lustre when fsx_lustre is enabled), "fsx"
360 # (require it) or "none".
361 "scratch": "auto",
362 },
363 # Extra slurm.conf / slurmdbd.conf lines (key -> value or list of values).
364 "slurm_conf": {},
365 "slurmdbd_conf": {},
366}
368#: Image pins GCO keeps in lockstep with the chart version in charts.yaml.
369#: The Slinky sidecars default to ``alpine:latest`` upstream; GCO pins them.
370#: ``tests/test_helm_charts_validation.py`` holds every charts.yaml image tag
371#: to these two constants.
372SLURM_IMAGE_TAG = "26.05.5-ubuntu24.04"
373SLURM_SIDECAR_IMAGE = {"repository": "public.ecr.aws/docker/library/alpine", "tag": "3.24.3"}
375#: The slurmrestd OpenAPI plugin version every GCO surface addresses
376#: (``/slurm/<version>/...``): the API client, the example job, the live
377#: validation probe and the API doc. It is a property of the Slurm release
378#: behind ``SLURM_IMAGE_TAG``: each release ships its newest ``data_parser``
379#: plugin plus the three before it and drops the oldest, so a tag bump must
380#: re-check this pin. ``validate_helm_charts.py --mode online`` does that in
381#: CI by running ``slurmrestd -d list`` inside the pinned image; the offline
382#: half holds the other surfaces to this constant. Request and response
383#: bodies differ between plugin versions, which is why the client is written
384#: against exactly one instead of negotiating at runtime.
385SLURM_API_VERSION = "v0.0.44"
388class SlurmConfigError(ValueError):
389 """Raised when the ``helm.slurm`` block is malformed."""
392# ---------------------------------------------------------------------------
393# Validation
394# ---------------------------------------------------------------------------
397def _deep_merge(defaults: Mapping[str, Any], overrides: Mapping[str, Any]) -> dict[str, Any]:
398 merged: dict[str, Any] = copy.deepcopy(dict(defaults))
399 for key, value in overrides.items():
400 if value is None and isinstance(merged.get(key), Mapping):
401 # JSON null for a nested block reads as "absent": keep its defaults.
402 continue
403 if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
404 merged[key] = _deep_merge(merged[key], value)
405 else:
406 merged[key] = copy.deepcopy(value)
407 return merged
410def _reject_unknown_keys(block: Mapping[str, Any], allowed: Iterable[str], path: str) -> None:
411 allowed_set = set(allowed)
412 unknown = sorted(str(key) for key in block if key not in allowed_set)
413 if unknown:
414 raise SlurmConfigError(
415 f"{path} contains unknown key(s): {', '.join(unknown)}; "
416 f"allowed keys: {', '.join(sorted(allowed_set))}"
417 )
420def _require_object(value: object, path: str) -> Mapping[str, Any]:
421 if not isinstance(value, Mapping):
422 raise SlurmConfigError(f"{path} must be an object, got {value!r}")
423 return value
426def _require_bool(value: object, path: str) -> bool:
427 if type(value) is not bool:
428 raise SlurmConfigError(f"{path} must be a boolean, got {value!r}")
429 return value
432def _require_int(value: object, bounds: tuple[int, int], path: str) -> int:
433 """An exact integer (``bool`` rejected) inside the inclusive ``bounds``."""
434 low, high = bounds
435 if type(value) is not int or not low <= value <= high:
436 raise SlurmConfigError(f"{path} must be an integer between {low} and {high}, got {value!r}")
437 return value
440def _require_quantity(value: object, path: str) -> float:
441 """A positive Kubernetes quantity (``"8"``, ``"500m"``, ``"32Gi"``, ``8``)."""
442 if type(value) is bool or not isinstance(value, str | int | float):
443 raise SlurmConfigError(f"{path} must be a Kubernetes quantity, got {value!r}")
444 try:
445 parsed = parse_k8s_quantity(str(value))
446 except ValueError as exc:
447 raise SlurmConfigError(f"{path}={value!r} is not a valid Kubernetes quantity") from exc
448 if parsed <= 0:
449 raise SlurmConfigError(f"{path} must be greater than zero, got {value!r}")
450 return parsed
453def _require_nonempty_string(value: object, path: str) -> str:
454 if not isinstance(value, str) or not value.strip():
455 raise SlurmConfigError(f"{path} must be a non-empty string, got {value!r}")
456 return value.strip()
459def _validate_conf_map(raw: object, path: str, owned: frozenset[str]) -> dict[str, list[str]]:
460 """Validate an ``extraConfMap``-shaped block: key -> string or list of strings."""
461 block = _require_object(raw, path)
462 normalized: dict[str, list[str]] = {}
463 for key, value in block.items():
464 key_text = str(key)
465 if not CONF_KEY_PATTERN.match(key_text):
466 raise SlurmConfigError(
467 f"{path} key {key_text!r} is not a Slurm configuration parameter name"
468 )
469 if key_text in owned:
470 raise SlurmConfigError(
471 f"{path}.{key_text} is managed by the slurm-operator and cannot be overridden"
472 )
473 values = value if isinstance(value, list) else [value]
474 rendered: list[str] = []
475 for item in values:
476 if type(item) is bool or not isinstance(item, str | int | float):
477 raise SlurmConfigError(
478 f"{path}.{key_text} must be a string, a number or a list of them, got {value!r}"
479 )
480 text = str(item).strip()
481 if not text or "\n" in text:
482 raise SlurmConfigError(
483 f"{path}.{key_text} values must be non-empty single-line strings, got {item!r}"
484 )
485 rendered.append(text)
486 if not rendered:
487 raise SlurmConfigError(f"{path}.{key_text} must not be an empty list")
488 normalized[key_text] = rendered
489 return normalized
492def _validate_partition(name: str, raw: object) -> dict[str, Any]:
493 path = f"helm.slurm.partitions.{name}"
494 if not PARTITION_NAME_PATTERN.match(name):
495 raise SlurmConfigError(
496 f"helm.slurm.partitions has an invalid partition name {name!r}: use a lowercase "
497 "DNS label of at most 32 characters (it names the NodeSet and its pods)"
498 )
499 if name in RESERVED_PARTITION_NAMES:
500 raise SlurmConfigError(
501 f"helm.slurm.partitions.{name} is reserved by the Slinky chart; choose another name"
502 )
503 block = _require_object(raw, path)
504 _reject_unknown_keys(block, PARTITION_DEFAULTS.keys(), path)
505 partition = _deep_merge(PARTITION_DEFAULTS, block)
507 _require_bool(partition["enabled"], f"{path}.enabled")
508 _require_bool(partition["default"], f"{path}.default")
509 _require_bool(partition["exclusive"], f"{path}.exclusive")
510 pool_name = partition["node_pool"]
511 if pool_name not in NODE_POOLS:
512 raise SlurmConfigError(
513 f"{path}.node_pool must be one of {', '.join(sorted(NODE_POOLS))}, got {pool_name!r}"
514 )
515 pool = NODE_POOLS[pool_name]
516 cpu = _require_quantity(partition["cpu"], f"{path}.cpu")
517 if cpu < 1 or cpu != int(cpu):
518 raise SlurmConfigError(
519 f"{path}.cpu must be a whole number of cores (a Slurm node advertises whole CPUs), "
520 f"got {partition['cpu']!r}"
521 )
522 memory = _require_quantity(partition["memory"], f"{path}.memory")
523 if memory < 1024 * 1024 * 1024:
524 raise SlurmConfigError(
525 f"{path}.memory must be at least 1Gi (slurmd and the job need room inside the "
526 f"pod's limit), got {partition['memory']!r}"
527 )
528 gpus = _require_int(partition["gpus"], (0, 64), f"{path}.gpus")
529 efa = _require_int(partition["efa_interfaces"], (0, 64), f"{path}.efa_interfaces")
530 if gpus and not pool["gpu"]:
531 raise SlurmConfigError(
532 f"{path}.gpus is {gpus} but node_pool {pool_name!r} has no GPUs; use gpu or gpu-efa"
533 )
534 if pool["gpu"] and not gpus:
535 raise SlurmConfigError(
536 f"{path}.node_pool {pool_name!r} schedules onto GPU nodes but gpus is 0; request at "
537 "least one GPU or use the cpu pool"
538 )
539 if efa and not pool["efa"]:
540 raise SlurmConfigError(
541 f"{path}.efa_interfaces is {efa} but node_pool {pool_name!r} has no EFA; use gpu-efa"
542 )
543 if pool["efa"] and not efa:
544 raise SlurmConfigError(
545 f"{path}.node_pool {pool_name!r} is the EFA pool but efa_interfaces is 0; request the "
546 "instance's EFA interfaces (32 on p5-class instances) or use the gpu pool"
547 )
548 if partition["capacity_type"] not in CAPACITY_TYPES:
549 raise SlurmConfigError(
550 f"{path}.capacity_type must be one of {', '.join(CAPACITY_TYPES)}, "
551 f"got {partition['capacity_type']!r}"
552 )
553 min_nodes = _require_int(partition["min_nodes"], (0, MAX_NODES_LIMIT), f"{path}.min_nodes")
554 max_nodes = _require_int(partition["max_nodes"], (1, MAX_NODES_LIMIT), f"{path}.max_nodes")
555 if min_nodes > max_nodes:
556 raise SlurmConfigError(
557 f"{path}.min_nodes must not exceed max_nodes, got {min_nodes} > {max_nodes}"
558 )
559 max_time = partition["max_time"]
560 if not isinstance(max_time, str) or not SLURM_TIME_PATTERN.match(max_time.strip()):
561 raise SlurmConfigError(
562 f"{path}.max_time must be a Slurm time limit (minutes, HH:MM:SS, D-HH:MM:SS or "
563 f"UNLIMITED), got {max_time!r}"
564 )
565 partition["max_time"] = max_time.strip()
566 features = partition["features"]
567 if not isinstance(features, list) or not all(
568 isinstance(item, str) and FEATURE_PATTERN.match(item) for item in features
569 ):
570 raise SlurmConfigError(
571 f"{path}.features must be a list of Slurm feature names (letters, digits, _ . -), "
572 f"got {features!r}"
573 )
574 if len(set(features)) != len(features):
575 raise SlurmConfigError(f"{path}.features lists a feature twice")
576 partition["_cpu_cores"] = cpu
577 partition["_memory_bytes"] = memory
578 return partition
581def _validate_partitions(raw: object) -> dict[str, dict[str, Any]]:
582 block = _require_object(raw, "helm.slurm.partitions")
583 partitions = {str(name): _validate_partition(str(name), spec) for name, spec in block.items()}
584 enabled = {name: spec for name, spec in partitions.items() if spec["enabled"]}
585 if not enabled:
586 raise SlurmConfigError(
587 "helm.slurm.partitions must declare at least one enabled partition when Slurm is on"
588 )
589 defaults = sorted(name for name, spec in enabled.items() if spec["default"])
590 if len(defaults) != 1:
591 raise SlurmConfigError(
592 "helm.slurm.partitions must mark exactly one enabled partition default: true "
593 f"(sbatch without --partition goes there), found {defaults or 'none'}"
594 )
595 return partitions
598def _quota_quantity(resource_quota: Mapping[str, Any], key: str) -> float | None:
599 """A quota ceiling as a float, ``None`` when absent or unparseable.
601 The regional stack validates ``resource_quota`` itself and reports a bad
602 value with its own message; this module only compares against values it
603 can read, so a malformed quota never produces a second, Slurm-flavored
604 error for the same typo.
605 """
606 value = resource_quota.get(key)
607 if value is None:
608 return None
609 try:
610 return parse_k8s_quantity(str(value))
611 except ValueError:
612 return None
615def _validate_quota_fit(
616 config: Mapping[str, Any], resource_quota: Mapping[str, Any] | None
617) -> None:
618 """Fail when the declared partitions can never fit the gco-jobs quota.
620 Slurm worker pods live in ``gco-jobs`` and count against its
621 ``ResourceQuota`` and ``LimitRange`` (04-resource-quotas.yaml). A partition
622 whose node is larger than the per-container ceiling, or whose
623 ``max_nodes`` worth of nodes exceeds the namespace ceiling on its own,
624 would deploy fine and then sit PENDING forever with the reason visible only
625 in namespace events. Check the certain failures at synth; the quota is
626 shared with other job pods, so fitting here is necessary, not sufficient.
627 """
628 if not resource_quota:
629 return
630 enabled = {n: p for n, p in config["partitions"].items() if p["enabled"]}
631 container_cpu = _quota_quantity(resource_quota, "container_max_cpu")
632 container_memory = _quota_quantity(resource_quota, "container_max_memory")
633 container_gpu = _quota_quantity(resource_quota, "container_max_gpu")
634 for name, spec in enabled.items():
635 path = f"helm.slurm.partitions.{name}"
636 if container_cpu is not None and spec["_cpu_cores"] > container_cpu:
637 raise SlurmConfigError(
638 f"{path}.cpu={spec['cpu']!r} exceeds resource_quota.container_max_cpu="
639 f"{resource_quota['container_max_cpu']!r}: the LimitRange would reject every "
640 "worker pod"
641 )
642 if container_memory is not None and spec["_memory_bytes"] > container_memory:
643 raise SlurmConfigError(
644 f"{path}.memory={spec['memory']!r} exceeds resource_quota.container_max_memory="
645 f"{resource_quota['container_max_memory']!r}: the LimitRange would reject every "
646 "worker pod"
647 )
648 if container_gpu is not None and spec["gpus"] > container_gpu:
649 raise SlurmConfigError(
650 f"{path}.gpus={spec['gpus']} exceeds resource_quota.container_max_gpu="
651 f"{resource_quota['container_max_gpu']!r}: the LimitRange would reject every "
652 "worker pod"
653 )
654 totals = {
655 "max_cpu": sum(p["_cpu_cores"] * p["max_nodes"] for p in enabled.values()),
656 "max_memory": sum(p["_memory_bytes"] * p["max_nodes"] for p in enabled.values()),
657 "max_gpu": float(sum(p["gpus"] * p["max_nodes"] for p in enabled.values())),
658 "max_pods": float(
659 sum(p["max_nodes"] for p in enabled.values()) + control_plane_pods(config)
660 ),
661 }
662 for key, needed in totals.items():
663 ceiling = _quota_quantity(resource_quota, key)
664 if ceiling is not None and needed > ceiling:
665 raise SlurmConfigError(
666 f"helm.slurm.partitions at max_nodes need {needed:g} of resource_quota.{key} "
667 f"but the gco-jobs ceiling is {resource_quota[key]!r}; lower max_nodes or raise "
668 "the quota (workers share it with every other job pod)"
669 )
672def control_plane_pods(config: Mapping[str, Any]) -> int:
673 """Pods the cluster runs besides workers: slurmctld, slurmrestd, slurmdbd, MariaDB, login."""
674 count = 1 + int(config["restapi"]["replicas"])
675 if config["accounting"]["enabled"]:
676 count += 1
677 if accounting_in_cluster(config):
678 count += 1
679 if config["login"]["enabled"]:
680 count += int(config["login"]["replicas"])
681 return count
684def validate_slurm_config(
685 raw: object,
686 *,
687 resource_quota: Mapping[str, Any] | None = None,
688 observability_enabled: bool = True,
689 fsx_enabled: bool = False,
690) -> dict[str, Any]:
691 """Validate the raw ``helm.slurm`` block and return it with defaults filled.
693 ``None`` means "absent" and yields the defaults (Slurm off). Unknown keys
694 fail by name at every level. The deployment-wide facts that decide whether
695 the block can work are passed in: the effective ``resource_quota`` (worker
696 pods must fit the gco-jobs ceilings), whether ``cluster_observability`` is
697 on (autoscaling reads Slurm's metrics through Prometheus) and whether
698 ``fsx_lustre`` is on (``storage.scratch: fsx``).
700 The returned mapping carries two derived, underscore-prefixed numbers per
701 partition (``_cpu_cores``, ``_memory_bytes``) for the renderer; everything
702 else is the operator's block with defaults merged.
703 """
704 if raw is None:
705 config = copy.deepcopy(SLURM_DEFAULTS)
706 config["partitions"] = _validate_partitions(config["partitions"])
707 return config
708 block = _require_object(raw, "helm.slurm")
709 _reject_unknown_keys(block, SLURM_DEFAULTS.keys(), "helm.slurm")
710 for key in (
711 "controller",
712 "restapi",
713 "accounting",
714 "metrics",
715 "login",
716 "autoscaling",
717 "storage",
718 ):
719 sub = block.get(key)
720 if sub is None:
721 continue
722 sub_block = _require_object(sub, f"helm.slurm.{key}")
723 _reject_unknown_keys(sub_block, SLURM_DEFAULTS[key].keys(), f"helm.slurm.{key}")
724 if key == "accounting":
725 for nested in ("backup", "external"):
726 nested_raw = sub_block.get(nested)
727 if nested_raw is None:
728 continue
729 nested_block = _require_object(nested_raw, f"helm.slurm.accounting.{nested}")
730 _reject_unknown_keys(
731 nested_block,
732 SLURM_DEFAULTS["accounting"][nested].keys(),
733 f"helm.slurm.accounting.{nested}",
734 )
735 # Partitions merge like every other block: the shipped cpu/gpu/gpu-efa
736 # definitions are always present, an entry of the same name deep-merges
737 # over the shipped one (``gpu: {max_nodes: 8}`` keeps the GPU pool), a new
738 # name starts from PARTITION_DEFAULTS, and ``enabled: false`` removes one.
739 config = _deep_merge(SLURM_DEFAULTS, block)
741 _require_bool(config["enabled"], "helm.slurm.enabled")
742 cluster_name = _require_nonempty_string(config["cluster_name"], "helm.slurm.cluster_name")
743 if not CLUSTER_NAME_PATTERN.match(cluster_name):
744 raise SlurmConfigError(
745 f"helm.slurm.cluster_name must be a Slurm ClusterName (letters, digits, _ -), "
746 f"got {cluster_name!r}"
747 )
748 config["cluster_name"] = cluster_name
749 _require_quantity(
750 config["controller"]["state_storage_size"], "helm.slurm.controller.state_storage_size"
751 )
752 _require_int(config["restapi"]["replicas"], (1, 10), "helm.slurm.restapi.replicas")
754 accounting = config["accounting"]
755 _require_bool(accounting["enabled"], "helm.slurm.accounting.enabled")
756 _require_quantity(accounting["storage_size"], "helm.slurm.accounting.storage_size")
757 backup = accounting["backup"]
758 _require_bool(backup["enabled"], "helm.slurm.accounting.backup.enabled")
759 _validate_cron(backup["schedule"], "helm.slurm.accounting.backup.schedule")
760 _require_int(backup["retention_days"], (1, 3650), "helm.slurm.accounting.backup.retention_days")
761 external = accounting["external"]
762 for key in ("host", "database", "username", "password_secret", "password_key"):
763 if not isinstance(external[key], str):
764 raise SlurmConfigError(
765 f"helm.slurm.accounting.external.{key} must be a string, got {external[key]!r}"
766 )
767 external[key] = external[key].strip()
768 _require_int(external["port"], (1, 65535), "helm.slurm.accounting.external.port")
769 if external["host"]:
770 for key in ("database", "username", "password_secret", "password_key"):
771 if not external[key]:
772 raise SlurmConfigError(
773 f"helm.slurm.accounting.external.{key} is required when external.host is set"
774 )
775 elif external["password_secret"]:
776 raise SlurmConfigError(
777 "helm.slurm.accounting.external.password_secret is set but external.host is empty; "
778 "set the host to use an external database"
779 )
781 _require_bool(config["metrics"]["enabled"], "helm.slurm.metrics.enabled")
782 _require_bool(config["login"]["enabled"], "helm.slurm.login.enabled")
783 _require_int(config["login"]["replicas"], (1, 10), "helm.slurm.login.replicas")
784 autoscaling = config["autoscaling"]
785 _require_int(
786 autoscaling["pending_jobs_threshold"],
787 (1, 1000),
788 "helm.slurm.autoscaling.pending_jobs_threshold",
789 )
790 _require_int(
791 autoscaling["cooldown_seconds"], (60, 86400), "helm.slurm.autoscaling.cooldown_seconds"
792 )
793 _require_int(
794 autoscaling["polling_interval_seconds"],
795 (5, 3600),
796 "helm.slurm.autoscaling.polling_interval_seconds",
797 )
799 storage = config["storage"]
800 if storage["home"] not in ("efs", "none"):
801 raise SlurmConfigError(
802 f"helm.slurm.storage.home must be 'efs' or 'none', got {storage['home']!r}"
803 )
804 if storage["scratch"] not in ("auto", "fsx", "none"):
805 raise SlurmConfigError(
806 f"helm.slurm.storage.scratch must be 'auto', 'fsx' or 'none', got {storage['scratch']!r}"
807 )
808 if storage["scratch"] == "fsx" and not fsx_enabled:
809 raise SlurmConfigError(
810 "helm.slurm.storage.scratch is 'fsx' but fsx_lustre is not enabled for this "
811 "deployment; enable fsx_lustre or use 'auto'"
812 )
814 config["slurm_conf"] = _validate_conf_map(
815 config["slurm_conf"], "helm.slurm.slurm_conf", OPERATOR_OWNED_SLURM_CONF_KEYS
816 )
817 config["slurmdbd_conf"] = _validate_conf_map(
818 config["slurmdbd_conf"], "helm.slurm.slurmdbd_conf", OPERATOR_OWNED_SLURMDBD_CONF_KEYS
819 )
821 config["partitions"] = _validate_partitions(config["partitions"])
822 if config["enabled"]:
823 if autoscaled_partitions(config) and not observability_enabled:
824 names = ", ".join(autoscaled_partitions(config))
825 raise SlurmConfigError(
826 f"helm.slurm.partitions {names} autoscale (min_nodes < max_nodes), which reads "
827 "Slurm's queue metrics through Prometheus, but cluster_observability is disabled; "
828 "enable it or set min_nodes equal to max_nodes for a fixed-size partition"
829 )
830 if autoscaled_partitions(config) and not config["metrics"]["enabled"]:
831 raise SlurmConfigError(
832 "helm.slurm.metrics.enabled is false but a partition autoscales; the autoscaler "
833 "reads slurmctld's metrics"
834 )
835 _validate_quota_fit(config, resource_quota)
836 return config
839_CRON_FIELD = r"(\*|\d+|\*/\d+|\d+-\d+|\d+(,\d+)+)"
840_CRON_PATTERN = re.compile(rf"^{_CRON_FIELD}(\s+{_CRON_FIELD}){{4}}$")
843def _validate_cron(value: object, path: str) -> None:
844 if not isinstance(value, str) or not _CRON_PATTERN.match(value.strip()):
845 raise SlurmConfigError(
846 f"{path} must be a five-field cron expression (e.g. '0 3 * * *'), got {value!r}"
847 )
850# ---------------------------------------------------------------------------
851# Derived views
852# ---------------------------------------------------------------------------
855def slurm_enabled(config: Mapping[str, Any]) -> bool:
856 """True when the block turns Slurm on (before any run-scoped override)."""
857 return bool(config.get("enabled"))
860def enabled_partitions(config: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
861 """The enabled partitions in declaration order."""
862 return {n: p for n, p in config["partitions"].items() if p["enabled"]}
865def autoscaled_partitions(config: Mapping[str, Any]) -> list[str]:
866 """Enabled partitions whose bounds differ (a KEDA ScaledObject each)."""
867 return [n for n, p in enabled_partitions(config).items() if p["min_nodes"] < p["max_nodes"]]
870def accounting_in_cluster(config: Mapping[str, Any]) -> bool:
871 """True when accounting is on and backed by the in-cluster MariaDB."""
872 accounting = config["accounting"]
873 return bool(accounting["enabled"]) and not accounting["external"]["host"]
876def scratch_backend(config: Mapping[str, Any], *, fsx_enabled: bool) -> str:
877 """``"fsx"`` or ``"none"``: what ``storage.scratch`` resolves to."""
878 scratch = str(config["storage"]["scratch"])
879 if scratch == "auto":
880 return "fsx" if fsx_enabled else "none"
881 return scratch
884def nodeset_name(partition: str) -> str:
885 """The NodeSet (and its pods' StatefulSet) for a partition."""
886 return f"{SLURM_CLUSTER_CHART_NAME}-worker-{partition}"
889def scaled_object_name(partition: str) -> str:
890 """The KEDA ScaledObject that autoscales a partition."""
891 return f"{SLURM_CLUSTER_CHART_NAME}-autoscale-{partition}"
894# ---------------------------------------------------------------------------
895# Chart values
896# ---------------------------------------------------------------------------
898#: The slurm.conf parameters GCO sets for every cluster, before the operator's
899#: block. select/cons_tres with CR_Core_Memory schedules cores and memory
900#: inside a worker pod; gres/gpu is tracked in accounting; ReturnToService=2
901#: brings a worker pod back into service as soon as it re-registers after a
902#: restart; the cgroup task plugin enforces the allocation; MinJobAge keeps
903#: finished jobs visible to the REST API long enough for the GCO API to read
904#: their final state; AccountingStoreFlags=job_comment keeps each job's
905#: comment in slurmdbd, which is where the GCO API records the submitting
906#: caller (``gco:<caller>``) for ``sacct`` attribution (verified live: without
907#: it the accounting record's comment is empty).
908BASE_SLURM_CONF: dict[str, list[str]] = {
909 "SelectType": ["select/cons_tres"],
910 "SelectTypeParameters": ["CR_Core_Memory"],
911 "ProctrackType": ["proctrack/cgroup"],
912 "TaskPlugin": ["task/cgroup", "task/affinity"],
913 "JobAcctGatherType": ["jobacct_gather/cgroup"],
914 "ReturnToService": ["2"],
915 "MinJobAge": ["600"],
916 "AccountingStoreFlags": ["job_comment"],
917 "PrologFlags": ["Contain"],
918 "SchedulerParameters": ["bf_continue", "bf_max_job_test=1000"],
919 "PriorityType": ["priority/multifactor"],
920 "PriorityWeightAge": ["1000"],
921 "PriorityWeightFairshare": ["10000"],
922 "PriorityWeightJobSize": ["1000"],
923 "PriorityWeightPartition": ["1000"],
924}
926#: Added when any enabled partition has GPUs.
927GPU_SLURM_CONF: dict[str, list[str]] = {
928 "GresTypes": ["gpu"],
929 "AccountingStorageTRES": ["gres/gpu"],
930}
932#: cgroup.conf: cgroup v2 (the operator's own two lines) plus constraints so a
933#: job sees only the cores, memory and GPU devices Slurm allocated to it.
934#: slurmd runs privileged in Slinky, which is what makes device constraints
935#: work inside a pod.
936CGROUP_CONF = (
937 "CgroupPlugin=cgroup/v2\n"
938 "IgnoreSystemd=yes\n"
939 "ConstrainCores=yes\n"
940 "ConstrainRAMSpace=yes\n"
941 "ConstrainDevices=yes\n"
942)
944#: gres.conf: discover the GPUs the NVIDIA runtime exposed to the worker pod
945#: from the driver's /proc and /sys files (Slurm's gpu/nvidia plugin), so no
946#: NVML library and no per-node device list is needed.
947GRES_CONF = "AutoDetect=nvidia\n"
949#: Prometheus queries the autoscaler scales on, per partition (``{partition}``
950#: is substituted). Both add the partition's busy nodes so a scale decision
951#: never drops below what is running; ``or vector(0)`` keeps a missing series
952#: from reading as "idle". The first grows one node per pending job (at the
953#: configured threshold); the second grows to the largest pending job's node
954#: count, which is what a multi-node job needs before it can start. KEDA's HPA
955#: takes the larger of the two.
956#:
957#: Busy is ALLOCATED *or* MIXED: Slurm reports a node whose CPUs are only
958#: partly allocated as MIXED, and ``slurm_partition_nodes_alloc`` counts the
959#: fully allocated ones alone. Counting only those, a worker running one
960#: 1-CPU job read as idle, KEDA scaled the NodeSet to zero after the cooldown,
961#: and the operator drained the busy node while its job ran (observed live,
962#: 2026-10-28).
963_BUSY_NODES = (
964 '(sum(slurm_partition_nodes_alloc{{partition="{partition}"}}) or vector(0)) + '
965 '(sum(slurm_partition_nodes_mixed{{partition="{partition}"}}) or vector(0))'
966)
967PENDING_JOBS_QUERY = (
968 '(sum(slurm_partition_jobs_pending{{partition="{partition}"}}) or vector(0)) + ' + _BUSY_NODES
969)
970LARGEST_PENDING_JOB_QUERY = (
971 '(max(slurm_partition_jobs_max_job_nodes_nohold{{partition="{partition}"}}) or vector(0)) + '
972 + _BUSY_NODES
973)
976def _conf_map(*layers: Mapping[str, list[str]]) -> dict[str, list[str]]:
977 merged: dict[str, list[str]] = {}
978 for layer in layers:
979 for key, values in layer.items():
980 merged[key] = list(values)
981 return merged
984#: Placement shared by every Slurm pod that is not a worker: slurmctld,
985#: slurmdbd, slurmrestd, the login pod (chart values) and the MariaDB /
986#: backup pods (post-Helm manifests mirror it verbatim). On-demand only (a
987#: Spot reclaim of the controller is a cluster outage), amd64 (the Slinky
988#: images are built for it), and never on an accelerator pool: the gco-jobs
989#: LimitRange defaults every container to ``nvidia.com/gpu: 0``, which the
990#: ExtendedResourceToleration admission plugin turns into a toleration of the
991#: ``nvidia.com/gpu`` taint, and Karpenter then provisioned a g4dn for the
992#: controller (observed live, 2026-10-27) and ``do-not-disrupt`` pinned it
993#: there. Every GCO accelerator and job pool labels its nodes with
994#: ``workload-type``; the Auto Mode general-purpose pool does not.
995PLATFORM_POD_NODE_SELECTOR: dict[str, str] = {
996 "kubernetes.io/os": "linux",
997 "kubernetes.io/arch": "amd64",
998 CAPACITY_TYPE_LABEL: "on-demand",
999}
1000PLATFORM_POD_AFFINITY: dict[str, Any] = {
1001 "nodeAffinity": {
1002 "requiredDuringSchedulingIgnoredDuringExecution": {
1003 "nodeSelectorTerms": [
1004 {"matchExpressions": [{"key": "workload-type", "operator": "DoesNotExist"}]}
1005 ]
1006 }
1007 }
1008}
1011def _platform_pod_placement() -> dict[str, Any]:
1012 """Fresh copies of the shared non-worker placement (selector + affinity)."""
1013 return {
1014 "nodeSelector": dict(PLATFORM_POD_NODE_SELECTOR),
1015 "affinity": copy.deepcopy(PLATFORM_POD_AFFINITY),
1016 }
1019def _control_plane_pod_spec() -> dict[str, Any]:
1020 """Pod spec fields shared by slurmctld, slurmrestd and slurmdbd.
1022 The shared placement plus the platform priority class, which keeps them
1023 scheduled ahead of user pods.
1024 """
1025 return {"priorityClassName": SLURM_PLATFORM_PRIORITY_CLASS, **_platform_pod_placement()}
1028#: Where API-submitted jobs run by default and where their ``slurm-<id>.out``
1029#: lands: the ``slurm`` user's home (``/home/slurm`` in the Slinky images),
1030#: which is on the shared EFS claim when ``storage.home`` is ``efs`` so the
1031#: login pod can read the output (``gco slurm output``). The login pod's init
1032#: container below creates it: the claim starts empty and the EFS access
1033#: point squashes every writer to one POSIX identity, so a plain mkdir there
1034#: is all the directory needs.
1035SLURM_DEFAULT_WORKING_DIRECTORY = "/home/slurm"
1038def _home_init_containers(config: Mapping[str, Any]) -> list[dict[str, Any]]:
1039 """The login pod's init container that creates the shared ``/home/slurm``."""
1040 if config["storage"]["home"] != "efs":
1041 return []
1042 return [
1043 {
1044 "name": "home-init",
1045 "image": f"{SLURM_SIDECAR_IMAGE['repository']}:{SLURM_SIDECAR_IMAGE['tag']}",
1046 "imagePullPolicy": "IfNotPresent",
1047 "command": [
1048 "sh",
1049 "-c",
1050 f"mkdir -p {SLURM_DEFAULT_WORKING_DIRECTORY} && chmod 2775 "
1051 f"{SLURM_DEFAULT_WORKING_DIRECTORY}",
1052 ],
1053 "resources": {
1054 "requests": {"cpu": "50m", "memory": "32Mi"},
1055 "limits": {"cpu": "100m", "memory": "64Mi"},
1056 },
1057 "securityContext": {
1058 "runAsNonRoot": True,
1059 "runAsUser": 1000,
1060 "runAsGroup": 1000,
1061 "allowPrivilegeEscalation": False,
1062 "readOnlyRootFilesystem": True,
1063 "capabilities": {"drop": ["ALL"]},
1064 "seccompProfile": {"type": "RuntimeDefault"},
1065 },
1066 "volumeMounts": [
1067 {
1068 "name": "home",
1069 "mountPath": SLURM_HOME_MOUNT_PATH,
1070 "subPath": SLURM_HOME_SUBPATH,
1071 }
1072 ],
1073 }
1074 ]
1077def _shared_volumes(
1078 config: Mapping[str, Any], *, fsx_enabled: bool
1079) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
1080 """``(volumes, volumeMounts)`` for /home and /scratch on worker and login pods."""
1081 volumes: list[dict[str, Any]] = []
1082 mounts: list[dict[str, Any]] = []
1083 if config["storage"]["home"] == "efs":
1084 volumes.append({"name": "home", "persistentVolumeClaim": {"claimName": SLURM_EFS_CLAIM}})
1085 mounts.append(
1086 {"name": "home", "mountPath": SLURM_HOME_MOUNT_PATH, "subPath": SLURM_HOME_SUBPATH}
1087 )
1088 if scratch_backend(config, fsx_enabled=fsx_enabled) == "fsx":
1089 volumes.append({"name": "scratch", "persistentVolumeClaim": {"claimName": SLURM_FSX_CLAIM}})
1090 mounts.append({"name": "scratch", "mountPath": SLURM_SCRATCH_MOUNT_PATH})
1091 return volumes, mounts
1094def slurm_real_memory_mib(memory_bytes: int) -> int:
1095 """``RealMemory`` for a worker whose pod limit is ``memory_bytes``.
1097 Slurm may hand a job everything RealMemory advertises; the pod's cgroup
1098 limit also has to hold slurmd and the step's overhead, so the larger of
1099 1 GiB and 5% is kept back (a 32Gi worker advertises 31130 MiB).
1100 """
1101 limit_mib = memory_bytes // (1024 * 1024)
1102 return max(limit_mib - max(1024, limit_mib // 20), 1)
1105def _nodeset_values(
1106 name: str,
1107 partition: Mapping[str, Any],
1108 *,
1109 volumes: list[dict[str, Any]],
1110 mounts: list[dict[str, Any]],
1111) -> dict[str, Any]:
1112 pool = NODE_POOLS[partition["node_pool"]]
1113 resources: dict[str, str] = {"cpu": str(partition["cpu"]), "memory": str(partition["memory"])}
1114 if partition["gpus"]:
1115 resources["nvidia.com/gpu"] = str(partition["gpus"])
1116 if partition["efa_interfaces"]:
1117 resources["vpc.amazonaws.com/efa"] = str(partition["efa_interfaces"])
1118 node_selector: dict[str, str] = {"kubernetes.io/os": "linux", "kubernetes.io/arch": "amd64"}
1119 node_selector.update(pool["selector"])
1120 if partition["capacity_type"] != "any":
1121 node_selector[CAPACITY_TYPE_LABEL] = partition["capacity_type"]
1122 tolerations = [
1123 {"key": taint, "operator": "Equal", "value": "true", "effect": "NoSchedule"}
1124 for taint in pool["taints"]
1125 ]
1126 # The node's Slurm definition, fixed from the pod's resources rather than
1127 # detected from the host. slurmd sees the whole instance (36 CPUs on a
1128 # c4.8xlarge, not the pod's 8), and a dynamic node keeps its first
1129 # registration's record across scale-to-zero cycles: when KEDA brought a
1130 # scaled-in worker back on a different instance type, slurmctld marked it
1131 # INVALID ("Low socket*core*thread count, CoreSpec differ") and the
1132 # partition was dead until an operator intervened (observed live,
1133 # 2026-10-28). Advertising exactly the pod's CPUs and memory (minus
1134 # headroom for slurmd itself) also stops Slurm from scheduling more onto
1135 # the pod than its cgroup allows. A host always has at least this much,
1136 # so re-registration on any instance type matches the stored record.
1137 cpus = int(partition["_cpu_cores"])
1138 real_memory = slurm_real_memory_mib(int(partition["_memory_bytes"]))
1139 extra_conf: dict[str, list[str]] = {
1140 "CPUs": [str(cpus)],
1141 "Boards": ["1"],
1142 "SocketsPerBoard": ["1"],
1143 "CoresPerSocket": [str(cpus)],
1144 "ThreadsPerCore": ["1"],
1145 "RealMemory": [str(real_memory)],
1146 }
1147 if partition["gpus"]:
1148 extra_conf["Gres"] = [f"gpu:{partition['gpus']}"]
1149 if partition["features"]:
1150 extra_conf["Features"] = list(partition["features"])
1151 partition_conf: dict[str, str] = {
1152 "Default": "YES" if partition["default"] else "NO",
1153 "State": "UP",
1154 "MaxTime": partition["max_time"],
1155 # A job that names no --mem gets its share of the node per CPU rather
1156 # than all of it (Slurm's DefMemPerNode default), so eight one-core
1157 # jobs share an eight-core worker instead of serialising on it.
1158 "DefMemPerCPU": str(max(real_memory // cpus, 1)),
1159 }
1160 if partition["exclusive"]:
1161 partition_conf["OverSubscribe"] = "EXCLUSIVE"
1162 return {
1163 "enabled": True,
1164 # Autoscaled partitions start at min_nodes; KEDA owns the count after
1165 # install through the NodeSet scale subresource.
1166 "replicas": int(partition["min_nodes"]),
1167 "slurmd": {
1168 # Requests equal limits: the pod is the Slurm node, and Slinky
1169 # sizes the node from the pod's resources, so a burstable pod
1170 # would advertise capacity it may not get.
1171 "resources": {"requests": dict(resources), "limits": dict(resources)},
1172 "volumeMounts": copy.deepcopy(mounts),
1173 },
1174 "extraConfMap": extra_conf,
1175 "partition": {"enabled": True, "configMap": partition_conf},
1176 "podSpec": {
1177 "nodeSelector": node_selector,
1178 "tolerations": tolerations,
1179 "volumes": copy.deepcopy(volumes),
1180 },
1181 }
1184def _scaled_object(
1185 name: str, partition: Mapping[str, Any], autoscaling: Mapping[str, Any]
1186) -> dict[str, Any]:
1187 min_nodes = int(partition["min_nodes"])
1188 spec: dict[str, Any] = {
1189 "scaleTargetRef": {
1190 "apiVersion": "slinky.slurm.net/v1beta1",
1191 "kind": "NodeSet",
1192 "name": nodeset_name(name),
1193 },
1194 "pollingInterval": int(autoscaling["polling_interval_seconds"]),
1195 "cooldownPeriod": int(autoscaling["cooldown_seconds"]),
1196 # KEDA only supports idleReplicaCount 0; with a floor of one or more
1197 # the HPA's minReplicas is the floor instead.
1198 "minReplicaCount": max(min_nodes, 1),
1199 "maxReplicaCount": int(partition["max_nodes"]),
1200 "advanced": {
1201 "horizontalPodAutoscalerConfig": {
1202 "behavior": {
1203 # Grow fast (jobs are waiting), shrink one node every two
1204 # minutes after ten quiet ones: the operator drains a
1205 # worker before removing it, so a hasty scale-in only
1206 # churns nodes.
1207 "scaleUp": {
1208 "stabilizationWindowSeconds": 0,
1209 "selectPolicy": "Max",
1210 "policies": [{"type": "Pods", "value": 4, "periodSeconds": 60}],
1211 },
1212 "scaleDown": {
1213 "stabilizationWindowSeconds": 600,
1214 "selectPolicy": "Min",
1215 "policies": [{"type": "Pods", "value": 1, "periodSeconds": 120}],
1216 },
1217 }
1218 }
1219 },
1220 "triggers": [
1221 {
1222 "type": "prometheus",
1223 "metricType": "Value",
1224 "metadata": {
1225 "serverAddress": SLURM_PROMETHEUS_URL,
1226 "query": PENDING_JOBS_QUERY.format(partition=name),
1227 "threshold": str(int(autoscaling["pending_jobs_threshold"])),
1228 },
1229 },
1230 {
1231 "type": "prometheus",
1232 "metricType": "Value",
1233 "metadata": {
1234 "serverAddress": SLURM_PROMETHEUS_URL,
1235 "query": LARGEST_PENDING_JOB_QUERY.format(partition=name),
1236 "threshold": "1",
1237 },
1238 },
1239 ],
1240 }
1241 if min_nodes == 0:
1242 spec["idleReplicaCount"] = 0
1243 return {
1244 "apiVersion": "keda.sh/v1alpha1",
1245 "kind": "ScaledObject",
1246 "metadata": {
1247 "name": scaled_object_name(name),
1248 "namespace": SLURM_NAMESPACE,
1249 "labels": {
1250 "project": "gco",
1251 SLURM_PART_OF_LABEL: SLURM_PART_OF_VALUE,
1252 "gco.io/slurm-partition": name,
1253 },
1254 },
1255 "spec": spec,
1256 }
1259def slurm_chart_values(config: Mapping[str, Any], *, fsx_enabled: bool) -> dict[str, Any]:
1260 """The ``slurm`` chart values GCO derives from a validated ``helm.slurm`` block.
1262 The regional stack hands them to the helm installer, which deep-merges
1263 them over the static hardening values in ``charts.yaml`` (image pins,
1264 resource requests, the disabled chart partition). Everything that varies
1265 per deployment is here: the cluster name, the controller's state volume,
1266 slurmrestd replicas and PDB, accounting and its database connection, the
1267 metrics switch, the login pod, every NodeSet with its partition line, the
1268 cgroup/gres config files, the extra slurm.conf lines, the shared-storage
1269 mounts, and one KEDA ScaledObject per autoscaled partition rendered
1270 through the chart's ``extraObjects``. A list value in the result replaces
1271 the list in charts.yaml (the installer's merge replaces scalars and lists
1272 and recurses into maps).
1273 """
1274 partitions = enabled_partitions(config)
1275 any_gpu = any(p["gpus"] for p in partitions.values())
1276 volumes, mounts = _shared_volumes(config, fsx_enabled=fsx_enabled)
1277 control_plane = _control_plane_pod_spec()
1279 slurm_conf = _conf_map(BASE_SLURM_CONF, GPU_SLURM_CONF if any_gpu else {}, config["slurm_conf"])
1280 config_files: dict[str, str] = {"cgroup.conf": CGROUP_CONF}
1281 if any_gpu:
1282 config_files["gres.conf"] = GRES_CONF
1284 values: dict[str, Any] = {
1285 "clusterName": config["cluster_name"],
1286 "configFiles": config_files,
1287 "controller": {
1288 "metadata": {"annotations": {"karpenter.sh/do-not-disrupt": "true"}},
1289 "persistence": {
1290 "enabled": True,
1291 "existingClaim": "",
1292 "storageClassName": SLURM_STORAGE_CLASS,
1293 "accessModes": ["ReadWriteOnce"],
1294 "resources": {
1295 "requests": {"storage": str(config["controller"]["state_storage_size"])}
1296 },
1297 },
1298 "extraConfMap": slurm_conf,
1299 "metrics": {
1300 "enabled": bool(config["metrics"]["enabled"]),
1301 # GCO ships the ServiceMonitor itself (gated on observability,
1302 # post-helm-monitoring-servicemonitors.yaml) so the operator
1303 # never depends on a CRD that may not be installed.
1304 "serviceMonitor": {"enabled": False},
1305 },
1306 "podSpec": copy.deepcopy(control_plane),
1307 },
1308 "restapi": {
1309 "replicas": int(config["restapi"]["replicas"]),
1310 "pdb": {"enabled": int(config["restapi"]["replicas"]) > 1, "minAvailable": 1},
1311 "metadata": {"annotations": {"karpenter.sh/do-not-disrupt": "true"}},
1312 "podSpec": copy.deepcopy(control_plane),
1313 },
1314 "accounting": _accounting_values(config, control_plane),
1315 "loginsets": {
1316 SLURM_LOGIN_SET_KEY: {
1317 "enabled": bool(config["login"]["enabled"]),
1318 "replicas": int(config["login"]["replicas"]),
1319 "login": {"volumeMounts": copy.deepcopy(mounts)},
1320 # ClusterIP: reached with `gco slurm shell` over the cluster
1321 # tunnel, never a load balancer.
1322 "service": {"spec": {"type": "ClusterIP"}},
1323 "podSpec": {
1324 **_platform_pod_placement(),
1325 "initContainers": _home_init_containers(config),
1326 "volumes": copy.deepcopy(volumes),
1327 },
1328 }
1329 },
1330 "nodesets": {
1331 name: _nodeset_values(name, spec, volumes=volumes, mounts=mounts)
1332 for name, spec in partitions.items()
1333 },
1334 # Partitions are one per NodeSet (above); the chart's catch-all is off
1335 # so exactly one declared partition is Slurm's default.
1336 "partitions": {"all": {"enabled": False}},
1337 "extraObjects": [
1338 _scaled_object(name, partitions[name], config["autoscaling"])
1339 for name in autoscaled_partitions(config)
1340 ],
1341 }
1342 return values
1345def _accounting_values(config: Mapping[str, Any], control_plane: dict[str, Any]) -> dict[str, Any]:
1346 accounting = config["accounting"]
1347 if not accounting["enabled"]:
1348 return {"enabled": False}
1349 external = accounting["external"]
1350 if external["host"]:
1351 storage = {
1352 "host": external["host"],
1353 "port": int(external["port"]),
1354 "database": external["database"],
1355 "username": external["username"],
1356 "passwordKeyRef": {
1357 "name": external["password_secret"],
1358 "key": external["password_key"],
1359 },
1360 }
1361 else:
1362 storage = {
1363 "host": SLURM_ACCOUNTING_DB_NAME,
1364 "port": SLURM_ACCOUNTING_DB_PORT,
1365 "database": SLURM_ACCOUNTING_DATABASE,
1366 "username": SLURM_ACCOUNTING_USER,
1367 "passwordKeyRef": {
1368 "name": SLURM_ACCOUNTING_DB_SECRET,
1369 "key": SLURM_ACCOUNTING_DB_PASSWORD_KEY,
1370 },
1371 }
1372 return {
1373 "enabled": True,
1374 "storageConfig": storage,
1375 "extraConfMap": _conf_map(config["slurmdbd_conf"]),
1376 "metadata": {"annotations": {"karpenter.sh/do-not-disrupt": "true"}},
1377 "podSpec": copy.deepcopy(control_plane),
1378 }
1381# ---------------------------------------------------------------------------
1382# kubectl-applier replacements
1383# ---------------------------------------------------------------------------
1385#: Tokens the Slurm manifests carry. ``{{SLURM_ENABLED}}`` gates the network
1386#: policies, the gp3 StorageClass, the API's Token and the observability
1387#: objects; ``{{SLURM_ACCOUNTING_DB_ENABLED}}`` gates the in-cluster MariaDB
1388#: (with its volume size) and ``{{SLURM_ACCOUNTING_BACKUP_ENABLED}}`` the
1389#: backup CronJob (with its schedule); retention is an S3 lifecycle rule the
1390#: regional stack adds from the same block, not a manifest token.
1391#: ``{{SLURM_API_ENABLED}}`` is different in kind: it is always emitted
1392#: ("true"/"false") because it sits in the core manifest-processor
1393#: Deployment (``GCO_SLURM_ENABLED``), where a leftover placeholder would make
1394#: the applier skip the whole file, exactly like ``{{TRACING_ENABLED}}``.
1395SLURM_MANIFEST_TOKENS: tuple[str, ...] = (
1396 "{{SLURM_ENABLED}}",
1397 "{{SLURM_API_ENABLED}}",
1398 "{{SLURM_ACCOUNTING_DB_ENABLED}}",
1399 "{{SLURM_ACCOUNTING_DB_STORAGE_SIZE}}",
1400 "{{SLURM_ACCOUNTING_BACKUP_ENABLED}}",
1401 "{{SLURM_ACCOUNTING_BACKUP_SCHEDULE}}",
1402 "{{SLURM_CLUSTER_NAME}}",
1403)
1406def compute_slurm_replacements(config: Mapping[str, Any], *, enabled: bool) -> dict[str, str]:
1407 """Build the kubectl-applier replacements for the Slurm manifests.
1409 ``enabled`` is the effective chart enablement (cdk.json toggle or a
1410 run-scoped ``helm_enabled_overrides``). When false only the always-present
1411 ``{{SLURM_API_ENABLED}}`` is emitted, as "false": the applier skips every
1412 Slurm manifest, prunes what an earlier deploy applied, and the API pod
1413 answers ``/api/v1/slurm/*`` with the enablement remedy. ``{{SLURM_ENABLED}}``
1414 itself stays with the scheduler gates in the regional stack; this adds the
1415 accounting and identity tokens.
1416 """
1417 if not enabled:
1418 return {"{{SLURM_API_ENABLED}}": "false"}
1419 replacements = {
1420 "{{SLURM_API_ENABLED}}": "true",
1421 "{{SLURM_CLUSTER_NAME}}": str(config["cluster_name"]),
1422 }
1423 if accounting_in_cluster(config):
1424 accounting = config["accounting"]
1425 replacements["{{SLURM_ACCOUNTING_DB_ENABLED}}"] = "true"
1426 replacements["{{SLURM_ACCOUNTING_DB_STORAGE_SIZE}}"] = str(accounting["storage_size"])
1427 if accounting["backup"]["enabled"]:
1428 replacements["{{SLURM_ACCOUNTING_BACKUP_ENABLED}}"] = "true"
1429 replacements["{{SLURM_ACCOUNTING_BACKUP_SCHEDULE}}"] = str(
1430 accounting["backup"]["schedule"]
1431 ).strip()
1432 return replacements