This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake 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 8dd33eb16 Association mapper client impl
8dd33eb16 is described below
commit 8dd33eb16731d7b372c26cdf0acb97ffa0c38ebd
Author: DImuthuUpe <[email protected]>
AuthorDate: Wed May 13 10:07:23 2026 -0400
Association mapper client impl
---
connectors/SLURM/Association-Mapper/.gitignore | 4 +
connectors/SLURM/Association-Mapper/Makefile | 18 +++
connectors/SLURM/Association-Mapper/README.md | 57 ++++++-
connectors/SLURM/Association-Mapper/go.mod | 3 +
.../internal/operations/accounts.go | 53 ++++++
.../internal/operations/accounts_test.go | 99 ++++++++++++
.../internal/operations/associations.go | 62 +++++++
.../internal/operations/associations_test.go | 74 +++++++++
.../internal/operations/client.go | 71 ++++++++
.../internal/operations/client_test.go | 47 ++++++
.../Association-Mapper/internal/operations/tres.go | 39 +++++
.../internal/operations/tres_test.go | 33 ++++
.../internal/operations/types.go | 180 +++++++++++++++++++++
connectors/SLURM/Association-Mapper/main.go | 25 +++
14 files changed, 763 insertions(+), 2 deletions(-)
diff --git a/connectors/SLURM/Association-Mapper/.gitignore
b/connectors/SLURM/Association-Mapper/.gitignore
new file mode 100644
index 000000000..7be4b455e
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/.gitignore
@@ -0,0 +1,4 @@
+/bin/
+*.test
+*.out
+.env
diff --git a/connectors/SLURM/Association-Mapper/Makefile
b/connectors/SLURM/Association-Mapper/Makefile
new file mode 100644
index 000000000..32efab927
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/Makefile
@@ -0,0 +1,18 @@
+.PHONY: build test run tidy clean
+
+BIN := bin/association-mapper
+
+build:
+ go build -o $(BIN) .
+
+run: build
+ ./$(BIN)
+
+test:
+ go test ./...
+
+tidy:
+ go mod tidy
+
+clean:
+ rm -rf bin
diff --git a/connectors/SLURM/Association-Mapper/README.md
b/connectors/SLURM/Association-Mapper/README.md
index 906d5dfba..667af800f 100644
--- a/connectors/SLURM/Association-Mapper/README.md
+++ b/connectors/SLURM/Association-Mapper/README.md
@@ -1,3 +1,56 @@
-SLURM Association Creation Logic Goes into this Plugin
+# SLURM Association-Mapper
-This plugin is triggered when the allocation manager has processed an
allocation request and relased it to downstream handlers
\ No newline at end of file
+SLURM association creation logic lives in this plugin. It is triggered when
the allocation manager has processed an allocation request and released it to
downstream handlers. It talks to `slurmrestd` to manage accounts, associations,
and TRES limits.
+
+## Prerequisites
+
+- Go **1.24+**
+- A reachable `slurmrestd` endpoint (for integration runs) plus a SLURM user
name and JWT token
+
+## Layout
+
+```
+.
+├── main.go # entry point
+├── internal/operations/ # slurmrestd client +
accounts/associations/TRES
+├── go.mod
+└── Makefile
+```
+
+Module path:
`github.com/apache/airavata-custos/connectors/SLURM/Association-Mapper`.
+
+## Build
+
+```bash
+# from this directory
+make build # produces bin/association-mapper
+# or directly:
+go build -o bin/association-mapper .
+```
+
+## Run
+
+```bash
+make run # build, then ./bin/association-mapper
+```
+
+The service starts, logs `association-mapper started`, and blocks until it
receives `SIGINT` or `SIGTERM`.
+
+## Test
+
+```bash
+make test # go test ./...
+go vet ./... # static checks
+```
+
+Tests are hermetic and use `httptest` — no live `slurmrestd` required.
+
+## Common make targets
+
+| Target | Description |
+|---------|--------------------------------------|
+| `build` | Compile the binary into `bin/` |
+| `run` | Build and run |
+| `test` | Run all unit tests |
+| `tidy` | `go mod tidy` |
+| `clean` | Remove `bin/` |
diff --git a/connectors/SLURM/Association-Mapper/go.mod
b/connectors/SLURM/Association-Mapper/go.mod
new file mode 100644
index 000000000..0e1832e7c
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/go.mod
@@ -0,0 +1,3 @@
+module github.com/apache/airavata-custos/connectors/SLURM/Association-Mapper
+
+go 1.24.0
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/accounts.go
b/connectors/SLURM/Association-Mapper/internal/operations/accounts.go
new file mode 100644
index 000000000..ea7d87708
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/accounts.go
@@ -0,0 +1,53 @@
+// cli/internal/client/accounts.go
+package operations
+
+import "fmt"
+
+type accountsResponse struct {
+ Accounts []Account `json:"accounts"`
+}
+
+func (c *Client) ListAccounts() ([]Account, error) {
+ var out accountsResponse
+ if _, err := c.do("GET", "/slurmdb/v0.0.41/accounts", nil, &out); err
!= nil {
+ return nil, err
+ }
+ return out.Accounts, nil
+}
+
+func (c *Client) GetAccount(name string) (*Account, error) {
+ var out accountsResponse
+ if _, err := c.do("GET", "/slurmdb/v0.0.41/account/"+name, nil, &out);
err != nil {
+ return nil, err
+ }
+ if len(out.Accounts) == 0 {
+ return nil, fmt.Errorf("account %q not found", name)
+ }
+ return &out.Accounts[0], nil
+}
+
+// CreateAccount creates an account and a cluster-scope association in a single
+// call, mirroring `sacctmgr add account <name> cluster=<cluster>`. Without the
+// association, the account record exists but is unusable (subsequent attempts
+// to add a user silently no-op). slurmrestd exposes this via the
+// /accounts_association/ endpoint, whose body wraps the account metadata and
+// an association_condition naming the accounts + clusters to wire up.
+func (c *Client) CreateAccount(a Account, cluster string) error {
+ body := map[string]any{
+ "association_condition": map[string]any{
+ "accounts": []string{a.Name},
+ "clusters": []string{cluster},
+ },
+ "account": map[string]any{
+ "description": a.Description,
+ "organization": a.Organization,
+ },
+ }
+ _, err := c.do("POST", "/slurmdb/v0.0.41/accounts_association/", body,
nil)
+ return err
+}
+
+func (c *Client) DeleteAccount(name string) error {
+ _, err := c.do("DELETE", "/slurmdb/v0.0.41/account/"+name, nil, nil)
+ return err
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/accounts_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/accounts_test.go
new file mode 100644
index 000000000..a427116e5
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/accounts_test.go
@@ -0,0 +1,99 @@
+// cli/internal/client/accounts_test.go
+package operations
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestListAccounts(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.URL.Path != "/slurmdb/v0.0.41/accounts" || r.Method !=
"GET" {
+ t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ _, _ =
w.Write([]byte(`{"accounts":[{"name":"root","description":"root
account","organization":"artisan"}]}`))
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "root", "t")
+ accts, err := c.ListAccounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(accts) != 1 || accts[0].Name != "root" {
+ t.Errorf("accts = %+v", accts)
+ }
+}
+
+func TestCreateAccount(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.Method != "POST" || r.URL.Path !=
"/slurmdb/v0.0.41/accounts_association/" {
+ t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ body, _ := io.ReadAll(r.Body)
+ var payload struct {
+ AssociationCondition struct {
+ Accounts []string `json:"accounts"`
+ Clusters []string `json:"clusters"`
+ } `json:"association_condition"`
+ Account struct {
+ Description string `json:"description"`
+ Organization string `json:"organization"`
+ } `json:"account"`
+ }
+ _ = json.Unmarshal(body, &payload)
+ if len(payload.AssociationCondition.Accounts) != 1 ||
payload.AssociationCondition.Accounts[0] != "eng" {
+ t.Errorf("accounts = %+v",
payload.AssociationCondition.Accounts)
+ }
+ if len(payload.AssociationCondition.Clusters) != 1 ||
payload.AssociationCondition.Clusters[0] != "artisan" {
+ t.Errorf("clusters = %+v",
payload.AssociationCondition.Clusters)
+ }
+ if payload.Account.Description != "engineering" {
+ t.Errorf("description = %q",
payload.Account.Description)
+ }
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "root", "t")
+ if err := c.CreateAccount(Account{Name: "eng", Description:
"engineering"}, "artisan"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeleteAccount(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.Method != "DELETE" || r.URL.Path !=
"/slurmdb/v0.0.41/account/eng" {
+ t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "root", "t")
+ if err := c.DeleteAccount("eng"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetAccount(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.URL.Path != "/slurmdb/v0.0.41/account/eng" {
+ t.Fatalf("path = %s", r.URL.Path)
+ }
+ _, _ =
w.Write([]byte(`{"accounts":[{"name":"eng","description":"engineering","organization":"artisan"}]}`))
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "root", "t")
+ a, err := c.GetAccount("eng")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.Name != "eng" || a.Description != "engineering" {
+ t.Errorf("a = %+v", a)
+ }
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/associations.go
b/connectors/SLURM/Association-Mapper/internal/operations/associations.go
new file mode 100644
index 000000000..ab34cd931
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/associations.go
@@ -0,0 +1,62 @@
+// cli/internal/client/associations.go
+package operations
+
+import "net/url"
+
+type AssocFilter struct {
+ Account string
+ User string
+ Cluster string
+ Partition string
+}
+
+func (f AssocFilter) query() string {
+ v := url.Values{}
+ if f.Account != "" {
+ v.Set("account", f.Account)
+ }
+ if f.User != "" {
+ v.Set("user", f.User)
+ }
+ if f.Cluster != "" {
+ v.Set("cluster", f.Cluster)
+ }
+ if f.Partition != "" {
+ v.Set("partition", f.Partition)
+ }
+ return v.Encode()
+}
+
+type associationsResponse struct {
+ Associations []Association `json:"associations"`
+}
+
+func (c *Client) ListAssociations(f AssocFilter) ([]Association, error) {
+ path := "/slurmdb/v0.0.41/associations"
+ if q := f.query(); q != "" {
+ path += "?" + q
+ }
+ var out associationsResponse
+ if _, err := c.do("GET", path, nil, &out); err != nil {
+ return nil, err
+ }
+ return out.Associations, nil
+}
+
+// UpsertAssociation creates or updates an association. slurmrestd POST
+// /slurmdb/v0.0.41/associations is an upsert: if the (cluster,account,user)
+// triple exists, it's updated; otherwise created.
+func (c *Client) UpsertAssociation(a Association) error {
+ body := map[string]any{"associations": []Association{a}}
+ _, err := c.do("POST", "/slurmdb/v0.0.41/associations", body, nil)
+ return err
+}
+
+func (c *Client) DeleteAssociation(f AssocFilter) error {
+ path := "/slurmdb/v0.0.41/association"
+ if q := f.query(); q != "" {
+ path += "?" + q
+ }
+ _, err := c.do("DELETE", path, nil, nil)
+ return err
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
new file mode 100644
index 000000000..9082927b0
--- /dev/null
+++
b/connectors/SLURM/Association-Mapper/internal/operations/associations_test.go
@@ -0,0 +1,74 @@
+// cli/internal/client/associations_test.go
+package operations
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+)
+
+
+func TestListAssociationsByAccount(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.URL.Path != "/slurmdb/v0.0.41/associations" {
+ t.Fatalf("path = %s", r.URL.Path)
+ }
+ if got, _ := url.QueryUnescape(r.URL.RawQuery); got !=
"account=eng" {
+ t.Fatalf("query = %q", got)
+ }
+ _, _ =
w.Write([]byte(`{"associations":[{"account":"eng","cluster":"artisan","user":"alice","id_association":5}]}`))
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "root", "t")
+ assocs, err := c.ListAssociations(AssocFilter{Account: "eng"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(assocs) != 1 || assocs[0].User != "alice" || assocs[0].ID != 5 {
+ t.Errorf("assocs = %+v", assocs)
+ }
+}
+
+func TestCreateAssociation(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.Method != "POST" || r.URL.Path !=
"/slurmdb/v0.0.41/associations" {
+ t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ b, _ := io.ReadAll(r.Body)
+ var payload struct {
+ Associations []Association `json:"associations"`
+ }
+ _ = json.Unmarshal(b, &payload)
+ if len(payload.Associations) != 1 ||
payload.Associations[0].Account != "eng" ||
+ payload.Associations[0].User != "alice" {
+ t.Errorf("payload = %+v", payload)
+ }
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "root", "t")
+ err := c.UpsertAssociation(Association{Account: "eng", Cluster:
"artisan", User: "alice"})
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeleteAssociation(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ if r.Method != "DELETE" || r.URL.Path !=
"/slurmdb/v0.0.41/association" {
+ t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
+ }
+ if r.URL.Query().Get("account") != "eng" ||
r.URL.Query().Get("user") != "alice" {
+ t.Fatalf("query = %v", r.URL.RawQuery)
+ }
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "root", "t")
+ if err := c.DeleteAssociation(AssocFilter{Account: "eng", User:
"alice"}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/operations/client.go
b/connectors/SLURM/Association-Mapper/internal/operations/client.go
new file mode 100644
index 000000000..62bd25b99
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/client.go
@@ -0,0 +1,71 @@
+// cli/internal/client/client.go
+package operations
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type Client struct {
+ baseURL string
+ user string
+ token string
+ http *http.Client
+}
+
+func New(baseURL, user, token string) *Client {
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ user: user,
+ token: token,
+ http: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+func (c *Client) do(method, path string, body any, out any) (*http.Response,
error) {
+ var reqBody io.Reader
+ if body != nil {
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reqBody = bytes.NewReader(buf)
+ }
+ req, err := http.NewRequest(method, c.baseURL+path, reqBody)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-SLURM-USER-NAME", c.user)
+ req.Header.Set("X-SLURM-USER-TOKEN", c.token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode >= 400 {
+ defer resp.Body.Close()
+ buf, _ := io.ReadAll(resp.Body)
+ var er ErrorResponse
+ if json.Unmarshal(buf, &er) == nil && len(er.Errors) > 0 {
+ e := er.Errors[0]
+ return resp, fmt.Errorf("slurmrestd %d: %s (code=%d
source=%s)",
+ resp.StatusCode, e.Description, e.ErrorNumber,
e.Source)
+ }
+ return resp, fmt.Errorf("slurmrestd %d: %s", resp.StatusCode,
string(buf))
+ }
+ if out != nil {
+ defer resp.Body.Close()
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return resp, fmt.Errorf("decode: %w", err)
+ }
+ }
+ return resp, nil
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/client_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/client_test.go
new file mode 100644
index 000000000..3d07b6456
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/client_test.go
@@ -0,0 +1,47 @@
+// cli/internal/client/client_test.go
+package operations
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestDoSetsHeaders(t *testing.T) {
+ var gotName, gotToken string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ gotName = r.Header.Get("X-SLURM-USER-NAME")
+ gotToken = r.Header.Get("X-SLURM-USER-TOKEN")
+ w.WriteHeader(200)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "root", "tok123")
+ if _, err := c.do("GET", "/slurm/v0.0.41/ping", nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ if gotName != "root" {
+ t.Errorf("X-SLURM-USER-NAME = %q", gotName)
+ }
+ if gotToken != "tok123" {
+ t.Errorf("X-SLURM-USER-TOKEN = %q", gotToken)
+ }
+}
+
+func TestDoUnwrapsErrors(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
+ w.WriteHeader(400)
+ _, _ =
w.Write([]byte(`{"errors":[{"description":"nope","error_number":42,"error":"Bad","source":"test"}]}`))
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "root", "tok")
+ _, err := c.do("GET", "/x", nil, nil)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "42") ||
!strings.Contains(err.Error(), "nope") {
+ t.Errorf("err = %v", err)
+ }
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/operations/tres.go
b/connectors/SLURM/Association-Mapper/internal/operations/tres.go
new file mode 100644
index 000000000..313c58c59
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/tres.go
@@ -0,0 +1,39 @@
+// cli/internal/client/tres.go
+package operations
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// ParseTRES parses a comma-separated TRES spec:
+//
+// "cpu=100", "cpu=100,mem=8000", "gres/gpu=2", "cpu=10,gres/gpu=4"
+func ParseTRES(s string) ([]TRES, error) {
+ if strings.TrimSpace(s) == "" {
+ return nil, nil
+ }
+ parts := strings.Split(s, ",")
+ out := make([]TRES, 0, len(parts))
+ for _, p := range parts {
+ kv := strings.SplitN(p, "=", 2)
+ if len(kv) != 2 {
+ return nil, fmt.Errorf("malformed TRES entry %q (want
key=value)", p)
+ }
+ n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("TRES count %q not an integer",
kv[1])
+ }
+ t := TRES{Count: n}
+ key := strings.TrimSpace(kv[0])
+ if slash := strings.Index(key, "/"); slash >= 0 {
+ t.Type = key[:slash]
+ t.Name = key[slash+1:]
+ } else {
+ t.Type = key
+ }
+ out = append(out, t)
+ }
+ return out, nil
+}
diff --git
a/connectors/SLURM/Association-Mapper/internal/operations/tres_test.go
b/connectors/SLURM/Association-Mapper/internal/operations/tres_test.go
new file mode 100644
index 000000000..79ba50faf
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/tres_test.go
@@ -0,0 +1,33 @@
+// cli/internal/client/tres_test.go
+package operations
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParseTRES(t *testing.T) {
+ cases := []struct {
+ in string
+ want []TRES
+ }{
+ {"cpu=100", []TRES{{Type: "cpu", Count: 100}}},
+ {"cpu=100,mem=8000", []TRES{{Type: "cpu", Count: 100}, {Type:
"mem", Count: 8000}}},
+ {"gres/gpu=4", []TRES{{Type: "gres", Name: "gpu", Count: 4}}},
+ {"cpu=10,gres/gpu=2", []TRES{{Type: "cpu", Count: 10}, {Type:
"gres", Name: "gpu", Count: 2}}},
+ }
+ for _, c := range cases {
+ got, err := ParseTRES(c.in)
+ if err != nil {
+ t.Errorf("ParseTRES(%q): err=%v", c.in, err)
+ continue
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("ParseTRES(%q) = %+v, want %+v", c.in, got,
c.want)
+ }
+ }
+
+ if _, err := ParseTRES("nope"); err == nil {
+ t.Error("expected error on malformed input")
+ }
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/operations/types.go
b/connectors/SLURM/Association-Mapper/internal/operations/types.go
new file mode 100644
index 000000000..75aaca6bd
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/operations/types.go
@@ -0,0 +1,180 @@
+// cli/internal/client/types.go
+package operations
+
+import "encoding/json"
+
+type ErrorResponse struct {
+ Errors []struct {
+ Description string `json:"description"`
+ ErrorNumber int `json:"error_number"`
+ Error string `json:"error"`
+ Source string `json:"source"`
+ } `json:"errors"`
+ Warnings []struct {
+ Description string `json:"description"`
+ Source string `json:"source"`
+ } `json:"warnings"`
+}
+
+type TRES struct {
+ Type string `json:"type"`
+ Name string `json:"name,omitempty"`
+ Count int64 `json:"count"`
+}
+
+type Account struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Organization string `json:"organization,omitempty"`
+}
+
+type AssocLimits struct {
+ GrpJobs *int64 `json:"grp_jobs,omitempty"`
+ GrpTRES []TRES `json:"grp_tres,omitempty"`
+ GrpTRESMins []TRES `json:"grp_tres_mins,omitempty"`
+ MaxWallPM *int64 `json:"max_wall_pj,omitempty"`
+}
+
+type Association struct {
+ Account string `json:"account"`
+ Cluster string `json:"cluster"`
+ User string `json:"user"`
+ Partition string `json:"partition,omitempty"`
+ ParentAccount string `json:"parent_account,omitempty"`
+ IsDefault *bool `json:"is_default,omitempty"`
+ ID int64 `json:"id_association,omitempty"`
+ // Limits is a logical grouping — slurmrestd v0.0.41 actually encodes
limits
+ // in a nested `max` object per-association. We translate between the
two
+ // shapes in Marshal/UnmarshalJSON below.
+ Limits AssocLimits `json:"-"`
+}
+
+// slurmNumber matches slurmrestd's {set, infinite, number} triple used for
+// all scalar limit values in the v0.0.41 accounting schema.
+type slurmNumber struct {
+ Set bool `json:"set"`
+ Infinite bool `json:"infinite"`
+ Number int64 `json:"number"`
+}
+
+func numPtr(n *int64) *slurmNumber {
+ if n == nil {
+ return nil
+ }
+ return &slurmNumber{Set: true, Number: *n}
+}
+
+func ptrNum(n *slurmNumber) *int64 {
+ if n == nil || !n.Set || n.Infinite {
+ return nil
+ }
+ v := n.Number
+ return &v
+}
+
+// assocMax is the v0.0.41 "max" sub-object inside each association. We only
+// populate the fields we actually manage; slurmrestd ignores unset
sub-objects.
+type assocMax struct {
+ Jobs *assocMaxJobs `json:"jobs,omitempty"`
+ TRES *assocMaxTRES `json:"tres,omitempty"`
+}
+
+type assocMaxJobs struct {
+ Per *assocMaxJobsPer `json:"per,omitempty"`
+}
+
+type assocMaxJobsPer struct {
+ Count *slurmNumber `json:"count,omitempty"` // GrpJobs
+ WallClock *slurmNumber `json:"wall_clock,omitempty"` //
MaxWallDurationPerJob (seconds)
+}
+
+type assocMaxTRES struct {
+ Total []TRES `json:"total,omitempty"` // GrpTRES
+ Group *assocMaxTRESGp `json:"group,omitempty"`
+}
+
+type assocMaxTRESGp struct {
+ Minutes []TRES `json:"minutes,omitempty"` // GrpTRESMins
+}
+
+// assocWire is the on-the-wire association record used for both request
+// marshaling and response unmarshaling. `user` is emitted even when empty
+// because slurmrestd rejects payloads without it (error 9200).
+type assocWire struct {
+ Account string `json:"account"`
+ Cluster string `json:"cluster"`
+ User string `json:"user"`
+ Partition string `json:"partition,omitempty"`
+ ParentAccount string `json:"parent_account,omitempty"`
+ IsDefault *bool `json:"is_default,omitempty"`
+ ID int64 `json:"id_association,omitempty"`
+ Max *assocMax `json:"max,omitempty"`
+}
+
+func (a Association) MarshalJSON() ([]byte, error) {
+ w := assocWire{
+ Account: a.Account,
+ Cluster: a.Cluster,
+ User: a.User,
+ Partition: a.Partition,
+ ParentAccount: a.ParentAccount,
+ IsDefault: a.IsDefault,
+ ID: a.ID,
+ }
+ m := &assocMax{}
+ touched := false
+ if a.Limits.GrpJobs != nil || a.Limits.MaxWallPM != nil {
+ per := &assocMaxJobsPer{}
+ if a.Limits.GrpJobs != nil {
+ per.Count = numPtr(a.Limits.GrpJobs)
+ }
+ if a.Limits.MaxWallPM != nil {
+ per.WallClock = numPtr(a.Limits.MaxWallPM)
+ }
+ m.Jobs = &assocMaxJobs{Per: per}
+ touched = true
+ }
+ if len(a.Limits.GrpTRES) > 0 || len(a.Limits.GrpTRESMins) > 0 {
+ t := &assocMaxTRES{}
+ if len(a.Limits.GrpTRES) > 0 {
+ t.Total = a.Limits.GrpTRES
+ }
+ if len(a.Limits.GrpTRESMins) > 0 {
+ t.Group = &assocMaxTRESGp{Minutes: a.Limits.GrpTRESMins}
+ }
+ m.TRES = t
+ touched = true
+ }
+ if touched {
+ w.Max = m
+ }
+ return json.Marshal(w)
+}
+
+func (a *Association) UnmarshalJSON(data []byte) error {
+ var w assocWire
+ if err := json.Unmarshal(data, &w); err != nil {
+ return err
+ }
+ a.Account = w.Account
+ a.Cluster = w.Cluster
+ a.User = w.User
+ a.Partition = w.Partition
+ a.ParentAccount = w.ParentAccount
+ a.IsDefault = w.IsDefault
+ a.ID = w.ID
+ a.Limits = AssocLimits{}
+ if w.Max != nil {
+ if w.Max.Jobs != nil && w.Max.Jobs.Per != nil {
+ a.Limits.GrpJobs = ptrNum(w.Max.Jobs.Per.Count)
+ a.Limits.MaxWallPM = ptrNum(w.Max.Jobs.Per.WallClock)
+ }
+ if w.Max.TRES != nil {
+ a.Limits.GrpTRES = w.Max.TRES.Total
+ if w.Max.TRES.Group != nil {
+ a.Limits.GrpTRESMins = w.Max.TRES.Group.Minutes
+ }
+ }
+ }
+ return nil
+}
diff --git a/connectors/SLURM/Association-Mapper/main.go
b/connectors/SLURM/Association-Mapper/main.go
new file mode 100644
index 000000000..d04dcad1e
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/main.go
@@ -0,0 +1,25 @@
+// Package main is the entry point for the SLURM Association-Mapper connector.
+//
+// It consumes allocation events released by the allocation manager and
+// materializes them as SLURM associations via slurmrestd.
+package main
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+func main() {
+ logger := slog.New(slog.NewTextHandler(os.Stdout,
&slog.HandlerOptions{Level: slog.LevelInfo}))
+ slog.SetDefault(logger)
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT,
syscall.SIGTERM)
+ defer stop()
+
+ logger.Info("association-mapper started")
+ <-ctx.Done()
+ logger.Info("association-mapper stopped")
+}