This is an automated email from the ASF dual-hosted git repository.
DImuthuUpe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
The following commit(s) were added to refs/heads/master by this push:
new 3c392586c Setting up a local slurm cluster and running integration
tests against it (#477)
3c392586c is described below
commit 3c392586c3a3fa49c513d1541963a879f8d5b95c
Author: Dimuthu Wannipurage <[email protected]>
AuthorDate: Fri May 22 07:34:47 2026 -0400
Setting up a local slurm cluster and running integration tests against it
(#477)
* Setting up a local slurm cluster and running tests against it
* Association integration tests
* Integration tests for valid and invalid associations
* Fixing test failures in mock tests
* Integrating missing run commnds
---
.../operations/accounts_integration_test.go | 95 +++++++++++++
.../internal/operations/associations.go | 31 ++++-
.../operations/associations_integration_test.go | 134 ++++++++++++++++++
.../internal/operations/associations_test.go | 13 ++
.../internal/operations/integration_common.go | 40 ++++++
dev-ops/local-slurm/Makefile | 33 +++++
dev-ops/local-slurm/compose.yaml | 150 +++++++++++++++++++++
dev-ops/local-slurm/docker/Dockerfile.base | 33 +++++
dev-ops/local-slurm/docker/Dockerfile.login | 11 ++
dev-ops/local-slurm/docker/Dockerfile.slurmctld | 8 ++
dev-ops/local-slurm/docker/Dockerfile.slurmd | 6 +
dev-ops/local-slurm/docker/Dockerfile.slurmdbd | 7 +
dev-ops/local-slurm/docker/Dockerfile.slurmrestd | 8 ++
.../local-slurm/scripts/bootstrap-accounting.sh | 30 +++++
dev-ops/local-slurm/scripts/entrypoint-ctld.sh | 19 +++
dev-ops/local-slurm/scripts/entrypoint-dbd.sh | 24 ++++
dev-ops/local-slurm/scripts/entrypoint-login.sh | 17 +++
dev-ops/local-slurm/scripts/entrypoint-restd.sh | 24 ++++
dev-ops/local-slurm/scripts/entrypoint-slurmd.sh | 24 ++++
dev-ops/local-slurm/scripts/init-keys.sh | 30 +++++
dev-ops/local-slurm/slurm/cgroup.conf | 1 +
dev-ops/local-slurm/slurm/gres.conf | 2 +
dev-ops/local-slurm/slurm/slurm.conf | 38 ++++++
dev-ops/local-slurm/slurm/slurmdbd.conf | 18 +++
scripts/run-integrations-tests.sh | 48 +++++++
25 files changed, 842 insertions(+), 2 deletions(-)
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/accounts_integration_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/accounts_integration_test.go
new file mode 100644
index 000000000..f8dbcc80d
--- /dev/null
+++
b/connectors/SLURM/Association-Mapper/internal/operations/accounts_integration_test.go
@@ -0,0 +1,95 @@
+package operations
+
+import (
+ "os"
+ "testing"
+)
+
+func TestAccountCreatiion_Integration(t *testing.T) {
+
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for account creation because
local SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ client.DeleteAccount("test_account") // clean up before test in
case it was left over from a previous failed test run
+ defer client.DeleteAccount("test_account") // clean up after test
+ crearteAndValidateAccount(t, client)
+}
+
+func TestAccountDeletion_Integration(t *testing.T) {
+
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for account deletion because
local SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ crearteAndValidateAccount(t, client)
+
+ err := client.DeleteAccount("test_account")
+ if err != nil {
+ t.Fatalf("Failed to delete account: %v", err)
+ }
+
+ accounts, err := client.ListAccounts()
+ if err != nil {
+ t.Fatalf("Failed to list accounts: %v", err)
+ }
+
+ for _, account := range accounts {
+ if account.Name == "test_account" {
+ t.Fatalf("Account was not deleted: %+v\n", account)
+ }
+ }
+
+ t.Logf("Successfully deleted account. Remaining accounts: %+v\n",
accounts)
+}
+
+func TestGetAccount_Integration(t *testing.T) {
+
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for get account because local
SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ client.DeleteAccount("test_account") // clean up before test in
case it was left over from a previous failed test run
+ defer client.DeleteAccount("test_account") // clean up after test
+ crearteAndValidateAccount(t, client)
+
+ account, err := client.GetAccount("test_account")
+ if err != nil {
+ t.Fatalf("Failed to get account: %v", err)
+ }
+
+ if account.Name != "test_account" {
+ t.Fatalf("Expected account name 'test_account', got '%s'",
account.Name)
+ }
+
+ if account.Description != "Test account for integration testing" {
+ t.Fatalf("Expected account description 'Test account for
integration testing', got '%s'", account.Description)
+ }
+
+ if account.Organization != "Test Organization" {
+ t.Fatalf("Expected account organization 'Test Organization',
got '%s'", account.Organization)
+ }
+
+ t.Logf("Successfully retrieved account: %+v\n", account)
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/associations.go
b/connectors/SLURM/Association-Mapper/internal/operations/associations.go
index fc1607480..4852daf5c 100644
--- a/connectors/SLURM/Association-Mapper/internal/operations/associations.go
+++ b/connectors/SLURM/Association-Mapper/internal/operations/associations.go
@@ -1,7 +1,11 @@
// cli/internal/client/associations.go
package operations
-import "net/url"
+import (
+ "errors"
+ "log"
+ "net/url"
+)
type AssocFilter struct {
Account string
@@ -49,7 +53,30 @@ func (c *Client) ListAssociations(f AssocFilter)
([]Association, error) {
func (c *Client) UpsertAssociation(a Association) error {
body := map[string]any{"associations": []Association{a}}
_, err := c.do("POST", "/slurmdb/v0.0."+c.apiVersion+"/associations",
body, nil)
- return err
+
+ if err != nil {
+ log.Printf("Failed to upsert association: %v", err)
+ return err
+ }
+
+ filter := AssocFilter{
+ Account: a.Account,
+ User: a.User,
+ Cluster: a.Cluster,
+ Partition: a.Partition,
+ }
+ assos, err := c.ListAssociations(filter)
+ if err != nil {
+ log.Printf("Failed to list associations after upsert: %v", err)
+ return err
+ }
+
+ if len(assos) == 0 {
+ log.Printf("No associations found after upsert")
+ return errors.New("association not found after upsert")
+ }
+
+ return nil
}
func (c *Client) DeleteAssociation(f AssocFilter) error {
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/associations_integration_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/associations_integration_test.go
new file mode 100644
index 000000000..0dc99a352
--- /dev/null
+++
b/connectors/SLURM/Association-Mapper/internal/operations/associations_integration_test.go
@@ -0,0 +1,134 @@
+package operations
+
+import (
+ "os"
+ "testing"
+)
+
+func TestCreateInvalidAssocation_Integration(t *testing.T) {
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for association creation
because local SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ association := Association{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "test_cluster",
+ Partition: "test_partition",
+ }
+
+ err := client.UpsertAssociation(association)
+ if err != nil {
+ expected := "association not found after upsert"
+ if err.Error() != expected {
+ t.Fatalf("Unexpected error: got %v, want %v",
err.Error(), expected)
+ }
+ } else {
+ t.Fatal("Expected error when creating association with
non-existent account, user, cluster, and partition, but got nil")
+ }
+}
+
+func TestCreateValidAssociation_Integration(t *testing.T) {
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for association creation
because local SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ crearteAndValidateAccount(t, client)
+
+ association := Association{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "artisan",
+ Partition: "compute",
+ }
+
+ err := client.UpsertAssociation(association)
+ if err != nil {
+ t.Fatalf("Failed to create association: %v", err)
+ }
+
+ assocs, err := client.ListAssociations(AssocFilter{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "artisan",
+ Partition: "compute",
+ })
+ if err != nil {
+ t.Fatalf("Failed to list associations after creation: %v", err)
+ }
+ if len(assocs) != 1 {
+ t.Fatalf("Expected exactly 1 association after creation, but
found %d: %+v", len(assocs), assocs)
+ }
+ if assocs[0].Account != "test_account" || assocs[0].User != "test_user"
|| assocs[0].Cluster != "artisan" || assocs[0].Partition != "compute" {
+ t.Fatalf("Association fields do not match expected values:
%+v", assocs[0])
+ }
+
+ defer client.DeleteAccount("test_account") // clean up after test
+
+}
+
+func TestDeleteAssociation_Integration(t *testing.T) {
+ if !isLocalSlurmConfigAvailable() {
+ t.Skip("Skipping integration test for association deletion
because local SLURM config is not available")
+ }
+
+ apiUrl := os.Getenv("TEST_SLURM_API")
+ user := os.Getenv("TEST_SLURM_USER")
+ token := os.Getenv("TEST_SLURM_TOKEN")
+ apiVersion := os.Getenv("TEST_SLURM_API_VERSION")
+
+ client := New(apiUrl, user, token, apiVersion)
+
+ crearteAndValidateAccount(t, client)
+ defer client.DeleteAccount("test_account") // clean up after test
+
+ association := Association{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "artisan",
+ Partition: "compute",
+ }
+
+ err := client.UpsertAssociation(association)
+ if err != nil {
+ t.Fatalf("Failed to create association: %v", err)
+ }
+
+ filter := AssocFilter{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "artisan",
+ Partition: "compute",
+ }
+ err = client.DeleteAssociation(filter)
+ if err != nil {
+ t.Fatalf("Failed to delete association: %v", err)
+ }
+
+ assocs, err := client.ListAssociations(AssocFilter{
+ Account: "test_account",
+ User: "test_user",
+ Cluster: "artisan",
+ Partition: "compute",
+ })
+ if err != nil {
+ t.Fatalf("Failed to list associations after deletion: %v", err)
+ }
+ if len(assocs) != 0 {
+ t.Fatalf("Expected no associations after deletion, but found:
%+v", assocs)
+ }
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
index d16da5186..9f394eed4 100644
---
a/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
+++
b/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
@@ -33,6 +33,19 @@ func TestListAssociationsByAccount(t *testing.T) {
func TestCreateAssociation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+
+ // Support to list associations so we can verify the
association was created successfully
+ if r.Method == "GET" && r.URL.Path ==
"/slurmdb/v0.0.41/associations" {
+ q := r.URL.Query()
+ if q.Get("account") != "eng" || q.Get("cluster") !=
"artisan" || q.Get("user") != "alice" {
+ t.Errorf("unexpected list query = %q",
r.URL.RawQuery)
+ http.Error(w, "bad query",
http.StatusBadRequest)
+ return
+ }
+ _, _ =
w.Write([]byte(`{"associations":[{"account":"eng","cluster":"artisan","user":"alice","id_association":5}]}`))
+ return
+ }
+
if r.Method != "POST" || r.URL.Path !=
"/slurmdb/v0.0.41/associations" {
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/integration_common.go
b/connectors/SLURM/Association-Mapper/internal/operations/integration_common.go
new file mode 100644
index 000000000..e25aa3a3e
--- /dev/null
+++
b/connectors/SLURM/Association-Mapper/internal/operations/integration_common.go
@@ -0,0 +1,40 @@
+package operations
+
+import "os"
+import "testing"
+
+func isLocalSlurmConfigAvailable() bool {
+ if os.Getenv("TEST_SLURM_API") == "" || os.Getenv("TEST_SLURM_USER") ==
"" || os.Getenv("TEST_SLURM_TOKEN") == "" ||
os.Getenv("TEST_SLURM_API_VERSION") == "" {
+ return false
+ }
+ return true
+}
+
+func crearteAndValidateAccount(t *testing.T, client *Client) {
+
+ err := client.CreateAccount(Account{
+ Name: "test_account",
+ Description: "Test account for integration testing",
+ Organization: "Test Organization",
+ }, "artisan")
+
+ if err != nil {
+ t.Fatalf("Failed to create account: %v", err)
+ }
+
+ accounts, err := client.ListAccounts()
+ if err != nil {
+ t.Fatalf("Failed to list accounts: %v", err)
+ }
+
+ if len(accounts) == 0 {
+ t.Fatal("No accounts found after creation")
+ }
+
+ for _, account := range accounts {
+ if account.Name == "test_account" {
+ t.Logf("Successfully created account: %+v\n", account)
+ return
+ }
+ }
+}
diff --git a/dev-ops/local-slurm/Makefile b/dev-ops/local-slurm/Makefile
new file mode 100644
index 000000000..e7ac5f228
--- /dev/null
+++ b/dev-ops/local-slurm/Makefile
@@ -0,0 +1,33 @@
+# Makefile
+SHELL := /bin/bash
+.PHONY: base up down build cli test test-integration smoke lint keys logs
+
+base:
+ docker build -f docker/Dockerfile.base -t slurmrest/base:24.05 .
+
+up: base
+ docker compose up -d --build
+
+down:
+ docker compose down -v
+
+build: base
+ docker compose build
+
+smoke:
+ docker compose exec login sbatch --wrap 'hostname' -o /tmp/out.txt
+ sleep 5
+ docker compose exec login sacct -n -o JobID,State --starttime
now-5minutes | tail -n 5
+ docker compose exec login cat /tmp/out.txt
+
+lint:
+ cd cli && go vet ./...
+
+keys:
+ docker compose up init-keys
+
+logs:
+ docker compose logs -f --tail=100
+
+token:
+ docker compose exec login scontrol token
\ No newline at end of file
diff --git a/dev-ops/local-slurm/compose.yaml b/dev-ops/local-slurm/compose.yaml
new file mode 100644
index 000000000..f86beb03b
--- /dev/null
+++ b/dev-ops/local-slurm/compose.yaml
@@ -0,0 +1,150 @@
+# compose.yaml
+name: slurmrest
+
+x-slurm-env: &slurm-env
+ MARIADB_PASSWORD: ${MARIADB_PASSWORD:-slurm}
+
+services:
+ init-keys:
+ image: slurmrest/base:24.05
+ command: ["/usr/local/bin/init-keys.sh"]
+ volumes:
+ - munge-key:/etc/munge
+ - jwt-key:/keys
+ - ./scripts/init-keys.sh:/usr/local/bin/init-keys.sh:ro
+ restart: "no"
+
+ mariadb:
+ image: mariadb:11
+ environment:
+ MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-rootpass}
+ MARIADB_DATABASE: slurm_acct_db
+ MARIADB_USER: slurm
+ MARIADB_PASSWORD: ${MARIADB_PASSWORD:-slurm}
+ volumes:
+ - mariadb-data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mariadb-admin", "ping", "-uslurm",
"-p${MARIADB_PASSWORD:-slurm}"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ slurmdbd:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.slurmdbd
+ hostname: slurmdbd
+ environment: *slurm-env
+ depends_on:
+ init-keys:
+ condition: service_completed_successfully
+ mariadb:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pgrep -x slurmdbd >/dev/null"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ slurmctld:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.slurmctld
+ hostname: slurmctld
+ environment:
+ CLUSTER_NAME: ${CLUSTER_NAME:-artisan}
+ depends_on:
+ slurmdbd:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+ - slurmctld-state:/var/spool/slurm
+ healthcheck:
+ test: ["CMD-SHELL", "scontrol ping >/dev/null 2>&1"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+
+ c1:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.slurmd
+ hostname: c1
+ environment:
+ SLURMD_NODENAME: c1
+ depends_on:
+ slurmctld:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pgrep -x slurmd >/dev/null"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ c2:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.slurmd
+ hostname: c2
+ environment:
+ SLURMD_NODENAME: c2
+ depends_on:
+ slurmctld:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pgrep -x slurmd >/dev/null"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ login:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.login
+ hostname: login
+ ports:
+ - "${LOGIN_SSH_PORT:-2222}:22"
+ depends_on:
+ slurmctld:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+
+ slurmrestd:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.slurmrestd
+ hostname: slurmrestd
+ ports:
+ - "${REST_PORT:-6820}:6820"
+ depends_on:
+ slurmctld:
+ condition: service_healthy
+ slurmdbd:
+ condition: service_healthy
+ volumes:
+ - munge-key:/etc/munge:ro
+ - jwt-key:/keys:ro
+ - ./slurm:/etc/slurm.readonly:ro
+
+volumes:
+ munge-key:
+ jwt-key:
+ mariadb-data:
+ slurmctld-state:
diff --git a/dev-ops/local-slurm/docker/Dockerfile.base
b/dev-ops/local-slurm/docker/Dockerfile.base
new file mode 100644
index 000000000..f252d76d5
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.base
@@ -0,0 +1,33 @@
+# docker/Dockerfile.base
+FROM rockylinux:9
+
+ARG SLURM_VERSION=24.05.5
+
+RUN dnf -y install epel-release \
+ && dnf -y install dnf-plugins-core \
+ && dnf config-manager --set-enabled crb \
+ && dnf -y install \
+ munge munge-libs munge-devel \
+ mariadb-connector-c mariadb-connector-c-devel \
+ http-parser-devel json-c-devel libyaml-devel libjwt-devel \
+ dbus-devel \
+ pam-devel readline-devel perl perl-Switch \
+ gcc make wget which procps-ng iproute bzip2 \
+ openssh-server openssh-clients \
+ python3 python3-pip \
+ && dnf clean all
+
+RUN useradd -r -u 995 -g 0 -s /sbin/nologin slurm \
+ && install -d -o slurm -g 0 -m 0755 /var/spool/slurm /var/log/slurm
/var/run/slurm /etc/slurm
+
+RUN wget -q https://download.schedmd.com/slurm/slurm-${SLURM_VERSION}.tar.bz2 \
+ && tar -xjf slurm-${SLURM_VERSION}.tar.bz2 \
+ && cd slurm-${SLURM_VERSION} \
+ && ./configure --prefix=/usr --sysconfdir=/etc/slurm \
+ --enable-slurmrestd \
+ && make -j"$(nproc)" && make install \
+ && cd .. && rm -rf slurm-${SLURM_VERSION}*
+
+RUN ssh-keygen -A
+
+CMD ["/bin/bash"]
diff --git a/dev-ops/local-slurm/docker/Dockerfile.login
b/dev-ops/local-slurm/docker/Dockerfile.login
new file mode 100644
index 000000000..204834631
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.login
@@ -0,0 +1,11 @@
+FROM slurmrest/base:24.05
+
+RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config \
+ && echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config \
+ && echo "root:rootpass" | chpasswd
+
+COPY scripts/entrypoint-login.sh /usr/local/bin/entrypoint-login.sh
+RUN chmod +x /usr/local/bin/entrypoint-login.sh
+
+EXPOSE 22
+ENTRYPOINT ["/usr/local/bin/entrypoint-login.sh"]
diff --git a/dev-ops/local-slurm/docker/Dockerfile.slurmctld
b/dev-ops/local-slurm/docker/Dockerfile.slurmctld
new file mode 100644
index 000000000..27aa426ee
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.slurmctld
@@ -0,0 +1,8 @@
+# docker/Dockerfile.slurmctld
+FROM slurmrest/base:24.05
+
+COPY scripts/entrypoint-ctld.sh /usr/local/bin/entrypoint-ctld.sh
+COPY scripts/bootstrap-accounting.sh /usr/local/bin/bootstrap-accounting.sh
+RUN chmod +x /usr/local/bin/entrypoint-ctld.sh
/usr/local/bin/bootstrap-accounting.sh
+
+ENTRYPOINT ["/usr/local/bin/entrypoint-ctld.sh"]
diff --git a/dev-ops/local-slurm/docker/Dockerfile.slurmd
b/dev-ops/local-slurm/docker/Dockerfile.slurmd
new file mode 100644
index 000000000..97ab1c857
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.slurmd
@@ -0,0 +1,6 @@
+FROM slurmrest/base:24.05
+
+COPY scripts/entrypoint-slurmd.sh /usr/local/bin/entrypoint-slurmd.sh
+RUN chmod +x /usr/local/bin/entrypoint-slurmd.sh
+
+ENTRYPOINT ["/usr/local/bin/entrypoint-slurmd.sh"]
diff --git a/dev-ops/local-slurm/docker/Dockerfile.slurmdbd
b/dev-ops/local-slurm/docker/Dockerfile.slurmdbd
new file mode 100644
index 000000000..9b3060658
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.slurmdbd
@@ -0,0 +1,7 @@
+# docker/Dockerfile.slurmdbd
+FROM slurmrest/base:24.05
+
+COPY scripts/entrypoint-dbd.sh /usr/local/bin/entrypoint-dbd.sh
+RUN chmod +x /usr/local/bin/entrypoint-dbd.sh
+
+ENTRYPOINT ["/usr/local/bin/entrypoint-dbd.sh"]
diff --git a/dev-ops/local-slurm/docker/Dockerfile.slurmrestd
b/dev-ops/local-slurm/docker/Dockerfile.slurmrestd
new file mode 100644
index 000000000..70bf9915e
--- /dev/null
+++ b/dev-ops/local-slurm/docker/Dockerfile.slurmrestd
@@ -0,0 +1,8 @@
+# docker/Dockerfile.slurmrestd
+FROM slurmrest/base:24.05
+
+COPY scripts/entrypoint-restd.sh /usr/local/bin/entrypoint-restd.sh
+RUN chmod +x /usr/local/bin/entrypoint-restd.sh
+
+EXPOSE 6820
+ENTRYPOINT ["/usr/local/bin/entrypoint-restd.sh"]
diff --git a/dev-ops/local-slurm/scripts/bootstrap-accounting.sh
b/dev-ops/local-slurm/scripts/bootstrap-accounting.sh
new file mode 100755
index 000000000..a8494393c
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/bootstrap-accounting.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Idempotent: creates the demo cluster, root account, root admin user.
+set -euo pipefail
+
+SENTINEL=/var/spool/slurm/ctld/.bootstrap-done
+
+if [[ -f "$SENTINEL" ]]; then
+ echo "[bootstrap] sentinel present, skipping"
+ exit 0
+fi
+
+# Wait until slurmdbd answers
+until sacctmgr -i show cluster >/dev/null 2>&1; do
+ echo "[bootstrap] waiting for slurmdbd..."
+ sleep 2
+done
+
+CLUSTER="${CLUSTER_NAME:-artisan}"
+if ! sacctmgr -in show cluster format=cluster | grep -qw "$CLUSTER"; then
+ sacctmgr -i add cluster "$CLUSTER"
+fi
+if ! sacctmgr -in show account format=account | grep -qw "root"; then
+ sacctmgr -i add account root Description="root account"
Organization="$CLUSTER"
+fi
+if ! sacctmgr -in show user format=user | grep -qw "root"; then
+ sacctmgr -i add user root Account=root AdminLevel=Administrator
+fi
+
+touch "$SENTINEL"
+echo "[bootstrap] done"
diff --git a/dev-ops/local-slurm/scripts/entrypoint-ctld.sh
b/dev-ops/local-slurm/scripts/entrypoint-ctld.sh
new file mode 100755
index 000000000..f4cec4ca4
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/entrypoint-ctld.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+install -m 0644 /etc/slurm.readonly/slurm.conf /etc/slurm/slurm.conf
+install -m 0644 /etc/slurm.readonly/cgroup.conf /etc/slurm/cgroup.conf
+ln -sf /keys/jwt.hs256.key /etc/slurm/jwt.hs256.key
+
+# Ensure StateSaveLocation exists (the slurmctld-state named volume is empty
+# on first boot; /var/spool/slurm itself is created by the base image).
+install -d -m 0755 -o slurm -g 0 /var/spool/slurm/ctld
+
+install -d -m 0755 -o munge -g munge /var/run/munge
+install -d -m 0700 -o munge -g munge /var/log/munge /var/lib/munge
+runuser -u munge -- /usr/sbin/munged --force
+
+# Bootstrap accounting in the background after slurmctld comes up
+( sleep 5; /usr/local/bin/bootstrap-accounting.sh ) &
+
+exec slurmctld -D -vvv
diff --git a/dev-ops/local-slurm/scripts/entrypoint-dbd.sh
b/dev-ops/local-slurm/scripts/entrypoint-dbd.sh
new file mode 100755
index 000000000..40a7becd8
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/entrypoint-dbd.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Copy slurmdbd.conf with the 0600 perms that slurmdbd requires
+# (slurm user's primary group is root/gid 0 — there is no 'slurm' group)
+install -m 0600 -o slurm -g 0 /etc/slurm.readonly/slurmdbd.conf
/etc/slurm/slurmdbd.conf
+ln -sf /keys/jwt.hs256.key /etc/slurm/jwt.hs256.key
+
+# Start munge. /var/run/munge must be world-readable (0755) so non-munge
+# users (slurm) can open the munge socket; /var/log and /var/lib stay 0700.
+install -d -m 0755 -o munge -g munge /var/run/munge
+install -d -m 0700 -o munge -g munge /var/log/munge /var/lib/munge
+runuser -u munge -- /usr/sbin/munged --force
+
+# Wait for MariaDB to accept TCP connections. The compose healthcheck on the
+# mariadb service already gates startup via depends_on, but this adds a
+# belt-and-suspenders TCP probe (base image has no mariadb client binary).
+until (exec 3<>/dev/tcp/mariadb/3306) 2>/dev/null; do
+ echo "[slurmdbd] waiting for mariadb..."
+ sleep 2
+done
+exec 3<&- 3>&- || true
+
+exec slurmdbd -D -vvv
diff --git a/dev-ops/local-slurm/scripts/entrypoint-login.sh
b/dev-ops/local-slurm/scripts/entrypoint-login.sh
new file mode 100755
index 000000000..4f77e0ca5
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/entrypoint-login.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+install -m 0644 /etc/slurm.readonly/slurm.conf /etc/slurm/slurm.conf
+ln -sf /keys/jwt.hs256.key /etc/slurm/jwt.hs256.key
+
+install -d -m 0755 -o munge -g munge /var/run/munge
+install -d -m 0700 -o munge -g munge /var/log/munge /var/lib/munge
+runuser -u munge -- /usr/sbin/munged --force
+
+# Ensure the default test user exists
+id -u testuser >/dev/null 2>&1 || useradd -m -s /bin/bash testuser
+id -u testuser2 >/dev/null 2>&1 || useradd -m -s /bin/bash testuser2
+id -u testuser3 >/dev/null 2>&1 || useradd -m -s /bin/bash testuser3
+
+# Start sshd in the foreground
+exec /usr/sbin/sshd -D -e
diff --git a/dev-ops/local-slurm/scripts/entrypoint-restd.sh
b/dev-ops/local-slurm/scripts/entrypoint-restd.sh
new file mode 100755
index 000000000..2060025b7
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/entrypoint-restd.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+install -m 0644 /etc/slurm.readonly/slurm.conf /etc/slurm/slurm.conf
+ln -sf /keys/jwt.hs256.key /etc/slurm/jwt.hs256.key
+
+install -d -m 0755 -o munge -g munge /var/run/munge
+install -d -m 0700 -o munge -g munge /var/log/munge /var/lib/munge
+runuser -u munge -- /usr/sbin/munged --force
+
+# slurmrestd must not run as root.
+# SLURM_JWT=daemon makes slurmrestd trust its own internal JWT for
daemon-to-daemon calls;
+# external requests still require X-SLURM-USER-TOKEN.
+# SLURMRESTD_SECURITY flags:
+# disable_unshare_sysv/files: Docker denies CLONE_NEWIPC without
CAP_SYS_ADMIN,
+# which we don't want to grant; skip those hardening steps.
+# disable_user_check: the base image's 'slurm' user is slurm:0 (no slurm
group
+# exists), matching how slurmdbd/slurmctld already run. slurmrestd's
default
+# check rejects root primary group; we opt out since the daemon itself is
not
+# running as uid 0.
+exec runuser -u slurm -- env \
+ SLURM_JWT=daemon \
+
SLURMRESTD_SECURITY=disable_unshare_sysv,disable_unshare_files,disable_user_check
\
+ slurmrestd -f /etc/slurm/slurm.conf -a rest_auth/jwt 0.0.0.0:6820 -vvv
diff --git a/dev-ops/local-slurm/scripts/entrypoint-slurmd.sh
b/dev-ops/local-slurm/scripts/entrypoint-slurmd.sh
new file mode 100755
index 000000000..15166d449
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/entrypoint-slurmd.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+install -m 0644 /etc/slurm.readonly/slurm.conf /etc/slurm/slurm.conf
+install -m 0644 /etc/slurm.readonly/cgroup.conf /etc/slurm/cgroup.conf
+install -m 0644 /etc/slurm.readonly/gres.conf /etc/slurm/gres.conf
+ln -sf /keys/jwt.hs256.key /etc/slurm/jwt.hs256.key
+
+# Ensure SlurmdSpoolDir exists (slurm.conf sets it to /var/spool/slurm/d;
+# /var/spool/slurm itself is created by the base image but the subdir is not).
+install -d -m 0755 -o slurm -g 0 /var/spool/slurm/d
+
+# Create two fake GPU device files so slurmd can register Gres=gpu:2 against
+# distinct File= entries. These are just /dev/null-style sinks — there are no
+# real GPUs. gres.conf references /dev/nullgpu0 and /dev/nullgpu1.
+for i in 0 1; do
+ [ -e "/dev/nullgpu${i}" ] || mknod -m 0666 "/dev/nullgpu${i}" c 1 3
+done
+
+install -d -m 0755 -o munge -g munge /var/run/munge
+install -d -m 0700 -o munge -g munge /var/log/munge /var/lib/munge
+runuser -u munge -- /usr/sbin/munged --force
+
+exec slurmd -D -N "${SLURMD_NODENAME:-$(hostname)}" -vvv
diff --git a/dev-ops/local-slurm/scripts/init-keys.sh
b/dev-ops/local-slurm/scripts/init-keys.sh
new file mode 100755
index 000000000..5bfdac8c5
--- /dev/null
+++ b/dev-ops/local-slurm/scripts/init-keys.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# scripts/init-keys.sh — generate munge + JWT keys into shared volumes if
missing.
+set -euo pipefail
+
+MUNGE_KEY=/etc/munge/munge.key
+# jwt-key volume is mounted at /keys; each service symlinks into /etc/slurm/
+JWT_KEY=/keys/jwt.hs256.key
+
+if [[ ! -s "$MUNGE_KEY" ]]; then
+ echo "[init-keys] generating $MUNGE_KEY"
+ install -d -m 0700 -o munge -g munge /etc/munge
+ dd if=/dev/urandom of="$MUNGE_KEY" bs=1 count=1024 status=none
+ chown munge:munge "$MUNGE_KEY"
+ chmod 0400 "$MUNGE_KEY"
+else
+ echo "[init-keys] $MUNGE_KEY already present"
+fi
+
+if [[ ! -s "$JWT_KEY" ]]; then
+ echo "[init-keys] generating $JWT_KEY"
+ install -d -m 0755 /keys
+ openssl rand -base64 32 | tr -d '\n' > "$JWT_KEY"
+ # slurm user's primary group is root (gid 0) in the base image; chown
accordingly
+ chown slurm:0 "$JWT_KEY"
+ chmod 0400 "$JWT_KEY"
+else
+ echo "[init-keys] $JWT_KEY already present"
+fi
+
+echo "[init-keys] done"
diff --git a/dev-ops/local-slurm/slurm/cgroup.conf
b/dev-ops/local-slurm/slurm/cgroup.conf
new file mode 100644
index 000000000..e59e9aeea
--- /dev/null
+++ b/dev-ops/local-slurm/slurm/cgroup.conf
@@ -0,0 +1 @@
+CgroupPlugin=cgroup/v1
diff --git a/dev-ops/local-slurm/slurm/gres.conf
b/dev-ops/local-slurm/slurm/gres.conf
new file mode 100644
index 000000000..52e0c7e41
--- /dev/null
+++ b/dev-ops/local-slurm/slurm/gres.conf
@@ -0,0 +1,2 @@
+Name=gpu File=/dev/nullgpu0
+Name=gpu File=/dev/nullgpu1
diff --git a/dev-ops/local-slurm/slurm/slurm.conf
b/dev-ops/local-slurm/slurm/slurm.conf
new file mode 100644
index 000000000..49bd34e35
--- /dev/null
+++ b/dev-ops/local-slurm/slurm/slurm.conf
@@ -0,0 +1,38 @@
+# slurm/slurm.conf
+ClusterName=artisan
+SlurmctldHost=slurmctld
+
+AuthType=auth/munge
+AuthAltTypes=auth/jwt
+AuthAltParameters=jwt_key=/etc/slurm/jwt.hs256.key
+CredType=cred/munge
+
+SlurmUser=slurm
+SlurmctldPort=6817
+SlurmdPort=6818
+StateSaveLocation=/var/spool/slurm/ctld
+SlurmdSpoolDir=/var/spool/slurm/d
+SwitchType=switch/none
+MpiDefault=none
+ProctrackType=proctrack/linuxproc
+TaskPlugin=task/none
+ReturnToService=2
+SlurmdParameters=config_overrides
+
+SlurmctldLogFile=/var/log/slurm/slurmctld.log
+SlurmdLogFile=/var/log/slurm/slurmd.log
+
+SelectType=select/cons_tres
+SelectTypeParameters=CR_CPU_Memory
+GresTypes=gpu
+
+AccountingStorageType=accounting_storage/slurmdbd
+AccountingStorageHost=slurmdbd
+AccountingStorageTRES=gres/gpu
+AccountingStorageEnforce=associations,limits,qos,safe
+AccountingStoreFlags=job_comment
+JobAcctGatherType=jobacct_gather/linux
+JobAcctGatherFrequency=30
+
+NodeName=c[1-2] CPUs=4 RealMemory=8000 Gres=gpu:2 State=UNKNOWN
+PartitionName=compute Nodes=c[1-2] Default=YES MaxTime=INFINITE State=UP
diff --git a/dev-ops/local-slurm/slurm/slurmdbd.conf
b/dev-ops/local-slurm/slurm/slurmdbd.conf
new file mode 100644
index 000000000..f34ea5429
--- /dev/null
+++ b/dev-ops/local-slurm/slurm/slurmdbd.conf
@@ -0,0 +1,18 @@
+# slurm/slurmdbd.conf — file mode must be 0600 when mounted
+AuthType=auth/munge
+AuthAltTypes=auth/jwt
+AuthAltParameters=jwt_key=/etc/slurm/jwt.hs256.key
+
+DbdHost=slurmdbd
+DbdPort=6819
+SlurmUser=slurm
+
+StorageType=accounting_storage/mysql
+StorageHost=mariadb
+StoragePort=3306
+StorageUser=slurm
+StoragePass=slurm
+StorageLoc=slurm_acct_db
+
+LogFile=/var/log/slurm/slurmdbd.log
+PidFile=/var/run/slurm/slurmdbd.pid
diff --git a/scripts/run-integrations-tests.sh
b/scripts/run-integrations-tests.sh
new file mode 100755
index 000000000..63286d120
--- /dev/null
+++ b/scripts/run-integrations-tests.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Run the SLURM Association-Mapper integration tests.
+#
+# Usage:
+# scripts/run-integrations-tests.sh # run all integration
tests in operations/
+# scripts/run-integrations-tests.sh -run TestFoo # forward extra flags to
`go test`
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+cd "${REPO_ROOT}"
+
+make -s -C dev-ops/local-slurm down
+make -s -C dev-ops/local-slurm build
+make -s -C dev-ops/local-slurm up
+
+# Mint a fresh SLURM JWT via the local-slurm Makefile target.
+# `make token` prints e.g. `SLURM_JWT=eyJhbGciOi...` — strip the prefix.
+echo "==> minting SLURM JWT via 'make token'"
+TOKEN_LINE="$(make -s -C dev-ops/local-slurm token | grep -E '^SLURM_JWT=' |
tail -n1)"
+if [[ -z "${TOKEN_LINE}" ]]; then
+ echo "ERROR: 'make token' did not produce a SLURM_JWT=... line" >&2
+ exit 1
+fi
+
+export TEST_SLURM_API="http://localhost:6820"
+export TEST_SLURM_USER="root"
+export TEST_SLURM_API_VERSION="41"
+export TEST_SLURM_TOKEN="${TOKEN_LINE#SLURM_JWT=}"
+echo "==> TEST_SLURM_TOKEN set (${#TEST_SLURM_TOKEN} chars)"
+
+ go test -tags integration -v -count=1 \
+ ./connectors/SLURM/Association-Mapper/internal/operations/...
+
+
+#go test -tags integration -v -count=1 \
+# ./connectors/SLURM/Association-Mapper/internal/operations/accounts.go \
+# ./connectors/SLURM/Association-Mapper/internal/operations/associations.go \
+# ./connectors/SLURM/Association-Mapper/internal/operations/client.go \
+# ./connectors/SLURM/Association-Mapper/internal/operations/tres.go \
+# ./connectors/SLURM/Association-Mapper/internal/operations/types.go \
+#
./connectors/SLURM/Association-Mapper/internal/operations/integration_common.go
\
+#
./connectors/SLURM/Association-Mapper/internal/operations/associations_integration_test.go
+
+
+make -s -C dev-ops/local-slurm down
\ No newline at end of file