You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by di...@apache.org on 2026/05/18 00:31:47 UTC
(airavata-custos) 07/09: Implemented the service layer for Compute Allocation Memberships
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
commit 9d6333262129e7c01cc67ea451341ab62c5842db
Author: DImuthuUpe <di...@gmail.com>
AuthorDate: Sat May 16 21:32:55 2026 -0400
Implemented the service layer for Compute Allocation Memberships
---
docs/API-Docs.md | 92 +++++++++
.../000008_compute_allocation_memberships.down.sql | 18 ++
.../000008_compute_allocation_memberships.up.sql | 34 ++++
internal/server/server.go | 105 ++++++++++
.../store/compute_allocation_membership_store.go | 122 ++++++++++++
internal/store/store.go | 22 +++
pkg/models/allocation.go | 14 +-
pkg/service/compute_allocation_membership.go | 220 +++++++++++++++++++++
pkg/service/service.go | 4 +
9 files changed, 624 insertions(+), 7 deletions(-)
diff --git a/docs/API-Docs.md b/docs/API-Docs.md
index 7854758d0..2a308db05 100644
--- a/docs/API-Docs.md
+++ b/docs/API-Docs.md
@@ -953,6 +953,98 @@ Return the most recent event for the given change request.
---
+## Compute Allocation Memberships
+
+A `ComputeAllocationMembership` records a user's sub-allocation against a
+parent `ComputeAllocation` — i.e. how many SUs of the parent allocation the
+user is entitled to consume, and the time window plus lifecycle status of
+that grant. At most one membership can exist per `(compute_allocation_id,
+user_id)` pair (enforced by a unique key). Memberships are cascade-deleted
+when their parent allocation is removed.
+
+### POST `/compute-allocation-memberships`
+
+Create a new membership.
+
+**Request body**
+
+```json
+{
+ "compute_allocation_id": "alloc-123",
+ "user_id": "user-456",
+ "allocation_amount": 50000,
+ "start_time": "2026-01-01T00:00:00Z",
+ "end_time": "2026-12-31T23:59:59Z",
+ "membership_status": "ACTIVE"
+}
+```
+
+- `compute_allocation_id` and `user_id` are required and must reference
+ existing rows.
+- `membership_status` defaults to `ACTIVE` when omitted.
+- `id` is generated server-side when omitted.
+
+**Errors**
+
+- `400` — missing required fields, or referenced allocation/user not found.
+- `409` — a membership already exists for this `(allocation, user)` pair.
+
+### GET `/compute-allocation-memberships/{id}`
+
+Retrieve a membership by ID.
+
+### PUT `/compute-allocation-memberships/{id}`
+
+Replace mutable fields of a membership. Fields left blank/zero in the request
+body fall back to the stored value (partial updates).
+
+### PUT `/compute-allocation-memberships/{id}/allocation-amount`
+
+Update only the SU sub-allocation granted to the user.
+
+**Request body**
+
+```json
+{ "allocation_amount": 75000 }
+```
+
+**Errors**
+
+- `400` — negative `allocation_amount`.
+- `404` — no membership with the given ID.
+
+### PUT `/compute-allocation-memberships/{id}/status`
+
+Update only the lifecycle status of the membership (`ACTIVE`, `INACTIVE`,
+`DELETED`, etc.).
+
+**Request body**
+
+```json
+{ "membership_status": "INACTIVE" }
+```
+
+**Errors**
+
+- `400` — empty `membership_status`.
+- `404` — no membership with the given ID.
+
+### DELETE `/compute-allocation-memberships/{id}`
+
+Remove a membership.
+
+### GET `/compute-allocations/{id}/memberships`
+
+List every membership recorded against the given allocation, ordered by
+`start_time` ascending.
+
+### GET `/users/{id}/compute-allocation-memberships`
+
+List every allocation membership held by the given user, ordered by
+`start_time` ascending.
+
+---
+
## End-to-end example
```bash
diff --git a/internal/db/migrations/000008_compute_allocation_memberships.down.sql b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
new file mode 100644
index 000000000..731073898
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
@@ -0,0 +1,18 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+DROP TABLE IF EXISTS compute_allocation_memberships;
diff --git a/internal/db/migrations/000008_compute_allocation_memberships.up.sql b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
new file mode 100644
index 000000000..17f753566
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
@@ -0,0 +1,34 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+CREATE TABLE IF NOT EXISTS compute_allocation_memberships
+(
+ id VARCHAR(255) NOT NULL,
+ compute_allocation_id VARCHAR(255) NOT NULL,
+ user_id VARCHAR(255) NOT NULL,
+ allocation_amount BIGINT NOT NULL DEFAULT 0,
+ start_time TIMESTAMP(6) NOT NULL,
+ end_time TIMESTAMP(6) NOT NULL,
+ membership_status VARCHAR(64) NOT NULL,
+ created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_compute_allocation_memberships_allocation_user (compute_allocation_id, user_id),
+ KEY idx_compute_allocation_memberships_user (user_id),
+ KEY idx_compute_allocation_memberships_status (membership_status),
+ CONSTRAINT fk_compute_allocation_memberships_allocation FOREIGN KEY (compute_allocation_id)
+ REFERENCES compute_allocations (id) ON DELETE CASCADE
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
diff --git a/internal/server/server.go b/internal/server/server.go
index 70f1e5948..bcf633a8c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -99,6 +99,15 @@ func (s *Server) routes() {
s.mux.HandleFunc("DELETE /compute-allocation-change-request-events/{id}", s.deleteComputeAllocationChangeRequestEvent)
s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}/events", s.listEventsForChangeRequest)
s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}/events/latest", s.getLatestEventForChangeRequest)
+
+ s.mux.HandleFunc("POST /compute-allocation-memberships", s.createComputeAllocationMembership)
+ s.mux.HandleFunc("GET /compute-allocation-memberships/{id}", s.getComputeAllocationMembership)
+ s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}", s.updateComputeAllocationMembership)
+ s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}/allocation-amount", s.updateMembershipAllocationAmount)
+ s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}/status", s.updateMembershipStatus)
+ s.mux.HandleFunc("DELETE /compute-allocation-memberships/{id}", s.deleteComputeAllocationMembership)
+ s.mux.HandleFunc("GET /compute-allocations/{id}/memberships", s.listMembersForAllocation)
+ s.mux.HandleFunc("GET /users/{id}/compute-allocation-memberships", s.listAllocationsForUser)
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
@@ -517,6 +526,102 @@ func (s *Server) getLatestEventForChangeRequest(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, evt)
}
+func (s *Server) createComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+ var m models.ComputeAllocationMembership
+ if err := decodeJSON(r, &m); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ created, err := s.svc.CreateComputeAllocationMembership(r.Context(), &m)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+ m, err := s.svc.GetComputeAllocationMembership(r.Context(), r.PathValue("id"))
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) updateComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+ var m models.ComputeAllocationMembership
+ if err := decodeJSON(r, &m); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ m.ID = r.PathValue("id")
+ updated, err := s.svc.UpdateComputeAllocationMembership(r.Context(), &m)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipAllocationAmount(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ AllocationAmount int64 `json:"allocation_amount"`
+ }
+ if err := decodeJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ updated, err := s.svc.UpdateMembershipAllocationAmount(r.Context(), r.PathValue("id"), body.AllocationAmount)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipStatus(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ MembershipStatus models.AllocationStatus `json:"membership_status"`
+ }
+ if err := decodeJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ updated, err := s.svc.UpdateMembershipStatus(r.Context(), r.PathValue("id"), body.MembershipStatus)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) deleteComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+ if err := s.svc.DeleteComputeAllocationMembership(r.Context(), r.PathValue("id")); err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) listMembersForAllocation(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.svc.ListMembersForAllocation(r.Context(), r.PathValue("id"))
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, rows)
+}
+
+func (s *Server) listAllocationsForUser(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.svc.ListAllocationsForUser(r.Context(), r.PathValue("id"))
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, rows)
+}
+
// LoggingMiddleware logs every request once it completes.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/store/compute_allocation_membership_store.go b/internal/store/compute_allocation_membership_store.go
new file mode 100644
index 000000000..aae350ac9
--- /dev/null
+++ b/internal/store/compute_allocation_membership_store.go
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/jmoiron/sqlx"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+const computeAllocationMembershipColumns = "id, compute_allocation_id, user_id, allocation_amount, start_time, end_time, membership_status"
+
+type mysqlComputeAllocationMembershipStore struct {
+ db *sqlx.DB
+}
+
+// NewComputeAllocationMembershipStore returns a MySQL-backed
+// ComputeAllocationMembershipStore.
+func NewComputeAllocationMembershipStore(db *sqlx.DB) ComputeAllocationMembershipStore {
+ return &mysqlComputeAllocationMembershipStore{db: db}
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocationMembership, error) {
+ var m models.ComputeAllocationMembership
+ err := s.db.GetContext(ctx, &m,
+ `SELECT `+computeAllocationMembershipColumns+` FROM compute_allocation_memberships WHERE id = ?`, id)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByPair(ctx context.Context, allocationID, userID string) (*models.ComputeAllocationMembership, error) {
+ var m models.ComputeAllocationMembership
+ err := s.db.GetContext(ctx, &m,
+ `SELECT `+computeAllocationMembershipColumns+`
+ FROM compute_allocation_memberships
+ WHERE compute_allocation_id = ? AND user_id = ?`, allocationID, userID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error) {
+ var rows []models.ComputeAllocationMembership
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+computeAllocationMembershipColumns+`
+ FROM compute_allocation_memberships
+ WHERE compute_allocation_id = ?
+ ORDER BY start_time`, allocationID)
+ if err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+ var rows []models.ComputeAllocationMembership
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+computeAllocationMembershipColumns+`
+ FROM compute_allocation_memberships
+ WHERE user_id = ?
+ ORDER BY start_time`, userID)
+ if err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Create(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error {
+ _, err := tx.ExecContext(ctx,
+ `INSERT INTO compute_allocation_memberships
+ (id, compute_allocation_id, user_id, allocation_amount, start_time, end_time, membership_status)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ m.ID, m.ComputeAllocationID, m.UserID, m.AllocationAmount, m.StartTime, m.EndTime, string(m.MembershipStatus))
+ return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Update(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error {
+ _, err := tx.ExecContext(ctx,
+ `UPDATE compute_allocation_memberships
+ SET compute_allocation_id = ?,
+ user_id = ?,
+ allocation_amount = ?,
+ start_time = ?,
+ end_time = ?,
+ membership_status = ?
+ WHERE id = ?`,
+ m.ComputeAllocationID, m.UserID, m.AllocationAmount, m.StartTime, m.EndTime, string(m.MembershipStatus), m.ID)
+ return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Delete(ctx context.Context, tx *sql.Tx, id string) error {
+ _, err := tx.ExecContext(ctx, `DELETE FROM compute_allocation_memberships WHERE id = ?`, id)
+ return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 54a367c49..3498072a3 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -208,3 +208,25 @@ type ComputeAllocationChangeRequestEventStore interface {
// Delete removes an event by ID within the provided transaction.
Delete(ctx context.Context, tx *sql.Tx, id string) error
}
+
+// ComputeAllocationMembershipStore defines persistence operations for the
+// per-user membership of a compute allocation, including the SU sub-allocation
+// granted to that user and the membership lifecycle.
+type ComputeAllocationMembershipStore interface {
+ // FindByID returns the membership with the given ID, or nil if it does not exist.
+ FindByID(ctx context.Context, id string) (*models.ComputeAllocationMembership, error)
+ // FindByPair returns the membership for a (allocation, user) pair, or nil if absent.
+ FindByPair(ctx context.Context, allocationID, userID string) (*models.ComputeAllocationMembership, error)
+ // FindByAllocation returns every membership recorded against the given
+ // allocation, ordered by start_time ascending.
+ FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error)
+ // FindByUser returns every membership held by the given user, ordered by
+ // start_time ascending.
+ FindByUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error)
+ // Create inserts a new membership within the provided transaction.
+ Create(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error
+ // Update replaces mutable fields of an existing membership within the provided transaction.
+ Update(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error
+ // Delete removes a membership by ID within the provided transaction.
+ Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go
index a6003f987..04bd11b4b 100644
--- a/pkg/models/allocation.go
+++ b/pkg/models/allocation.go
@@ -89,11 +89,11 @@ type ComputeAllocationUsage struct { // Represents the usage of a compute alloca
}
type ComputeAllocationMembership struct {
- ID string `json:"id"`
- ComputeAllocationID string `json:"compute_allocation_id"`
- UserID string `json:"user_id"`
- AllocationAmount int64 `json:"allocation_amount"` // SUs allocated to the user, e.g., 100 CPU hours, 50 GPU hours, etc.
- StartTime time.Time `json:"start_time"`
- EndTime time.Time `json:"end_time"`
- MembershipStatus AllocationStatus `json:"membership_status"` // ACTIVE, INACTIVE, etc.
+ ID string `json:"id" db:"id"`
+ ComputeAllocationID string `json:"compute_allocation_id" db:"compute_allocation_id"`
+ UserID string `json:"user_id" db:"user_id"`
+ AllocationAmount int64 `json:"allocation_amount" db:"allocation_amount"` // SUs allocated to the user, e.g., 100 CPU hours, 50 GPU hours, etc.
+ StartTime time.Time `json:"start_time" db:"start_time"`
+ EndTime time.Time `json:"end_time" db:"end_time"`
+ MembershipStatus AllocationStatus `json:"membership_status" db:"membership_status"` // ACTIVE, INACTIVE, etc.
}
diff --git a/pkg/service/compute_allocation_membership.go b/pkg/service/compute_allocation_membership.go
new file mode 100644
index 000000000..b010224c5
--- /dev/null
+++ b/pkg/service/compute_allocation_membership.go
@@ -0,0 +1,220 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateComputeAllocationMembership grants a user a sub-allocation against a
+// compute allocation. Both the referenced allocation and user must exist, and
+// the (allocation, user) pair must not already have a membership.
+// MembershipStatus defaults to ACTIVE when unset.
+func (s *Service) CreateComputeAllocationMembership(ctx context.Context, m *models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, error) {
+ if m == nil {
+ return nil, fmt.Errorf("%w: compute allocation membership is nil", ErrInvalidInput)
+ }
+ if m.ComputeAllocationID == "" {
+ return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+ }
+ if m.UserID == "" {
+ return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+ }
+
+ if alloc, err := s.allocs.FindByID(ctx, m.ComputeAllocationID); err != nil {
+ return nil, fmt.Errorf("lookup compute allocation: %w", err)
+ } else if alloc == nil {
+ return nil, fmt.Errorf("%w: compute allocation %q not found", ErrInvalidInput, m.ComputeAllocationID)
+ }
+ if u, err := s.users.FindByID(ctx, m.UserID); err != nil {
+ return nil, fmt.Errorf("lookup user: %w", err)
+ } else if u == nil {
+ return nil, fmt.Errorf("%w: user %q not found", ErrInvalidInput, m.UserID)
+ }
+
+ if existing, err := s.memberships.FindByPair(ctx, m.ComputeAllocationID, m.UserID); err != nil {
+ return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+ } else if existing != nil {
+ return nil, fmt.Errorf("%w: user %q already has a membership on allocation %q", ErrAlreadyExists, m.UserID, m.ComputeAllocationID)
+ }
+
+ if m.ID == "" {
+ m.ID = newID()
+ }
+ if m.MembershipStatus == "" {
+ m.MembershipStatus = models.ACTIVE
+ }
+
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ return s.memberships.Create(ctx, tx, m)
+ }); err != nil {
+ return nil, fmt.Errorf("create compute allocation membership: %w", err)
+ }
+ return m, nil
+}
+
+// GetComputeAllocationMembership retrieves a membership by its ID. Returns
+// ErrNotFound when no membership matches.
+func (s *Service) GetComputeAllocationMembership(ctx context.Context, id string) (*models.ComputeAllocationMembership, error) {
+ m, err := s.memberships.FindByID(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("get compute allocation membership: %w", err)
+ }
+ if m == nil {
+ return nil, ErrNotFound
+ }
+ return m, nil
+}
+
+// ListMembersForAllocation returns every membership recorded against the
+// given allocation, ordered by start_time ascending.
+func (s *Service) ListMembersForAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error) {
+ if allocationID == "" {
+ return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+ }
+ rows, err := s.memberships.FindByAllocation(ctx, allocationID)
+ if err != nil {
+ return nil, fmt.Errorf("list members for allocation: %w", err)
+ }
+ return rows, nil
+}
+
+// ListAllocationsForUser returns every membership held by the given user,
+// ordered by start_time ascending.
+func (s *Service) ListAllocationsForUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+ if userID == "" {
+ return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+ }
+ rows, err := s.memberships.FindByUser(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("list allocations for user: %w", err)
+ }
+ return rows, nil
+}
+
+// UpdateComputeAllocationMembership replaces mutable fields of an existing
+// membership. Fields left blank/zero on the supplied record fall back to the
+// stored value (so callers can perform partial updates).
+func (s *Service) UpdateComputeAllocationMembership(ctx context.Context, m *models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, error) {
+ if m == nil || m.ID == "" {
+ return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+ }
+ existing, err := s.memberships.FindByID(ctx, m.ID)
+ if err != nil {
+ return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+ }
+ if existing == nil {
+ return nil, ErrNotFound
+ }
+ if m.ComputeAllocationID == "" {
+ m.ComputeAllocationID = existing.ComputeAllocationID
+ }
+ if m.UserID == "" {
+ m.UserID = existing.UserID
+ }
+ if m.StartTime.IsZero() {
+ m.StartTime = existing.StartTime
+ }
+ if m.EndTime.IsZero() {
+ m.EndTime = existing.EndTime
+ }
+ if m.MembershipStatus == "" {
+ m.MembershipStatus = existing.MembershipStatus
+ }
+
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ return s.memberships.Update(ctx, tx, m)
+ }); err != nil {
+ return nil, fmt.Errorf("update compute allocation membership: %w", err)
+ }
+ return m, nil
+}
+
+// UpdateMembershipAllocationAmount sets the SU sub-allocation granted to the
+// user identified by the given membership ID. Other fields are preserved.
+func (s *Service) UpdateMembershipAllocationAmount(ctx context.Context, id string, amount int64) (*models.ComputeAllocationMembership, error) {
+ if id == "" {
+ return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+ }
+ if amount < 0 {
+ return nil, fmt.Errorf("%w: allocation_amount must be non-negative", ErrInvalidInput)
+ }
+ existing, err := s.memberships.FindByID(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+ }
+ if existing == nil {
+ return nil, ErrNotFound
+ }
+ existing.AllocationAmount = amount
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ return s.memberships.Update(ctx, tx, existing)
+ }); err != nil {
+ return nil, fmt.Errorf("update compute allocation membership amount: %w", err)
+ }
+ return existing, nil
+}
+
+// UpdateMembershipStatus sets the lifecycle status (ACTIVE, INACTIVE, etc.)
+// of the membership identified by the given ID. Other fields are preserved.
+func (s *Service) UpdateMembershipStatus(ctx context.Context, id string, status models.AllocationStatus) (*models.ComputeAllocationMembership, error) {
+ if id == "" {
+ return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+ }
+ if status == "" {
+ return nil, fmt.Errorf("%w: membership_status is required", ErrInvalidInput)
+ }
+ existing, err := s.memberships.FindByID(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+ }
+ if existing == nil {
+ return nil, ErrNotFound
+ }
+ existing.MembershipStatus = status
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ return s.memberships.Update(ctx, tx, existing)
+ }); err != nil {
+ return nil, fmt.Errorf("update compute allocation membership status: %w", err)
+ }
+ return existing, nil
+}
+
+// DeleteComputeAllocationMembership removes a membership by ID.
+func (s *Service) DeleteComputeAllocationMembership(ctx context.Context, id string) error {
+ if id == "" {
+ return fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+ }
+ existing, err := s.memberships.FindByID(ctx, id)
+ if err != nil {
+ return fmt.Errorf("lookup compute allocation membership: %w", err)
+ }
+ if existing == nil {
+ return ErrNotFound
+ }
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ return s.memberships.Delete(ctx, tx, id)
+ }); err != nil {
+ return fmt.Errorf("delete compute allocation membership: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index 1942451be..e8145c0d5 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -45,6 +45,7 @@ type Service struct {
allocDiffs store.ComputeAllocationDiffStore
changeRequests store.ComputeAllocationChangeRequestStore
changeEvents store.ComputeAllocationChangeRequestEventStore
+ memberships store.ComputeAllocationMembershipStore
}
// New constructs a Service backed by the supplied database handle.
@@ -63,6 +64,7 @@ func New(database *sqlx.DB) *Service {
allocDiffs: store.NewComputeAllocationDiffStore(database),
changeRequests: store.NewComputeAllocationChangeRequestStore(database),
changeEvents: store.NewComputeAllocationChangeRequestEventStore(database),
+ memberships: store.NewComputeAllocationMembershipStore(database),
}
}
@@ -82,6 +84,7 @@ func NewWithStores(
allocDiffs store.ComputeAllocationDiffStore,
changeRequests store.ComputeAllocationChangeRequestStore,
changeEvents store.ComputeAllocationChangeRequestEventStore,
+ memberships store.ComputeAllocationMembershipStore,
) *Service {
return &Service{
db: database,
@@ -96,6 +99,7 @@ func NewWithStores(
allocDiffs: allocDiffs,
changeRequests: changeRequests,
changeEvents: changeEvents,
+ memberships: memberships,
}
}