Skip to content

Commit b90716d

Browse files
Remove Slurm access the allocations no longer grant
Fixes #540
1 parent 0eea55d commit b90716d

4 files changed

Lines changed: 292 additions & 49 deletions

File tree

connectors/SLURM/Association-Mapper/internal/subscribers/association_writer.go

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -74,71 +74,88 @@ func (a *AssociationSubscriber) upsertAssociationsForMembership(ctx context.Cont
7474
return a.syncAssociationsForMembership(ctx, membership, nil)
7575
}
7676

77-
// syncAssociationsForMembership builds the association for each of the
78-
// allocation's resources and writes the ones that differ from existing, the
79-
// state already on the cluster. A nil existing writes all of them.
77+
// syncAssociationsForMembership writes the associations this membership should
78+
// have, skipping any that already match existing, the state already on the
79+
// cluster. Pass nil to skip that comparison and write every record.
8080
//
8181
// Every caller goes through here on purpose. Slurm keys an association by
8282
// (cluster, account, user, partition) and its upsert is last-write-wins, so a
8383
// caller that built the record without limits would wipe limits another caller
8484
// had set. Folding the per-member overrides in here keeps all writers
8585
// producing the same record for the same key.
8686
func (a *AssociationSubscriber) syncAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership, existing map[assocKey]client.Association) error {
87+
desired, err := a.desiredAssociationsForMembership(ctx, membership)
88+
if err != nil {
89+
return err
90+
}
91+
for _, association := range desired {
92+
if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
93+
continue
94+
}
95+
if err := a.slurmClient.UpsertAssociation(association); err != nil {
96+
return fmt.Errorf("upsert association for partition %s: %w", association.Partition, err)
97+
}
98+
slog.Info("Upserted association", "association", association)
99+
}
100+
return nil
101+
}
102+
103+
// desiredAssociationsForMembership builds the associations this membership
104+
// should have on the cluster, without writing anything. An inactive allocation
105+
// grants nothing, so it yields an empty set rather than an error.
106+
func (a *AssociationSubscriber) desiredAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership) ([]client.Association, error) {
87107
allocation, err := a.coreService.GetComputeAllocation(ctx, membership.ComputeAllocationID)
88108
if err != nil {
89-
return fmt.Errorf("get compute allocation: %w", err)
109+
return nil, fmt.Errorf("get compute allocation: %w", err)
110+
}
111+
if !activeAllocation(*allocation) {
112+
return nil, nil
90113
}
91114
cluster, err := a.coreService.GetComputeCluster(ctx, allocation.ComputeClusterID)
92115
if err != nil {
93-
return fmt.Errorf("get compute cluster: %w", err)
116+
return nil, fmt.Errorf("get compute cluster: %w", err)
94117
}
95118
csu, err := a.coreService.GetComputeClusterUserByPair(ctx, cluster.ID, membership.UserID)
96119
if err != nil {
97-
return fmt.Errorf("get compute cluster user: %w", err)
120+
return nil, fmt.Errorf("get compute cluster user: %w", err)
98121
}
99122
if csu.ProvisionedAt == nil {
100-
return errNotProvisioned
123+
return nil, errNotProvisioned
101124
}
102125

103126
resources, err := a.coreService.ListResourcesForAllocation(ctx, allocation.ID)
104127
if err != nil {
105-
return fmt.Errorf("list resources for allocation: %w", err)
128+
return nil, fmt.Errorf("list resources for allocation: %w", err)
106129
}
107130
if len(resources) == 0 {
108131
// Nothing to map onto a partition. Skipping beats guessing a
109132
// partition name the cluster may not have.
110133
slog.Warn("Allocation has no resources, no association written",
111134
"allocation_id", allocation.ID, "user_id", membership.UserID)
112-
return nil
135+
return nil, nil
113136
}
114137

115138
overrides, err := a.coreService.ListOverridesForMembership(ctx, membership.ID)
116139
if err != nil {
117-
return fmt.Errorf("list overrides for membership: %w", err)
140+
return nil, fmt.Errorf("list overrides for membership: %w", err)
118141
}
119142
overrideByResource := make(map[string]models.ComputeAllocationMembershipResourceOverride, len(overrides))
120143
for _, o := range overrides {
121144
overrideByResource[o.ComputeAllocationResourceID] = o
122145
}
123146

147+
out := make([]client.Association, 0, len(resources))
124148
for _, resource := range resources {
125-
association := client.Association{
149+
out = append(out, client.Association{
126150
Account: allocation.Name,
127151
Cluster: cluster.Name,
128152
User: csu.LocalUsername,
129153
Partition: resource.Name,
130154
QoS: []string{"normal"},
131155
Limits: limitsFor(resource, overrideByResource[resource.ID]),
132-
}
133-
if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
134-
continue
135-
}
136-
if err := a.slurmClient.UpsertAssociation(association); err != nil {
137-
return fmt.Errorf("upsert association for partition %s: %w", resource.Name, err)
138-
}
139-
slog.Info("Upserted association", "association", association)
156+
})
140157
}
141-
return nil
158+
return out, nil
142159
}
143160

144161
// limitsFor turns a per-member override into association limits. A zero

connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"testing"
2323
"time"
2424

25+
"github.com/apache/airavata-custos/connectors/SLURM/Rest-Client/pkg/client"
2526
"github.com/apache/airavata-custos/pkg/models"
2627
)
2728

@@ -153,3 +154,116 @@ func TestAllocationDeletionRemovesAllAssociations(t *testing.T) {
153154
t.Fatalf("expected an account-wide delete on allocation deletion, got %+v", got)
154155
}
155156
}
157+
158+
// The sweep is the backstop for a lost deactivation: an association the
159+
// allocations no longer call for is removed even if no event ever arrived.
160+
func TestReconcilerRemovesStaleAssociation(t *testing.T) {
161+
core := coreMock(mockOpts{
162+
provisionedAt: ago(time.Minute),
163+
memberships: []models.ComputeAllocationMembership{testMembership()},
164+
})
165+
slurm := &fakeSlurmClient{existing: []client.Association{
166+
// desired
167+
{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
168+
// left behind by a membership that is gone
169+
{Account: "test-alloc", Cluster: "testcluster", User: "ghost", Partition: "compute"},
170+
}}
171+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
172+
173+
got := slurm.allDeletes()
174+
if len(got) != 1 {
175+
t.Fatalf("expected the stale association to be removed, got %d deletes", len(got))
176+
}
177+
if got[0].User != "ghost" || got[0].Partition != "compute" {
178+
t.Errorf("removed the wrong association: %+v", got[0])
179+
}
180+
}
181+
182+
// Guard: an empty desired set almost always means a failed lookup, so the
183+
// sweep must not read it as "revoke everyone".
184+
func TestReconcilerDoesNotPruneWhenNothingIsDesired(t *testing.T) {
185+
core := coreMock(mockOpts{
186+
provisionedAt: ago(time.Minute),
187+
memberships: nil, // nobody entitled to anything
188+
})
189+
slurm := &fakeSlurmClient{existing: []client.Association{
190+
{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
191+
}}
192+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
193+
194+
if n := len(slurm.allDeletes()); n != 0 {
195+
t.Fatalf("an empty desired set must not revoke anything, got %d deletes", n)
196+
}
197+
}
198+
199+
// Guard: associations on accounts Custos does not manage are never touched.
200+
func TestReconcilerLeavesUnmanagedAccountsAlone(t *testing.T) {
201+
core := coreMock(mockOpts{
202+
provisionedAt: ago(time.Minute),
203+
memberships: []models.ComputeAllocationMembership{testMembership()},
204+
unmanagedAccounts: true, // core reports no allocations on this cluster
205+
})
206+
slurm := &fakeSlurmClient{existing: []client.Association{
207+
{Account: "someone-elses-account", Cluster: "testcluster", User: "outsider", Partition: "compute"},
208+
}}
209+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
210+
211+
if n := len(slurm.allDeletes()); n != 0 {
212+
t.Fatalf("an unmanaged account must not be touched, got %d deletes", n)
213+
}
214+
}
215+
216+
// Account-level records carry the allocation's own limits, not a member's
217+
// access, so the sweep must leave them be.
218+
func TestReconcilerLeavesAccountLevelAssociationsAlone(t *testing.T) {
219+
core := coreMock(mockOpts{
220+
provisionedAt: ago(time.Minute),
221+
memberships: []models.ComputeAllocationMembership{testMembership()},
222+
})
223+
slurm := &fakeSlurmClient{existing: []client.Association{
224+
{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
225+
{Account: "test-alloc", Cluster: "testcluster", User: ""}, // account-level
226+
}}
227+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
228+
229+
if n := len(slurm.allDeletes()); n != 0 {
230+
t.Fatalf("account-level associations must not be pruned, got %d deletes", n)
231+
}
232+
}
233+
234+
// A member inside the provisioning grace is still entitled, so their fresh
235+
// association must not be pruned just because the sweep is not writing it yet.
236+
func TestReconcilerDoesNotPruneAssociationsInsideGrace(t *testing.T) {
237+
core := coreMock(mockOpts{
238+
provisionedAt: ago(time.Second),
239+
memberships: []models.ComputeAllocationMembership{testMembership()},
240+
})
241+
slurm := &fakeSlurmClient{existing: []client.Association{
242+
{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
243+
}}
244+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
245+
246+
if n := len(slurm.allDeletes()); n != 0 {
247+
t.Fatalf("a member inside the grace must keep their association, got %d deletes", n)
248+
}
249+
}
250+
251+
// An allocation that is no longer active grants nothing, so its members'
252+
// associations are swept away even without a deactivation event.
253+
func TestReconcilerRemovesAssociationsForInactiveAllocation(t *testing.T) {
254+
core := coreMock(mockOpts{
255+
provisionedAt: ago(time.Minute),
256+
allocationStatus: models.INACTIVE,
257+
memberships: []models.ComputeAllocationMembership{testMembership()},
258+
})
259+
slurm := &fakeSlurmClient{existing: []client.Association{
260+
{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
261+
}}
262+
NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
263+
264+
// Desired is empty for an inactive allocation, so the empty-desired guard
265+
// holds and nothing is revoked. The event handler does that job.
266+
if n := len(slurm.allDeletes()); n != 0 {
267+
t.Fatalf("expected the empty-desired guard to hold, got %d deletes", n)
268+
}
269+
}

connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,26 @@ func (f *fakeSlurmClient) all() []client.Association {
6969
}
7070

7171
type mockOpts struct {
72-
provisionedAt *time.Time
73-
resources []models.ComputeAllocationResource
74-
overrides []models.ComputeAllocationMembershipResourceOverride
75-
memberships []models.ComputeAllocationMembership
72+
allocationStatus models.AllocationStatus
73+
unmanagedAccounts bool
74+
provisionedAt *time.Time
75+
resources []models.ComputeAllocationResource
76+
overrides []models.ComputeAllocationMembershipResourceOverride
77+
memberships []models.ComputeAllocationMembership
7678
}
7779

7880
func coreMock(o mockOpts) *service.CoreServiceMock {
81+
allocStatus := o.allocationStatus
82+
if allocStatus == "" {
83+
allocStatus = models.ACTIVE
84+
}
7985
resources := o.resources
8086
if resources == nil {
8187
resources = []models.ComputeAllocationResource{{ID: "res-1", Name: "compute", ResourceType: "cpu"}}
8288
}
8389
return &service.CoreServiceMock{
8490
GetComputeAllocationFunc: func(ctx context.Context, id string) (*models.ComputeAllocation, error) {
85-
return &models.ComputeAllocation{ID: id, Name: "test-alloc", ComputeClusterID: "cluster-1"}, nil
91+
return &models.ComputeAllocation{ID: id, Name: "test-alloc", ComputeClusterID: "cluster-1", Status: allocStatus}, nil
8692
},
8793
GetComputeClusterFunc: func(ctx context.Context, id string) (*models.ComputeCluster, error) {
8894
return &models.ComputeCluster{ID: id, Name: "testcluster"}, nil
@@ -102,6 +108,14 @@ func coreMock(o mockOpts) *service.CoreServiceMock {
102108
LocalUsername: "testuser", ProvisionedAt: o.provisionedAt,
103109
}, nil
104110
},
111+
ListComputeAllocationsByClusterFunc: func(ctx context.Context, clusterID string) ([]models.ComputeAllocation, error) {
112+
if o.unmanagedAccounts {
113+
return nil, nil
114+
}
115+
return []models.ComputeAllocation{
116+
{ID: "alloc-1", Name: "test-alloc", ComputeClusterID: clusterID, Status: allocStatus},
117+
}, nil
118+
},
105119
ListComputeClustersFunc: func(ctx context.Context) ([]models.ComputeCluster, error) {
106120
return []models.ComputeCluster{{ID: "cluster-1", Name: "testcluster"}}, nil
107121
},

0 commit comments

Comments
 (0)