Skip to content

Commit 3cf89c2

Browse files
Persist the resource layer for AMIE-ingested allocations (#546)
* Create resource mappings from AMIE project packets Fixes #539 * Cover AMIE resource mapping creation in the baseline scenario
1 parent aa58c75 commit 3cf89c2

6 files changed

Lines changed: 132 additions & 4 deletions

File tree

connectors/ACCESS/AMIE-Processor/handler/request_project_create.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"database/sql"
2323
"errors"
2424
"fmt"
25+
"log/slog"
2526
"strings"
2627

2728
"go.opentelemetry.io/otel/codes"
@@ -114,6 +115,10 @@ func (h *RequestProjectCreateHandler) Handle(ctx context.Context, tx *sql.Tx, pa
114115
return fmt.Errorf("request_project_create: audit CREATE_ALLOCATION: %w", err)
115116
}
116117

118+
if err := h.ensureResourceMappings(ctx, tx, body, allocation, packet, eventID); err != nil {
119+
return fmt.Errorf("request_project_create: ensure resource mappings: %w", err)
120+
}
121+
117122
piMembership, created, err := h.ensurePIMembership(ctx, allocation, pi.ID)
118123
if err != nil {
119124
return fmt.Errorf("request_project_create: ensure PI membership: %w", err)
@@ -240,6 +245,35 @@ func (h *RequestProjectCreateHandler) ensureAllocation(ctx context.Context, body
240245
})
241246
}
242247

248+
// ensureResourceMappings grants the allocation each packet resource found in
249+
// the cluster's catalog. AMIE expresses the grant only in service units,
250+
// already stored on the allocation, so mappings carry no native caps;
251+
// unregistered names are skipped rather than failing the packet.
252+
func (h *RequestProjectCreateHandler) ensureResourceMappings(ctx context.Context, tx *sql.Tx, body map[string]any, allocation *models.ComputeAllocation, packet *model.Packet, eventID string) error {
253+
for _, name := range getResourceList(body) {
254+
resource, err := h.svc.GetComputeAllocationResourceByNameAndCluster(ctx, name, h.clusterID)
255+
if errors.Is(err, service.ErrNotFound) {
256+
slog.Warn("amie: packet resource not in the cluster catalog, mapping skipped",
257+
"resource", name, "allocation_id", allocation.ID)
258+
continue
259+
}
260+
if err != nil {
261+
return fmt.Errorf("lookup resource %q: %w", name, err)
262+
}
263+
mapping, err := h.svc.AttachResourceToAllocation(ctx, allocation.ID, resource.ID, 0, 0)
264+
if errors.Is(err, service.ErrAlreadyExists) {
265+
continue
266+
}
267+
if err != nil {
268+
return fmt.Errorf("attach resource %q: %w", name, err)
269+
}
270+
if err := h.auditSvc.Log(ctx, tx, packet.ID, eventID, model.AuditAttachResource, "compute_allocation_resource_mapping", mapping.ID, name); err != nil {
271+
return fmt.Errorf("audit ATTACH_RESOURCE: %w", err)
272+
}
273+
}
274+
return nil
275+
}
276+
243277
// ensurePIMembership asserts the PI as a role=PI membership on the allocation.
244278
// Returns (membership, created=true) when it inserted a new row, or
245279
// (existing, created=false) on redelivery. The service rejects a second

connectors/ACCESS/AMIE-Processor/handler/request_project_create_integration_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"testing"
2828

2929
"github.com/apache/airavata-custos/connectors/ACCESS/AMIE-Processor/model"
30+
"github.com/apache/airavata-custos/pkg/models"
3031
)
3132

3233
// baseRPCBody returns a fully populated request_project_create body with
@@ -91,6 +92,10 @@ func TestRequestProjectCreate_HappyPath(t *testing.T) {
9192
if got := countRows(t, database, "compute_allocations"); got != 1 {
9293
t.Errorf("compute_allocations: got %d, want 1", got)
9394
}
95+
// Unregistered packet resources create no mapping and do not fail the packet.
96+
if got := countRows(t, database, "compute_allocation_resource_mappings"); got != 0 {
97+
t.Errorf("compute_allocation_resource_mappings: got %d, want 0 (resource not in catalog)", got)
98+
}
9499

95100
var org struct {
96101
OriginatedID string `db:"originated_id"`
@@ -356,5 +361,71 @@ func TestRequestProjectCreate_ReplyFailurePropagates(t *testing.T) {
356361
}
357362
}
358363

364+
// TestRequestProjectCreate_AttachesRegisteredResources asserts a catalog-registered
365+
// packet resource is mapped with no caps and not duplicated on re-delivery.
366+
func TestRequestProjectCreate_AttachesRegisteredResources(t *testing.T) {
367+
database := setupTestDB(t)
368+
369+
svc := newTestCoreService(database)
370+
audit := newTestAuditService(database)
371+
amie := &fakeAmieClient{}
372+
h := NewRequestProjectCreateHandler(svc, testClusterID, amie, audit)
373+
374+
resource, err := svc.CreateComputeAllocationResource(context.Background(), &models.ComputeAllocationResource{
375+
Name: "compute.access-ci.org",
376+
ResourceType: "CPU_HOURS",
377+
ResourceAmount: 1000,
378+
ComputeClusterID: testClusterID,
379+
})
380+
if err != nil {
381+
t.Fatalf("seed catalog resource: %v", err)
382+
}
383+
384+
body := baseRPCBody()
385+
pkt := insertPacket(t, database, "request_project_create", body)
386+
if err := runHandlerInTx(t, database, func(ctx context.Context, tx *sql.Tx) error {
387+
return h.Handle(ctx, tx, map[string]any{"type": pkt.Type, "body": body}, pkt, "")
388+
}); err != nil {
389+
t.Fatalf("Handle returned error: %v", err)
390+
}
391+
392+
var mapping struct {
393+
ComputeAllocationResourceID string `db:"compute_allocation_resource_id"`
394+
ResourceAmount int64 `db:"resource_amount"`
395+
ResourceTime int64 `db:"resource_time"`
396+
}
397+
if err := database.Get(&mapping,
398+
"SELECT compute_allocation_resource_id, resource_amount, resource_time FROM compute_allocation_resource_mappings LIMIT 1",
399+
); err != nil {
400+
t.Fatalf("read mapping: %v", err)
401+
}
402+
if mapping.ComputeAllocationResourceID != resource.ID {
403+
t.Errorf("mapping.resource_id: got %q, want %q", mapping.ComputeAllocationResourceID, resource.ID)
404+
}
405+
// The packet carries no native amounts; the SU budget is the limit.
406+
if mapping.ResourceAmount != 0 || mapping.ResourceTime != 0 {
407+
t.Errorf("mapping caps: got (%d,%d), want (0,0)", mapping.ResourceAmount, mapping.ResourceTime)
408+
}
409+
if got := countAuditActions(t, database, pkt.ID, model.AuditAttachResource); got != 1 {
410+
t.Errorf("audit ATTACH_RESOURCE: got %d, want 1", got)
411+
}
412+
413+
// Re-delivery (supplement) must not duplicate the mapping.
414+
second := baseRPCBody()
415+
second["AllocationType"] = "supplement"
416+
secondPkt := insertPacket(t, database, "request_project_create", second)
417+
if err := runHandlerInTx(t, database, func(ctx context.Context, tx *sql.Tx) error {
418+
return h.Handle(ctx, tx, map[string]any{"type": secondPkt.Type, "body": second}, secondPkt, "")
419+
}); err != nil {
420+
t.Fatalf("second Handle: %v", err)
421+
}
422+
if got := countRows(t, database, "compute_allocation_resource_mappings"); got != 1 {
423+
t.Errorf("mappings after re-delivery: got %d, want 1", got)
424+
}
425+
if got := countAuditActions(t, database, secondPkt.ID, model.AuditAttachResource); got != 0 {
426+
t.Errorf("audit ATTACH_RESOURCE on re-delivery: got %d, want 0", got)
427+
}
428+
}
429+
359430
// silence unused-import false positives when only some tests reference these.
360431
var _ = model.AuditCreatePerson

connectors/ACCESS/AMIE-Processor/model/audit.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const (
2929
AuditCreateAccount AuditAction = "CREATE_ACCOUNT"
3030
AuditCreateProject AuditAction = "CREATE_PROJECT"
3131
AuditCreateAllocation AuditAction = "CREATE_ALLOCATION"
32+
AuditAttachResource AuditAction = "ATTACH_RESOURCE"
3233
AuditInactivateProject AuditAction = "INACTIVATE_PROJECT"
3334
AuditReactivateProject AuditAction = "REACTIVATE_PROJECT"
3435
AuditCreateMembership AuditAction = "CREATE_MEMBERSHIP"

connectors/ACCESS/AMIE-Processor/pipeline/baseline_integration_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ func TestPipeline_BaselineDeterminism(t *testing.T) {
4848
{"compute_allocations", 2},
4949
{"compute_allocation_diffs", 1},
5050
{"amie_user_dns", 2},
51-
{"amie_audit_extras", 52},
51+
{"amie_audit_extras", 54},
5252
{"compute_cluster_users", 4},
5353
{"compute_allocation_memberships", 5},
5454
{"project_memberships", 4},
55+
// One per allocation; the supplement re-delivery does not duplicate.
56+
{"compute_allocation_resource_mappings", 2},
5557
}
5658
if decoded != 12 {
5759
t.Errorf("decoded packets: got %d, want 12", decoded)
@@ -69,8 +71,8 @@ func TestPipeline_BaselineDeterminism(t *testing.T) {
6971
); err != nil {
7072
t.Fatalf("count amie audit_events: %v", err)
7173
}
72-
if amieAuditEvents != 52 {
73-
t.Errorf("audit_events source='amie': got %d, want 52", amieAuditEvents)
74+
if amieAuditEvents != 54 {
75+
t.Errorf("audit_events source='amie': got %d, want 54", amieAuditEvents)
7476
}
7577

7678
// audit_log.by_action.

connectors/ACCESS/AMIE-Processor/pipeline/integration_common.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ func seedCluster(t *testing.T, database *sqlx.DB) {
164164
); err != nil {
165165
t.Fatalf("seed cluster: %v", err)
166166
}
167+
// Catalog entry matching the mock packets' ResourceList, so
168+
// request_project_create exercises the resource-mapping path.
169+
if _, err := database.Exec(
170+
"INSERT INTO compute_allocation_resources (id, name, resource_type, resource_amount, compute_cluster_id) VALUES (?, ?, ?, ?, ?)",
171+
"baseline-resource", "baseline-cluster.example.edu", "CPU_HOURS", 0, testClusterID,
172+
); err != nil {
173+
t.Fatalf("seed catalog resource: %v", err)
174+
}
167175
}
168176

169177
// testPipeline runs the AMIE connector in-process against the local mock

connectors/ACCESS/AMIE-Processor/testdata/scenarios/baseline.yaml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ trigger:
4646
seed:
4747
- table: compute_clusters
4848
row: { id: "${AMIE_CLUSTER_ID}", name: default-cluster }
49+
- table: compute_allocation_resources
50+
# Matches every packet's ResourceList so project_create attaches it.
51+
row: { id: baseline-resource, name: baseline-cluster.example.edu, resource_type: CPU_HOURS, resource_amount: 0, compute_cluster_id: "${AMIE_CLUSTER_ID}" }
4952

5053
expectations:
5154

@@ -155,16 +158,25 @@ expectations:
155158
CO_PI: 1
156159
ALLOCATION_MANAGER: 1
157160

161+
compute_allocation_resource_mappings:
162+
# One mapping per allocation from the seeded catalog row; the
163+
# supplement re-delivery does not duplicate.
164+
total_count: 2
165+
rows:
166+
- { compute_allocation_resource_id: baseline-resource, resource_amount: 0, resource_time: 0 }
167+
- { compute_allocation_resource_id: baseline-resource, resource_amount: 0, resource_time: 0 }
168+
158169
audit_log:
159170
# audit_events (source='amie') and amie_audit_extras carry the same count;
160171
# one row each per AMIE audit.
161-
total_count: 52
172+
total_count: 54
162173
by_action:
163174
PACKET_RECEIVED: 12 # one per packet
164175
CREATE_PERSON: 6 # 3 project_create + 3 account_create
165176
CREATE_ACCOUNT: 6 # 3 project_create + 3 account_create
166177
CREATE_PROJECT: 3
167178
CREATE_ALLOCATION: 3 # supplement-delivery still audits CREATE_ALLOCATION
179+
ATTACH_RESOURCE: 2 # one per allocation; supplement attach is a no-op
168180
CREATE_MEMBERSHIP: 5 # 2 PIs + 3 account_create
169181
REPLY_SENT: 11 # everything except inform_transaction_complete
170182
PERSIST_DNS: 3 # 2 from data_*, 1 delete from request_user_modify

0 commit comments

Comments
 (0)