Skip to content

Commit e742258

Browse files
Remove Slurm access the allocations no longer grant
Fixes #540
1 parent 01df980 commit e742258

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
@@ -85,71 +85,88 @@ func (a *AssociationSubscriber) upsertAssociationsForMembership(ctx context.Cont
8585
return a.syncAssociationsForMembership(ctx, membership, nil)
8686
}
8787

88-
// syncAssociationsForMembership builds the association for each of the
89-
// allocation's resources and writes the ones that differ from existing, the
90-
// state already on the cluster. A nil existing writes all of them.
88+
// syncAssociationsForMembership writes the associations this membership should
89+
// have, skipping any that already match existing, the state already on the
90+
// cluster. Pass nil to skip that comparison and write every record.
9191
//
9292
// Every caller goes through here on purpose. Slurm keys an association by
9393
// (cluster, account, user, partition) and its upsert is last-write-wins, so a
9494
// caller that built the record without limits would wipe limits another caller
9595
// had set. Folding the per-member overrides in here keeps all writers
9696
// producing the same record for the same key.
9797
func (a *AssociationSubscriber) syncAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership, existing map[assocKey]client.Association) error {
98+
desired, err := a.desiredAssociationsForMembership(ctx, membership)
99+
if err != nil {
100+
return err
101+
}
102+
for _, association := range desired {
103+
if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
104+
continue
105+
}
106+
if err := a.slurmClient.UpsertAssociation(association); err != nil {
107+
return fmt.Errorf("upsert association for partition %s: %w", association.Partition, err)
108+
}
109+
slog.Info("Upserted association", "association", association)
110+
}
111+
return nil
112+
}
113+
114+
// desiredAssociationsForMembership builds the associations this membership
115+
// should have on the cluster, without writing anything. An inactive allocation
116+
// grants nothing, so it yields an empty set rather than an error.
117+
func (a *AssociationSubscriber) desiredAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership) ([]client.Association, error) {
98118
allocation, err := a.coreService.GetComputeAllocation(ctx, membership.ComputeAllocationID)
99119
if err != nil {
100-
return fmt.Errorf("get compute allocation: %w", err)
120+
return nil, fmt.Errorf("get compute allocation: %w", err)
121+
}
122+
if !activeAllocation(*allocation) {
123+
return nil, nil
101124
}
102125
cluster, err := a.coreService.GetComputeCluster(ctx, allocation.ComputeClusterID)
103126
if err != nil {
104-
return fmt.Errorf("get compute cluster: %w", err)
127+
return nil, fmt.Errorf("get compute cluster: %w", err)
105128
}
106129
csu, err := a.coreService.GetComputeClusterUserByPair(ctx, cluster.ID, membership.UserID)
107130
if err != nil {
108-
return fmt.Errorf("get compute cluster user: %w", err)
131+
return nil, fmt.Errorf("get compute cluster user: %w", err)
109132
}
110133
if csu.ProvisionedAt == nil {
111-
return errNotProvisioned
134+
return nil, errNotProvisioned
112135
}
113136

114137
resources, err := a.coreService.ListResourcesForAllocation(ctx, allocation.ID)
115138
if err != nil {
116-
return fmt.Errorf("list resources for allocation: %w", err)
139+
return nil, fmt.Errorf("list resources for allocation: %w", err)
117140
}
118141
if len(resources) == 0 {
119142
// Nothing to map onto a partition. Skipping beats guessing a
120143
// partition name the cluster may not have.
121144
slog.Warn("Allocation has no resources, no association written",
122145
"allocation_id", allocation.ID, "user_id", membership.UserID)
123-
return nil
146+
return nil, nil
124147
}
125148

126149
overrides, err := a.coreService.ListOverridesForMembership(ctx, membership.ID)
127150
if err != nil {
128-
return fmt.Errorf("list overrides for membership: %w", err)
151+
return nil, fmt.Errorf("list overrides for membership: %w", err)
129152
}
130153
overrideByResource := make(map[string]models.ComputeAllocationMembershipResourceOverride, len(overrides))
131154
for _, o := range overrides {
132155
overrideByResource[o.ComputeAllocationResourceID] = o
133156
}
134157

158+
out := make([]client.Association, 0, len(resources))
135159
for _, resource := range resources {
136-
association := client.Association{
160+
out = append(out, client.Association{
137161
Account: allocation.Name,
138162
Cluster: cluster.Name,
139163
User: csu.LocalUsername,
140164
Partition: resource.Name,
141165
QoS: []string{"normal"},
142166
Limits: limitsFor(resource, overrideByResource[resource.ID]),
143-
}
144-
if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
145-
continue
146-
}
147-
if err := a.slurmClient.UpsertAssociation(association); err != nil {
148-
return fmt.Errorf("upsert association for partition %s: %w", resource.Name, err)
149-
}
150-
slog.Info("Upserted association", "association", association)
167+
})
151168
}
152-
return nil
169+
return out, nil
153170
}
154171

155172
// 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)