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 <[email protected]> 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, } }
