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 f392978aeb75f4ffcf328f23f003b89c710194ad Author: DImuthuUpe <[email protected]> AuthorDate: Sat May 16 21:21:59 2026 -0400 Implemented the service layer for Allocation Change Requests and related events --- docs/API-Docs.md | 186 +++++++++++++++++ ...007_compute_allocation_change_requests.down.sql | 19 ++ ...00007_compute_allocation_change_requests.up.sql | 49 +++++ internal/server/server.go | 126 ++++++++++++ ...ompute_allocation_change_request_event_store.go | 97 +++++++++ .../compute_allocation_change_request_store.go | 109 ++++++++++ internal/store/store.go | 38 ++++ pkg/models/allocation.go | 28 +-- pkg/service/compute_allocation_change_request.go | 219 +++++++++++++++++++++ .../compute_allocation_change_request_event.go | 119 +++++++++++ pkg/service/service.go | 8 + 11 files changed, 984 insertions(+), 14 deletions(-) diff --git a/docs/API-Docs.md b/docs/API-Docs.md index 69674e582..7854758d0 100644 --- a/docs/API-Docs.md +++ b/docs/API-Docs.md @@ -767,6 +767,192 @@ Return the most recent diff (highest `timestamp`) for the given allocation. --- +## Compute Allocation Change Requests + +A change request represents a user- or admin-initiated proposal to mutate a +compute allocation — e.g. asking for additional Service Units or to change +its status. Each request carries a lifecycle (`change_status`: `PENDING`, +`APPROVED`, `REJECTED`, etc.). Change requests are cascade-deleted when their +parent allocation is deleted. Every create, update, and delete of a change +request transactionally appends an entry to its event log (see below); the +event log is intentionally **not** cascade-deleted so the audit trail +survives the deletion of the parent change request. + +### `POST /compute-allocation-change-requests` + +Submit a new change request. + +**Required fields:** `compute_allocation_id`, `requester_id` +**Optional fields:** `id`, `requested_su_amount`, `requested_status`, `reason`, `change_status` (defaults to `PENDING`), `approver_id`, `timestamp` (defaults to the server's current UTC time) + +**Request** + +```json +{ + "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", + "requested_su_amount": 120000, + "requested_status": "ACTIVE", + "reason": "Need more SUs for upcoming HPC runs", + "requester_id": "11112222-3333-4444-5555-666677778888" +} +``` + +**Response 201** + +```json +{ + "id": "9988aabb-ccdd-eeff-0011-223344556677", + "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", + "requested_su_amount": 120000, + "requested_status": "ACTIVE", + "reason": "Need more SUs for upcoming HPC runs", + "change_status": "PENDING", + "requester_id": "11112222-3333-4444-5555-666677778888", + "timestamp": "2026-05-16T17:42:11.918Z" +} +``` + +**Errors** + +- `400` — required field missing, or `compute_allocation_id` does not exist. + +--- + +### `GET /compute-allocation-change-requests/{id}` + +Retrieve a single change request by its ID. + +**Errors** + +- `404` — no change request matches the supplied ID. + +--- + +### `PUT /compute-allocation-change-requests/{id}` + +Replace mutable fields of a change request. Typically used by an approver to +transition `change_status` (e.g. to `APPROVED` or `REJECTED`) and stamp +`approver_id`. Omitted fields are preserved from the existing record. + +**Request** + +```json +{ + "change_status": "APPROVED", + "approver_id": "aaaa-bbbb-cccc-dddd-eeee" +} +``` + +**Errors** + +- `400` — request id missing. +- `404` — no change request matches the supplied ID. + +--- + +### `DELETE /compute-allocation-change-requests/{id}` + +Remove a change request and (cascading) its event log. + +**Response 204** — empty body on success. + +--- + +### `GET /compute-allocations/{id}/change-requests` + +List every change request ever submitted against the given allocation, +ordered by `timestamp` ascending. + +--- + +### `GET /users/{id}/change-requests` + +List every change request submitted by the given user, ordered by +`timestamp` ascending. + +--- + +## Compute Allocation Change Request Events + +Events are an append-only audit trail of state transitions applied to a +change request — typically `CREATED`, `APPROVED`, `REJECTED`, `UPDATED`, +`DELETED`, or arbitrary workflow markers. Create / update / delete of a +change request each emit an event automatically; clients may also append +custom events via the endpoint below. Events are **not** cascade-deleted +when their parent change request is removed, so the audit trail is +preserved indefinitely. + +### `POST /compute-allocation-change-request-events` + +Append a new event to a change request. + +**Required fields:** `compute_allocation_change_request_id`, `event_type` +**Optional fields:** `id`, `description`, `timestamp` (defaults to the server's current UTC time) + +**Request** + +```json +{ + "compute_allocation_change_request_id": "9988aabb-ccdd-eeff-0011-223344556677", + "event_type": "APPROVED", + "description": "Change request approved by admin" +} +``` + +**Response 201** + +```json +{ + "id": "ee11ff22-3344-5566-7788-99aabbccddee", + "compute_allocation_change_request_id": "9988aabb-ccdd-eeff-0011-223344556677", + "event_type": "APPROVED", + "description": "Change request approved by admin", + "timestamp": "2026-05-16T18:00:00.000Z" +} +``` + +**Errors** + +- `400` — required field missing, or `compute_allocation_change_request_id` does not exist. + +--- + +### `GET /compute-allocation-change-request-events/{id}` + +Retrieve a single event by its ID. + +**Errors** + +- `404` — no event matches the supplied ID. + +--- + +### `DELETE /compute-allocation-change-request-events/{id}` + +Remove an event record. Intended for administrative cleanup; events are +otherwise append-only. + +**Response 204** — empty body on success. + +--- + +### `GET /compute-allocation-change-requests/{id}/events` + +List every event recorded against the given change request, ordered by +`timestamp` ascending. + +--- + +### `GET /compute-allocation-change-requests/{id}/events/latest` + +Return the most recent event for the given change request. + +**Errors** + +- `404` — the change request has no events recorded. + +--- + ## End-to-end example ```bash diff --git a/internal/db/migrations/000007_compute_allocation_change_requests.down.sql b/internal/db/migrations/000007_compute_allocation_change_requests.down.sql new file mode 100644 index 000000000..8140a01cb --- /dev/null +++ b/internal/db/migrations/000007_compute_allocation_change_requests.down.sql @@ -0,0 +1,19 @@ +-- 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_change_request_events; +DROP TABLE IF EXISTS compute_allocation_change_requests; diff --git a/internal/db/migrations/000007_compute_allocation_change_requests.up.sql b/internal/db/migrations/000007_compute_allocation_change_requests.up.sql new file mode 100644 index 000000000..aa04a8571 --- /dev/null +++ b/internal/db/migrations/000007_compute_allocation_change_requests.up.sql @@ -0,0 +1,49 @@ +-- 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_change_requests +( + id VARCHAR(255) NOT NULL, + compute_allocation_id VARCHAR(255) NOT NULL, + requested_su_amount BIGINT NOT NULL DEFAULT 0, + requested_status VARCHAR(64) NOT NULL, + reason TEXT NOT NULL, + change_status VARCHAR(64) NOT NULL, + requester_id VARCHAR(255) NOT NULL, + approver_id VARCHAR(255) NOT NULL DEFAULT '', + timestamp TIMESTAMP(6) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_compute_allocation_change_requests_allocation (compute_allocation_id, timestamp), + KEY idx_compute_allocation_change_requests_status (change_status), + KEY idx_compute_allocation_change_requests_requester (requester_id), + CONSTRAINT fk_compute_allocation_change_requests_allocation FOREIGN KEY (compute_allocation_id) + REFERENCES compute_allocations (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS compute_allocation_change_request_events +( + id VARCHAR(255) NOT NULL, + compute_allocation_change_request_id VARCHAR(255) NOT NULL, + event_type VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + timestamp TIMESTAMP(6) NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_compute_allocation_change_request_events_request (compute_allocation_change_request_id, timestamp), + KEY idx_compute_allocation_change_request_events_type (event_type) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/internal/server/server.go b/internal/server/server.go index 3638c0751..70f1e5948 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -86,6 +86,19 @@ func (s *Server) routes() { s.mux.HandleFunc("DELETE /compute-allocation-diffs/{id}", s.deleteComputeAllocationDiff) s.mux.HandleFunc("GET /compute-allocations/{id}/diffs", s.listDiffsForAllocation) s.mux.HandleFunc("GET /compute-allocations/{id}/diffs/latest", s.getLatestDiffForAllocation) + + s.mux.HandleFunc("POST /compute-allocation-change-requests", s.createComputeAllocationChangeRequest) + s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}", s.getComputeAllocationChangeRequest) + s.mux.HandleFunc("PUT /compute-allocation-change-requests/{id}", s.updateComputeAllocationChangeRequest) + s.mux.HandleFunc("DELETE /compute-allocation-change-requests/{id}", s.deleteComputeAllocationChangeRequest) + s.mux.HandleFunc("GET /compute-allocations/{id}/change-requests", s.listChangeRequestsForAllocation) + s.mux.HandleFunc("GET /users/{id}/change-requests", s.listChangeRequestsByRequester) + + s.mux.HandleFunc("POST /compute-allocation-change-request-events", s.createComputeAllocationChangeRequestEvent) + s.mux.HandleFunc("GET /compute-allocation-change-request-events/{id}", s.getComputeAllocationChangeRequestEvent) + 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) } func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { @@ -391,6 +404,119 @@ func (s *Server) getLatestDiffForAllocation(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, diff) } +func (s *Server) createComputeAllocationChangeRequest(w http.ResponseWriter, r *http.Request) { + var req models.ComputeAllocationChangeRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + created, err := s.svc.CreateComputeAllocationChangeRequest(r.Context(), &req) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) getComputeAllocationChangeRequest(w http.ResponseWriter, r *http.Request) { + req, err := s.svc.GetComputeAllocationChangeRequest(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, req) +} + +func (s *Server) updateComputeAllocationChangeRequest(w http.ResponseWriter, r *http.Request) { + var req models.ComputeAllocationChangeRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + req.ID = r.PathValue("id") + updated, err := s.svc.UpdateComputeAllocationChangeRequest(r.Context(), &req) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (s *Server) deleteComputeAllocationChangeRequest(w http.ResponseWriter, r *http.Request) { + if err := s.svc.DeleteComputeAllocationChangeRequest(r.Context(), r.PathValue("id")); err != nil { + writeServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) listChangeRequestsForAllocation(w http.ResponseWriter, r *http.Request) { + rows, err := s.svc.ListChangeRequestsForAllocation(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, rows) +} + +func (s *Server) listChangeRequestsByRequester(w http.ResponseWriter, r *http.Request) { + rows, err := s.svc.ListChangeRequestsByRequester(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, rows) +} + +func (s *Server) createComputeAllocationChangeRequestEvent(w http.ResponseWriter, r *http.Request) { + var evt models.ComputeAllocationChangeRequestEvent + if err := decodeJSON(r, &evt); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + created, err := s.svc.CreateComputeAllocationChangeRequestEvent(r.Context(), &evt) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) getComputeAllocationChangeRequestEvent(w http.ResponseWriter, r *http.Request) { + evt, err := s.svc.GetComputeAllocationChangeRequestEvent(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, evt) +} + +func (s *Server) deleteComputeAllocationChangeRequestEvent(w http.ResponseWriter, r *http.Request) { + if err := s.svc.DeleteComputeAllocationChangeRequestEvent(r.Context(), r.PathValue("id")); err != nil { + writeServiceError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) listEventsForChangeRequest(w http.ResponseWriter, r *http.Request) { + rows, err := s.svc.ListEventsForChangeRequest(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, rows) +} + +func (s *Server) getLatestEventForChangeRequest(w http.ResponseWriter, r *http.Request) { + evt, err := s.svc.GetLatestEventForChangeRequest(r.Context(), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, evt) +} + // 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_change_request_event_store.go b/internal/store/compute_allocation_change_request_event_store.go new file mode 100644 index 000000000..087a696d5 --- /dev/null +++ b/internal/store/compute_allocation_change_request_event_store.go @@ -0,0 +1,97 @@ +// 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 computeAllocationChangeRequestEventColumns = "id, compute_allocation_change_request_id, event_type, description, timestamp" + +type mysqlComputeAllocationChangeRequestEventStore struct { + db *sqlx.DB +} + +// NewComputeAllocationChangeRequestEventStore returns a MySQL-backed +// ComputeAllocationChangeRequestEventStore. +func NewComputeAllocationChangeRequestEventStore(db *sqlx.DB) ComputeAllocationChangeRequestEventStore { + return &mysqlComputeAllocationChangeRequestEventStore{db: db} +} + +func (s *mysqlComputeAllocationChangeRequestEventStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocationChangeRequestEvent, error) { + var e models.ComputeAllocationChangeRequestEvent + err := s.db.GetContext(ctx, &e, + `SELECT `+computeAllocationChangeRequestEventColumns+` FROM compute_allocation_change_request_events WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +func (s *mysqlComputeAllocationChangeRequestEventStore) FindByChangeRequest(ctx context.Context, changeRequestID string) ([]models.ComputeAllocationChangeRequestEvent, error) { + var rows []models.ComputeAllocationChangeRequestEvent + err := s.db.SelectContext(ctx, &rows, + `SELECT `+computeAllocationChangeRequestEventColumns+` + FROM compute_allocation_change_request_events + WHERE compute_allocation_change_request_id = ? + ORDER BY timestamp`, changeRequestID) + if err != nil { + return nil, err + } + return rows, nil +} + +func (s *mysqlComputeAllocationChangeRequestEventStore) FindLatestByChangeRequest(ctx context.Context, changeRequestID string) (*models.ComputeAllocationChangeRequestEvent, error) { + var e models.ComputeAllocationChangeRequestEvent + err := s.db.GetContext(ctx, &e, + `SELECT `+computeAllocationChangeRequestEventColumns+` + FROM compute_allocation_change_request_events + WHERE compute_allocation_change_request_id = ? + ORDER BY timestamp DESC + LIMIT 1`, changeRequestID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +func (s *mysqlComputeAllocationChangeRequestEventStore) Create(ctx context.Context, tx *sql.Tx, e *models.ComputeAllocationChangeRequestEvent) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO compute_allocation_change_request_events + (id, compute_allocation_change_request_id, event_type, description, timestamp) + VALUES (?, ?, ?, ?, ?)`, + e.ID, e.ComputeAllocationChangeRequestID, e.EventType, e.Description, e.Timestamp) + return err +} + +func (s *mysqlComputeAllocationChangeRequestEventStore) Delete(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM compute_allocation_change_request_events WHERE id = ?`, id) + return err +} diff --git a/internal/store/compute_allocation_change_request_store.go b/internal/store/compute_allocation_change_request_store.go new file mode 100644 index 000000000..b0b87fe8f --- /dev/null +++ b/internal/store/compute_allocation_change_request_store.go @@ -0,0 +1,109 @@ +// 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 computeAllocationChangeRequestColumns = "id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp" + +type mysqlComputeAllocationChangeRequestStore struct { + db *sqlx.DB +} + +// NewComputeAllocationChangeRequestStore returns a MySQL-backed +// ComputeAllocationChangeRequestStore. +func NewComputeAllocationChangeRequestStore(db *sqlx.DB) ComputeAllocationChangeRequestStore { + return &mysqlComputeAllocationChangeRequestStore{db: db} +} + +func (s *mysqlComputeAllocationChangeRequestStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocationChangeRequest, error) { + var c models.ComputeAllocationChangeRequest + err := s.db.GetContext(ctx, &c, + `SELECT `+computeAllocationChangeRequestColumns+` FROM compute_allocation_change_requests WHERE id = ?`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &c, nil +} + +func (s *mysqlComputeAllocationChangeRequestStore) FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationChangeRequest, error) { + var rows []models.ComputeAllocationChangeRequest + err := s.db.SelectContext(ctx, &rows, + `SELECT `+computeAllocationChangeRequestColumns+` + FROM compute_allocation_change_requests + WHERE compute_allocation_id = ? + ORDER BY timestamp`, allocationID) + if err != nil { + return nil, err + } + return rows, nil +} + +func (s *mysqlComputeAllocationChangeRequestStore) FindByRequester(ctx context.Context, requesterID string) ([]models.ComputeAllocationChangeRequest, error) { + var rows []models.ComputeAllocationChangeRequest + err := s.db.SelectContext(ctx, &rows, + `SELECT `+computeAllocationChangeRequestColumns+` + FROM compute_allocation_change_requests + WHERE requester_id = ? + ORDER BY timestamp`, requesterID) + if err != nil { + return nil, err + } + return rows, nil +} + +func (s *mysqlComputeAllocationChangeRequestStore) Create(ctx context.Context, tx *sql.Tx, c *models.ComputeAllocationChangeRequest) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO compute_allocation_change_requests + (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.ID, c.ComputeAllocationID, c.RequestedSUAmount, string(c.RequestedStatus), c.Reason, c.ChangeStatus, c.RequesterID, c.ApproverID, c.Timestamp) + return err +} + +func (s *mysqlComputeAllocationChangeRequestStore) Update(ctx context.Context, tx *sql.Tx, c *models.ComputeAllocationChangeRequest) error { + _, err := tx.ExecContext(ctx, + `UPDATE compute_allocation_change_requests + SET compute_allocation_id = ?, + requested_su_amount = ?, + requested_status = ?, + reason = ?, + change_status = ?, + requester_id = ?, + approver_id = ?, + timestamp = ? + WHERE id = ?`, + c.ComputeAllocationID, c.RequestedSUAmount, string(c.RequestedStatus), c.Reason, c.ChangeStatus, c.RequesterID, c.ApproverID, c.Timestamp, c.ID) + return err +} + +func (s *mysqlComputeAllocationChangeRequestStore) Delete(ctx context.Context, tx *sql.Tx, id string) error { + _, err := tx.ExecContext(ctx, `DELETE FROM compute_allocation_change_requests WHERE id = ?`, id) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go index 6624d8f5a..54a367c49 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -170,3 +170,41 @@ type ComputeAllocationDiffStore interface { // Delete removes a diff by ID within the provided transaction. Delete(ctx context.Context, tx *sql.Tx, id string) error } + +// ComputeAllocationChangeRequestStore defines persistence operations for +// user/admin requests to change a compute allocation (e.g. asking for more +// SUs or to change its status). +type ComputeAllocationChangeRequestStore interface { + // FindByID returns the change request with the given ID, or nil if it does not exist. + FindByID(ctx context.Context, id string) (*models.ComputeAllocationChangeRequest, error) + // FindByAllocation returns every change request ever recorded against the + // given allocation, ordered by timestamp ascending. + FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationChangeRequest, error) + // FindByRequester returns every change request made by the given user, + // ordered by timestamp ascending. + FindByRequester(ctx context.Context, requesterID string) ([]models.ComputeAllocationChangeRequest, error) + // Create inserts a new change request within the provided transaction. + Create(ctx context.Context, tx *sql.Tx, c *models.ComputeAllocationChangeRequest) error + // Update replaces mutable fields of an existing change request within the provided transaction. + Update(ctx context.Context, tx *sql.Tx, c *models.ComputeAllocationChangeRequest) error + // Delete removes a change request by ID within the provided transaction. + Delete(ctx context.Context, tx *sql.Tx, id string) error +} + +// ComputeAllocationChangeRequestEventStore defines persistence operations +// for the append-only audit trail of state transitions applied to a +// ComputeAllocationChangeRequest. +type ComputeAllocationChangeRequestEventStore interface { + // FindByID returns the event with the given ID, or nil if it does not exist. + FindByID(ctx context.Context, id string) (*models.ComputeAllocationChangeRequestEvent, error) + // FindByChangeRequest returns every event recorded against the given + // change request, ordered by timestamp ascending. + FindByChangeRequest(ctx context.Context, changeRequestID string) ([]models.ComputeAllocationChangeRequestEvent, error) + // FindLatestByChangeRequest returns the most recent event for the given + // change request, or nil if none exist. + FindLatestByChangeRequest(ctx context.Context, changeRequestID string) (*models.ComputeAllocationChangeRequestEvent, error) + // Create inserts a new event within the provided transaction. + Create(ctx context.Context, tx *sql.Tx, e *models.ComputeAllocationChangeRequestEvent) error + // Delete removes an event 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 b3e284927..a6003f987 100644 --- a/pkg/models/allocation.go +++ b/pkg/models/allocation.go @@ -58,23 +58,23 @@ type ComputeAllocationDiff struct { // Diff will occur either through a change r } type ComputeAllocationChangeRequest struct { // Represents a request to change the allocation, e.g., requesting more SUs, requesting a reduction in SUs, etc from users or admins. - ID string `json:"id"` - ComputeAllocationID string `json:"compute_allocation_id"` - RequestedSUAmount int64 `json:"requested_su_amount"` // The requested allocation amount in SUs, e.g., 1200 SUs, etc. - RequestedStatus AllocationStatus `json:"requested_status"` // ACTIVE, INACTIVE, DELETED, etc. - Reason string `json:"reason"` // The reason for the change request, e.g., "Need more SUs for upcoming jobs", "Requesting reduction in SUs due to project completion", etc. - ChangeStatus string `json:"change_status"` // "PENDING", "APPROVED", "REJECTED", etc. - RequesterID string `json:"requester_id"` // The ID of the user who made the change request. - ApproverID string `json:"approver_id,omitempty"` // The ID of the user who approved/rejected the change request, if applicable. - Timestamp time.Time `json:"timestamp"` // The time when the change request was made. + ID string `json:"id" db:"id"` + ComputeAllocationID string `json:"compute_allocation_id" db:"compute_allocation_id"` + RequestedSUAmount int64 `json:"requested_su_amount" db:"requested_su_amount"` // The requested allocation amount in SUs, e.g., 1200 SUs, etc. + RequestedStatus AllocationStatus `json:"requested_status" db:"requested_status"` // ACTIVE, INACTIVE, DELETED, etc. + Reason string `json:"reason" db:"reason"` // The reason for the change request, e.g., "Need more SUs for upcoming jobs", "Requesting reduction in SUs due to project completion", etc. + ChangeStatus string `json:"change_status" db:"change_status"` // "PENDING", "APPROVED", "REJECTED", etc. + RequesterID string `json:"requester_id" db:"requester_id"` // The ID of the user who made the change request. + ApproverID string `json:"approver_id,omitempty" db:"approver_id"` // The ID of the user who approved/rejected the change request, if applicable. + Timestamp time.Time `json:"timestamp" db:"timestamp"` // The time when the change request was made. } type ComputeAllocationChangeRequestEvent struct { - ID string `json:"id"` - ComputeAllocationChangeRequestID string `json:"compute_allocation_change_request_id"` - EventType string `json:"event_type"` // "CREATED", "APPROVED", "REJECTED", etc. - Description string `json:"description,omitempty"` // Optional description of the event, e.g., "Change request created by user", "Change request approved by admin", etc. - Timestamp time.Time `json:"timestamp"` // The time when the event occurred. + ID string `json:"id" db:"id"` + ComputeAllocationChangeRequestID string `json:"compute_allocation_change_request_id" db:"compute_allocation_change_request_id"` + EventType string `json:"event_type" db:"event_type"` // "CREATED", "APPROVED", "REJECTED", etc. + Description string `json:"description,omitempty" db:"description"` // Optional description of the event, e.g., "Change request created by user", "Change request approved by admin", etc. + Timestamp time.Time `json:"timestamp" db:"timestamp"` // The time when the event occurred. } type ComputeAllocationUsage struct { // Represents the usage of a compute allocation, e.g., when a job consumes some of the allocated SUs, etc. diff --git a/pkg/service/compute_allocation_change_request.go b/pkg/service/compute_allocation_change_request.go new file mode 100644 index 000000000..06bda3024 --- /dev/null +++ b/pkg/service/compute_allocation_change_request.go @@ -0,0 +1,219 @@ +// 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" +) + +// CreateComputeAllocationChangeRequest records a new change request against a +// compute allocation. The referenced allocation must exist and a requester +// must be supplied. ChangeStatus defaults to "PENDING" and Timestamp to the +// server's current UTC time. A "CREATED" event is appended to the change +// request's audit log in the same transaction. +func (s *Service) CreateComputeAllocationChangeRequest(ctx context.Context, req *models.ComputeAllocationChangeRequest) (*models.ComputeAllocationChangeRequest, error) { + if req == nil { + return nil, fmt.Errorf("%w: compute allocation change request is nil", ErrInvalidInput) + } + if req.ComputeAllocationID == "" { + return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput) + } + if req.RequesterID == "" { + return nil, fmt.Errorf("%w: requester_id is required", ErrInvalidInput) + } + + if alloc, err := s.allocs.FindByID(ctx, req.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, req.ComputeAllocationID) + } + + if req.ID == "" { + req.ID = newID() + } + if req.ChangeStatus == "" { + req.ChangeStatus = "PENDING" + } + if req.Timestamp.IsZero() { + req.Timestamp = nowUTC() + } + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + if err := s.changeRequests.Create(ctx, tx, req); err != nil { + return err + } + return s.changeEvents.Create(ctx, tx, &models.ComputeAllocationChangeRequestEvent{ + ID: newID(), + ComputeAllocationChangeRequestID: req.ID, + EventType: "CREATED", + Description: "Change request created", + Timestamp: req.Timestamp, + }) + }); err != nil { + return nil, fmt.Errorf("create compute allocation change request: %w", err) + } + return req, nil +} + +// GetComputeAllocationChangeRequest retrieves a change request by its ID. +// Returns ErrNotFound when no request matches. +func (s *Service) GetComputeAllocationChangeRequest(ctx context.Context, id string) (*models.ComputeAllocationChangeRequest, error) { + c, err := s.changeRequests.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get compute allocation change request: %w", err) + } + if c == nil { + return nil, ErrNotFound + } + return c, nil +} + +// ListChangeRequestsForAllocation returns every change request ever recorded +// against the given allocation, ordered chronologically by timestamp. +func (s *Service) ListChangeRequestsForAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationChangeRequest, error) { + if allocationID == "" { + return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput) + } + rows, err := s.changeRequests.FindByAllocation(ctx, allocationID) + if err != nil { + return nil, fmt.Errorf("list change requests for allocation: %w", err) + } + return rows, nil +} + +// ListChangeRequestsByRequester returns every change request submitted by the +// given user, ordered chronologically by timestamp. +func (s *Service) ListChangeRequestsByRequester(ctx context.Context, requesterID string) ([]models.ComputeAllocationChangeRequest, error) { + if requesterID == "" { + return nil, fmt.Errorf("%w: requester_id is required", ErrInvalidInput) + } + rows, err := s.changeRequests.FindByRequester(ctx, requesterID) + if err != nil { + return nil, fmt.Errorf("list change requests by requester: %w", err) + } + return rows, nil +} + +// UpdateComputeAllocationChangeRequest replaces mutable fields of an existing +// change request — typically used to transition change_status and record the +// approver. An audit event is appended in the same transaction: when +// change_status transitions to a new value the event_type is the new status +// (e.g. "APPROVED"), otherwise "UPDATED". +func (s *Service) UpdateComputeAllocationChangeRequest(ctx context.Context, req *models.ComputeAllocationChangeRequest) (*models.ComputeAllocationChangeRequest, error) { + if req == nil || req.ID == "" { + return nil, fmt.Errorf("%w: compute allocation change request id is required", ErrInvalidInput) + } + existing, err := s.changeRequests.FindByID(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("lookup compute allocation change request: %w", err) + } + if existing == nil { + return nil, ErrNotFound + } + if req.ComputeAllocationID == "" { + req.ComputeAllocationID = existing.ComputeAllocationID + } + if req.RequesterID == "" { + req.RequesterID = existing.RequesterID + } + if req.ChangeStatus == "" { + req.ChangeStatus = existing.ChangeStatus + } + if req.Timestamp.IsZero() { + req.Timestamp = existing.Timestamp + } + + eventType := "UPDATED" + eventDescription := "Change request updated" + if req.ChangeStatus != existing.ChangeStatus { + eventType = req.ChangeStatus + eventDescription = fmt.Sprintf("Change request transitioned from %s to %s", existing.ChangeStatus, req.ChangeStatus) + } + + // When a change request transitions into APPROVED, materialise the + // approved change as a ComputeAllocationDiff so the allocation's audit + // log reflects the agreed-upon new SU amount and status. + approved := req.ChangeStatus == "APPROVED" && existing.ChangeStatus != "APPROVED" + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + if err := s.changeRequests.Update(ctx, tx, req); err != nil { + return err + } + return s.changeEvents.Create(ctx, tx, &models.ComputeAllocationChangeRequestEvent{ + ID: newID(), + ComputeAllocationChangeRequestID: req.ID, + EventType: eventType, + Description: eventDescription, + Timestamp: nowUTC(), + }) + }); err != nil { + return nil, fmt.Errorf("update compute allocation change request: %w", err) + } + + if approved { + diffStatus := req.RequestedStatus + if diffStatus == "" { + diffStatus = models.ACTIVE + } + if _, err := s.CreateComputeAllocationDiff(ctx, &models.ComputeAllocationDiff{ + ComputeAllocationID: req.ComputeAllocationID, + DiffType: "CHANGE_REQUEST_APPROVED", + NewSUAmount: req.RequestedSUAmount, + Status: diffStatus, + Description: fmt.Sprintf("Applied approved change request %s", req.ID), + }); err != nil { + return nil, fmt.Errorf("record approved change request diff: %w", err) + } + } + return req, nil +} + +// DeleteComputeAllocationChangeRequest removes a change request by ID. A +// "DELETED" event is appended to the audit log first so the trail survives +// the parent record's removal. +func (s *Service) DeleteComputeAllocationChangeRequest(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("%w: compute allocation change request id is required", ErrInvalidInput) + } + existing, err := s.changeRequests.FindByID(ctx, id) + if err != nil { + return fmt.Errorf("lookup compute allocation change request: %w", err) + } + if existing == nil { + return ErrNotFound + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + if err := s.changeEvents.Create(ctx, tx, &models.ComputeAllocationChangeRequestEvent{ + ID: newID(), + ComputeAllocationChangeRequestID: id, + EventType: "DELETED", + Description: "Change request deleted", + Timestamp: nowUTC(), + }); err != nil { + return err + } + return s.changeRequests.Delete(ctx, tx, id) + }); err != nil { + return fmt.Errorf("delete compute allocation change request: %w", err) + } + return nil +} diff --git a/pkg/service/compute_allocation_change_request_event.go b/pkg/service/compute_allocation_change_request_event.go new file mode 100644 index 000000000..7dd277796 --- /dev/null +++ b/pkg/service/compute_allocation_change_request_event.go @@ -0,0 +1,119 @@ +// 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" +) + +// CreateComputeAllocationChangeRequestEvent records a new event against a +// change request. The referenced change request must exist. If Timestamp is +// zero the server's current UTC time is used. Event records are append-only +// and have no update operation. +func (s *Service) CreateComputeAllocationChangeRequestEvent(ctx context.Context, evt *models.ComputeAllocationChangeRequestEvent) (*models.ComputeAllocationChangeRequestEvent, error) { + if evt == nil { + return nil, fmt.Errorf("%w: compute allocation change request event is nil", ErrInvalidInput) + } + if evt.ComputeAllocationChangeRequestID == "" { + return nil, fmt.Errorf("%w: compute_allocation_change_request_id is required", ErrInvalidInput) + } + if evt.EventType == "" { + return nil, fmt.Errorf("%w: event_type is required", ErrInvalidInput) + } + + if cr, err := s.changeRequests.FindByID(ctx, evt.ComputeAllocationChangeRequestID); err != nil { + return nil, fmt.Errorf("lookup compute allocation change request: %w", err) + } else if cr == nil { + return nil, fmt.Errorf("%w: compute allocation change request %q not found", ErrInvalidInput, evt.ComputeAllocationChangeRequestID) + } + + if evt.ID == "" { + evt.ID = newID() + } + if evt.Timestamp.IsZero() { + evt.Timestamp = nowUTC() + } + + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.changeEvents.Create(ctx, tx, evt) + }); err != nil { + return nil, fmt.Errorf("create compute allocation change request event: %w", err) + } + return evt, nil +} + +// GetComputeAllocationChangeRequestEvent retrieves an event by its ID. +// Returns ErrNotFound when no event matches. +func (s *Service) GetComputeAllocationChangeRequestEvent(ctx context.Context, id string) (*models.ComputeAllocationChangeRequestEvent, error) { + e, err := s.changeEvents.FindByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get compute allocation change request event: %w", err) + } + if e == nil { + return nil, ErrNotFound + } + return e, nil +} + +// ListEventsForChangeRequest returns every event recorded against the given +// change request, ordered chronologically by timestamp. +func (s *Service) ListEventsForChangeRequest(ctx context.Context, changeRequestID string) ([]models.ComputeAllocationChangeRequestEvent, error) { + if changeRequestID == "" { + return nil, fmt.Errorf("%w: compute_allocation_change_request_id is required", ErrInvalidInput) + } + rows, err := s.changeEvents.FindByChangeRequest(ctx, changeRequestID) + if err != nil { + return nil, fmt.Errorf("list events for change request: %w", err) + } + return rows, nil +} + +// GetLatestEventForChangeRequest returns the most recent event for the given +// change request. Returns ErrNotFound when the change request has no events. +func (s *Service) GetLatestEventForChangeRequest(ctx context.Context, changeRequestID string) (*models.ComputeAllocationChangeRequestEvent, error) { + if changeRequestID == "" { + return nil, fmt.Errorf("%w: compute_allocation_change_request_id is required", ErrInvalidInput) + } + e, err := s.changeEvents.FindLatestByChangeRequest(ctx, changeRequestID) + if err != nil { + return nil, fmt.Errorf("get latest event for change request: %w", err) + } + if e == nil { + return nil, ErrNotFound + } + return e, nil +} + +// DeleteComputeAllocationChangeRequestEvent removes an event record by ID. +// This is intended for administrative cleanup of erroneous entries; the +// event log is otherwise append-only. +func (s *Service) DeleteComputeAllocationChangeRequestEvent(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("%w: compute allocation change request event id is required", ErrInvalidInput) + } + if err := s.inTx(ctx, func(tx *sql.Tx) error { + return s.changeEvents.Delete(ctx, tx, id) + }); err != nil { + return fmt.Errorf("delete compute allocation change request event: %w", err) + } + return nil +} diff --git a/pkg/service/service.go b/pkg/service/service.go index 90de9e238..1942451be 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -43,6 +43,8 @@ type Service struct { resourceMappings store.ComputeAllocationResourceMappingStore resourceRates store.ComputeAllocationResourceRateStore allocDiffs store.ComputeAllocationDiffStore + changeRequests store.ComputeAllocationChangeRequestStore + changeEvents store.ComputeAllocationChangeRequestEventStore } // New constructs a Service backed by the supplied database handle. @@ -59,6 +61,8 @@ func New(database *sqlx.DB) *Service { resourceMappings: store.NewComputeAllocationResourceMappingStore(database), resourceRates: store.NewComputeAllocationResourceRateStore(database), allocDiffs: store.NewComputeAllocationDiffStore(database), + changeRequests: store.NewComputeAllocationChangeRequestStore(database), + changeEvents: store.NewComputeAllocationChangeRequestEventStore(database), } } @@ -76,6 +80,8 @@ func NewWithStores( resourceMappings store.ComputeAllocationResourceMappingStore, resourceRates store.ComputeAllocationResourceRateStore, allocDiffs store.ComputeAllocationDiffStore, + changeRequests store.ComputeAllocationChangeRequestStore, + changeEvents store.ComputeAllocationChangeRequestEventStore, ) *Service { return &Service{ db: database, @@ -88,6 +94,8 @@ func NewWithStores( resourceMappings: resourceMappings, resourceRates: resourceRates, allocDiffs: allocDiffs, + changeRequests: changeRequests, + changeEvents: changeEvents, } }
