| <!-- |
| 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. |
| --> |
| |
| # Custos API Documentation |
| |
| HTTP/JSON API exposed by `cmd/server`. All endpoints accept and return |
| `application/json` and use UTF-8. |
| |
| - **Base URL:** `http://<host>:<port>` (default port `8080`, configurable via `core.api.port` in `config/custos.yaml`) |
| - **Auth:** none currently enforced (deploy behind a trusted ingress / auth proxy) |
| - **Content-Type:** `application/json` is required on every request that has a body |
| - **Unknown fields:** request bodies with unknown JSON fields are rejected with `400` |
| |
| --- |
| |
| ## Conventions |
| |
| ### Identifiers |
| |
| - `id` fields are server-generated UUIDs when omitted from a create request. |
| - `originated_id` is an optional external identifier (e.g. ACCESS Record ID) — when supplied, it must be unique within its entity type. |
| |
| ### Timestamps |
| |
| All timestamps are RFC 3339 / ISO 8601 with timezone, e.g. `2026-05-16T12:34:56.789Z`. The server emits UTC. |
| |
| ### Error format |
| |
| Errors are returned with an appropriate HTTP status code and a JSON body: |
| |
| ```json |
| { "error": "human-readable message" } |
| ``` |
| |
| | Status | Meaning | Triggered by | |
| |--------|---------|--------------| |
| | `400 Bad Request` | Malformed JSON, unknown field, missing required field, or unknown foreign-key reference | request body validation, `service.ErrInvalidInput` | |
| | `404 Not Found` | Requested record does not exist | `service.ErrNotFound` | |
| | `409 Conflict` | Duplicate `email` or duplicate `originated_id` | `service.ErrAlreadyExists` | |
| | `500 Internal Server Error` | Unexpected server / database failure (driver message is logged, never returned) | any other error | |
| |
| --- |
| |
| ## Health |
| |
| ### `GET /healthz` |
| |
| Liveness probe. Always returns `200` when the process is accepting connections. |
| |
| **Response 200** |
| |
| ```json |
| { "status": "ok" } |
| ``` |
| |
| --- |
| |
| ## Organizations |
| |
| ### `POST /organizations` |
| |
| Create a new organization. |
| |
| **Required fields:** `name` |
| **Optional fields:** `id` (auto-generated if omitted), `originated_id` |
| |
| **Request** |
| |
| ```json |
| { |
| "name": "University of Example", |
| "originated_id": "ACCESS-ORG-001" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "originated_id": "ACCESS-ORG-001", |
| "name": "University of Example" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `name` is required. |
| - `409` — an organization with the supplied `originated_id` already exists. |
| |
| #### Example |
| |
| ```bash |
| curl -s -X POST http://localhost:8080/organizations \ |
| -H 'Content-Type: application/json' \ |
| -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}' |
| ``` |
| |
| --- |
| |
| ### `GET /organizations/{id}` |
| |
| Retrieve an organization by its ID. |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "originated_id": "ACCESS-ORG-001", |
| "name": "University of Example" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `404` — no organization matches the supplied ID. |
| |
| --- |
| |
| ## Users |
| |
| ### `POST /users` |
| |
| Create a new user. |
| |
| **Required fields:** `organization_id`, `email` |
| **Optional fields:** `id`, `first_name`, `last_name`, `middle_name` |
| |
| The referenced `organization_id` must already exist; emails must be unique. |
| |
| **Request** |
| |
| ```json |
| { |
| "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "first_name": "Ada", |
| "last_name": "Lovelace", |
| "email": "ada@example.edu" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", |
| "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "first_name": "Ada", |
| "last_name": "Lovelace", |
| "email": "ada@example.edu" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `email`, `organization_id` missing, or `organization_id` does not exist. |
| - `409` — a user with this `email` already exists. |
| |
| #### Example |
| |
| ```bash |
| curl -s -X POST http://localhost:8080/users \ |
| -H 'Content-Type: application/json' \ |
| -d '{ |
| "organization_id":"8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "first_name":"Ada", |
| "last_name":"Lovelace", |
| "email":"ada@example.edu" |
| }' |
| ``` |
| |
| --- |
| |
| ### `GET /users/{id}` |
| |
| Retrieve a user by its ID. |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", |
| "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21", |
| "first_name": "Ada", |
| "last_name": "Lovelace", |
| "email": "ada@example.edu" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `404` — no user matches the supplied ID. |
| |
| --- |
| |
| ## Projects |
| |
| ### `POST /projects` |
| |
| Create a new project. |
| |
| **Required fields:** `title`, `project_pi_id` |
| **Optional fields:** `id`, `origination`, `originated_id`, `created_time` (defaults to current UTC time) |
| |
| The referenced `project_pi_id` must be an existing user. `originated_id`, when supplied, must be unique across projects. |
| |
| **Request** |
| |
| ```json |
| { |
| "title": "Climate Simulation 2026", |
| "origination": "ACCESS", |
| "originated_id": "ACCESS-PRJ-9000", |
| "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", |
| "originated_id": "ACCESS-PRJ-9000", |
| "title": "Climate Simulation 2026", |
| "origination": "ACCESS", |
| "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", |
| "created_time": "2026-05-16T17:21:04.512Z" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `title`, `project_pi_id` missing, or the PI user does not exist. |
| - `409` — a project with this `originated_id` already exists. |
| |
| #### Example |
| |
| ```bash |
| curl -s -X POST http://localhost:8080/projects \ |
| -H 'Content-Type: application/json' \ |
| -d '{ |
| "title":"Climate Simulation 2026", |
| "origination":"ACCESS", |
| "originated_id":"ACCESS-PRJ-9000", |
| "project_pi_id":"f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02" |
| }' |
| ``` |
| |
| --- |
| |
| ### `GET /projects/{id}` |
| |
| Retrieve a project by its ID. |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", |
| "originated_id": "ACCESS-PRJ-9000", |
| "title": "Climate Simulation 2026", |
| "origination": "ACCESS", |
| "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02", |
| "created_time": "2026-05-16T17:21:04.512Z" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `404` — no project matches the supplied ID. |
| |
| --- |
| |
| ## Compute Clusters |
| |
| A compute cluster represents a physical or logical HPC resource (e.g. a |
| Slurm cluster) where allocations can be provisioned. |
| |
| ### `POST /compute-clusters` |
| |
| Create a new compute cluster. |
| |
| **Required fields:** `name` |
| **Optional fields:** `id` (auto-generated if omitted) |
| |
| `name` must be unique across compute clusters. |
| |
| **Request** |
| |
| ```json |
| { "name": "Delta" } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "name": "Delta" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `name` is required. |
| - `409` — a compute cluster with this `name` already exists. |
| |
| --- |
| |
| ### `GET /compute-clusters` |
| |
| List all compute clusters. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { "id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", "name": "Delta" }, |
| { "id": "1d4e6a3b-7c8f-49b2-bd34-7c1f9a4e5d10", "name": "Phoenix" } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-clusters/{id}` |
| |
| Retrieve a single compute cluster by its ID. |
| |
| **Errors** |
| |
| - `404` — no compute cluster matches the supplied ID. |
| |
| --- |
| |
| ## Compute Cluster Users |
| |
| A compute-cluster user maps a Custos user to their local account |
| (`local_username`) on a specific compute cluster. Each `(compute_cluster_id, |
| user_id)` pair is unique. The mapping is removed automatically when either |
| the referenced compute cluster or user is deleted. |
| |
| ### `POST /compute-cluster-users` |
| |
| Create a new compute-cluster user mapping. |
| |
| **Required fields:** `compute_cluster_id`, `user_id`, `local_username` |
| **Optional fields:** `id` (auto-generated if omitted) |
| |
| Both `compute_cluster_id` and `user_id` must reference existing records. |
| |
| **Request** |
| |
| ```json |
| { |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jdoe" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "0d72d3b1-6f1a-4a92-9c4e-1d7a4b5f9c2d", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jdoe" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `compute_cluster_id`, `user_id`, or `local_username` is missing, |
| or the referenced cluster/user does not exist. |
| - `409` — this user is already mapped on the given compute cluster. |
| |
| --- |
| |
| ### `GET /compute-cluster-users/{id}` |
| |
| Retrieve a single compute-cluster user mapping by its ID. |
| |
| **Errors** |
| |
| - `404` — no mapping matches the supplied ID. |
| |
| --- |
| |
| ### `PUT /compute-cluster-users/{id}` |
| |
| Replace mutable fields of an existing compute-cluster user mapping. The |
| path `{id}` overrides any `id` in the request body. |
| |
| **Required fields:** `compute_cluster_id`, `user_id`, `local_username` |
| |
| **Request** |
| |
| ```json |
| { |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jane.doe" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — required fields missing. |
| |
| --- |
| |
| ### `DELETE /compute-cluster-users/{id}` |
| |
| Remove a compute-cluster user mapping. |
| |
| **Response 204** — empty body. |
| |
| **Errors** |
| |
| - `404` — no mapping matches the supplied ID. |
| |
| --- |
| |
| ### `GET /compute-clusters/{id}/users` |
| |
| List every user mapping for the given compute cluster, ordered by |
| `local_username`. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "0d72d3b1-6f1a-4a92-9c4e-1d7a4b5f9c2d", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jdoe" |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-clusters/{id}/users/{userId}` |
| |
| Look up the single compute-cluster user mapping for the given |
| `(compute_cluster_id, user_id)` pair. |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "0d72d3b1-6f1a-4a92-9c4e-1d7a4b5f9c2d", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jdoe" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `compute_cluster_id` or `user_id` is missing. |
| - `404` — no mapping exists for the given pair. |
| |
| --- |
| |
| ### `GET /users/{id}/compute-cluster-users` |
| |
| List every cluster mapping held by the given Custos user, ordered by |
| `compute_cluster_id`. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "0d72d3b1-6f1a-4a92-9c4e-1d7a4b5f9c2d", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "user_id": "5e2c7b3a-1d8f-4d2c-bf09-83a7c4d6e210", |
| "local_username": "jdoe" |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ## Compute Allocations |
| |
| A compute allocation grants a project a budget of Service Units (SUs) on a |
| specific compute cluster for a bounded time window. |
| |
| ### `POST /compute-allocations` |
| |
| Create a new compute allocation. |
| |
| **Required fields:** `project_id`, `name`, `compute_cluster_id` |
| **Optional fields:** `id`, `status` (defaults to `ACTIVE`), `initial_su_amount`, `start_time`, `end_time` |
| |
| Both `project_id` and `compute_cluster_id` must reference existing records. |
| `status` is one of `ACTIVE`, `INACTIVE`, `DELETED`. |
| |
| **Request** |
| |
| ```json |
| { |
| "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", |
| "name": "Q2 2026 Climate Run", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "initial_su_amount": 100000, |
| "start_time": "2026-04-01T00:00:00Z", |
| "end_time": "2026-06-30T23:59:59Z" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", |
| "name": "Q2 2026 Climate Run", |
| "status": "ACTIVE", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "initial_su_amount": 100000, |
| "start_time": "2026-04-01T00:00:00Z", |
| "end_time": "2026-06-30T23:59:59Z" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — required field missing, or `project_id` / `compute_cluster_id` does not exist. |
| |
| --- |
| |
| ### `GET /compute-allocations/{id}` |
| |
| Retrieve a compute allocation by its ID. |
| |
| **Errors** |
| |
| - `404` — no compute allocation matches the supplied ID. |
| |
| --- |
| |
| ## Compute Allocation Resources |
| |
| A compute allocation resource describes a hardware capability (e.g. |
| `GPU B200`, `CPU`) that can be attached to one or more allocations. |
| |
| ### `POST /compute-allocation-resources` |
| |
| Create a new compute allocation resource. |
| |
| **Required fields:** `name`, `resource_type` |
| **Optional fields:** `id`, `resource_amount` |
| |
| **Request** |
| |
| ```json |
| { |
| "name": "GPU B200", |
| "resource_type": "GPU", |
| "resource_amount": 8 |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "name": "GPU B200", |
| "resource_type": "GPU", |
| "resource_amount": 8 |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `name` or `resource_type` is missing. |
| |
| --- |
| |
| ### `GET /compute-allocation-resources` |
| |
| List all compute allocation resources. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "name": "GPU B200", |
| "resource_type": "GPU", |
| "resource_amount": 8 |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-allocation-resources/{id}` |
| |
| Retrieve a compute allocation resource by its ID. |
| |
| **Errors** |
| |
| - `404` — no resource matches the supplied ID. |
| |
| --- |
| |
| ## Compute Allocation ↔ Resource Mappings |
| |
| A many-to-many join: an allocation can have many resources attached, and a |
| resource can be attached to many allocations. Mappings are unique per |
| (allocation, resource) pair, and are cascade-deleted when either parent is |
| removed. |
| |
| ### `POST /compute-allocations/{id}/resources` |
| |
| Attach an existing resource to a compute allocation, recording the amount of |
| the resource and the wall-clock time granted to the allocation. |
| |
| **Path parameters:** `{id}` — the compute allocation ID. |
| **Required body fields:** `compute_allocation_resource_id` |
| **Optional body fields:** `resource_amount` (int64, default `0`), `resource_time` (int64, default `0`). Both must be non-negative. |
| |
| **Request** |
| |
| ```json |
| { |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "resource_amount": 24, |
| "resource_time": 1440 |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "7e1d2c3b-4a5f-4b6c-9d8e-0011223344ff", |
| "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "resource_amount": 24, |
| "resource_time": 1440 |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `compute_allocation_resource_id` missing, `resource_amount` / `resource_time` negative, or either the allocation or the resource does not exist. |
| - `409` — this resource is already attached to the allocation. |
| |
| --- |
| |
| ### `PUT /compute-allocations/{id}/resources/{resourceId}` |
| |
| Update the `resource_amount` and `resource_time` recorded on an existing |
| (allocation, resource) mapping. |
| |
| **Path parameters:** `{id}` — the compute allocation ID; `{resourceId}` — the compute allocation resource ID. |
| **Required body fields:** `resource_amount`, `resource_time` (both non-negative int64). |
| |
| **Request** |
| |
| ```json |
| { |
| "resource_amount": 48, |
| "resource_time": 2880 |
| } |
| ``` |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "7e1d2c3b-4a5f-4b6c-9d8e-0011223344ff", |
| "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "resource_amount": 48, |
| "resource_time": 2880 |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — either id missing, or `resource_amount` / `resource_time` negative. |
| - `404` — no such mapping exists. |
| |
| --- |
| |
| ### `DELETE /compute-allocations/{id}/resources/{resourceId}` |
| |
| Detach a resource from a compute allocation. |
| |
| **Response 204** — empty body on success. |
| |
| **Errors** |
| |
| - `404` — no such mapping exists. |
| |
| --- |
| |
| ### `GET /compute-allocations/{id}/resources` |
| |
| List every compute allocation resource currently attached to the given |
| compute allocation. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "name": "GPU B200", |
| "resource_type": "GPU", |
| "resource_amount": 8 |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-allocation-resources/{id}/allocations` |
| |
| List every compute allocation that has the given resource attached. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1", |
| "name": "Q2 2026 Climate Run", |
| "status": "ACTIVE", |
| "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", |
| "initial_su_amount": 100000, |
| "start_time": "2026-04-01T00:00:00Z", |
| "end_time": "2026-06-30T23:59:59Z" |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ## Compute Allocation Resource Rates |
| |
| A rate captures how many Service Units (SUs) are charged per unit of a |
| compute allocation resource over a bounded time window. Multiple rates can |
| exist for the same resource; usage at any instant is charged using the rate |
| whose `[start_time, end_time)` window contains that instant. |
| |
| Rates are cascade-deleted when their parent resource is deleted. |
| |
| ### `POST /compute-allocation-resource-rates` |
| |
| Create a new rate for a compute allocation resource. |
| |
| **Required fields:** `compute_allocation_resource_id`, `rate`, `start_time`, `end_time` |
| **Optional fields:** `id` |
| |
| Validation: |
| |
| - `compute_allocation_resource_id` must reference an existing resource. |
| - `rate` must be ≥ 0. |
| - `start_time` must be strictly before `end_time`. |
| |
| **Request** |
| |
| ```json |
| { |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "rate": 2.0, |
| "start_time": "2026-01-01T00:00:00Z", |
| "end_time": "2026-12-31T23:59:59Z" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "55aa66bb-77cc-88dd-99ee-001122334455", |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "rate": 2.0, |
| "start_time": "2026-01-01T00:00:00Z", |
| "end_time": "2026-12-31T23:59:59Z" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — required field missing, invalid time window, negative `rate`, or unknown `compute_allocation_resource_id`. |
| |
| --- |
| |
| ### `GET /compute-allocation-resource-rates/{id}` |
| |
| Retrieve a rate by its ID. |
| |
| **Errors** |
| |
| - `404` — no rate matches the supplied ID. |
| |
| --- |
| |
| ### `GET /compute-allocation-resources/{id}/rates` |
| |
| List every rate ever defined for the given compute allocation resource, |
| ordered by `start_time` ascending. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "55aa66bb-77cc-88dd-99ee-001122334455", |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "rate": 2.0, |
| "start_time": "2026-01-01T00:00:00Z", |
| "end_time": "2026-12-31T23:59:59Z" |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-allocation-resources/{id}/rates/effective` |
| |
| Return the rate currently in effect for the given resource. By default the |
| server uses the current time; supply `?at=<RFC 3339 timestamp>` to query an |
| arbitrary instant. |
| |
| A rate is "effective" at instant *t* when `start_time <= t < end_time`. If |
| multiple rates overlap *t*, the one with the most recent `start_time` wins. |
| |
| **Examples** |
| |
| ```http |
| GET /compute-allocation-resources/c0a1.../rates/effective |
| GET /compute-allocation-resources/c0a1.../rates/effective?at=2026-05-16T12:00:00Z |
| ``` |
| |
| **Response 200** |
| |
| ```json |
| { |
| "id": "55aa66bb-77cc-88dd-99ee-001122334455", |
| "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff", |
| "rate": 2.0, |
| "start_time": "2026-01-01T00:00:00Z", |
| "end_time": "2026-12-31T23:59:59Z" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — `at` query parameter is not a valid RFC 3339 timestamp. |
| - `404` — no rate is effective for the resource at the supplied instant. |
| |
| --- |
| |
| ## Compute Allocation Diffs |
| |
| A diff is an append-only audit record of a change applied to a compute |
| allocation — for example a usage update or a status transition. Diffs are |
| cascade-deleted when their parent allocation is deleted. |
| |
| ### `POST /compute-allocation-diffs` |
| |
| Record a new diff against a compute allocation. |
| |
| **Required fields:** `compute_allocation_id`, `diff_type`, `status` |
| **Optional fields:** `id`, `new_su_amount` (defaults to `0`), `timestamp` (defaults to the server's current UTC time), `description` |
| |
| `diff_type` is a free-form short code such as `USAGE_UPDATE` or |
| `ALLOCATION_STATUS_CHANGE`. `status` must be one of `ACTIVE`, `INACTIVE`, |
| `DELETED`. |
| |
| **Request** |
| |
| ```json |
| { |
| "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "diff_type": "USAGE_UPDATE", |
| "new_su_amount": 90000, |
| "status": "ACTIVE", |
| "description": "Charged 10000 SUs for completed jobs" |
| } |
| ``` |
| |
| **Response 201** |
| |
| ```json |
| { |
| "id": "44bb55cc-66dd-77ee-88ff-aabbccddeeff", |
| "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "diff_type": "USAGE_UPDATE", |
| "new_su_amount": 90000, |
| "status": "ACTIVE", |
| "timestamp": "2026-05-16T17:42:11.918Z", |
| "description": "Charged 10000 SUs for completed jobs" |
| } |
| ``` |
| |
| **Errors** |
| |
| - `400` — required field missing, or `compute_allocation_id` does not exist. |
| |
| --- |
| |
| ### `GET /compute-allocation-diffs/{id}` |
| |
| Retrieve a single diff by its ID. |
| |
| **Errors** |
| |
| - `404` — no diff matches the supplied ID. |
| |
| --- |
| |
| ### `DELETE /compute-allocation-diffs/{id}` |
| |
| Remove a diff record. Intended for administrative cleanup; diffs are |
| otherwise append-only. |
| |
| **Response 204** — empty body on success. |
| |
| --- |
| |
| ### `GET /compute-allocations/{id}/diffs` |
| |
| List every diff ever recorded against the given compute allocation, ordered |
| by `timestamp` ascending. |
| |
| **Response 200** |
| |
| ```json |
| [ |
| { |
| "id": "44bb55cc-66dd-77ee-88ff-aabbccddeeff", |
| "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56", |
| "diff_type": "USAGE_UPDATE", |
| "new_su_amount": 90000, |
| "status": "ACTIVE", |
| "timestamp": "2026-05-16T17:42:11.918Z", |
| "description": "Charged 10000 SUs for completed jobs" |
| } |
| ] |
| ``` |
| |
| --- |
| |
| ### `GET /compute-allocations/{id}/diffs/latest` |
| |
| Return the most recent diff (highest `timestamp`) for the given allocation. |
| |
| **Errors** |
| |
| - `404` — the allocation has no diffs recorded. |
| |
| --- |
| |
| ## 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. |
| |
| --- |
| |
| ## 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", |
| "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. |
| - Per-resource SU caps for this membership are stored separately as |
| `ComputeAllocationMembershipResourceOverride` rows; see below. |
| |
| **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}/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. |
| |
| ### GET `/compute-allocation-memberships/{id}/resource-overrides` |
| |
| List every per-resource override recorded against the given membership. |
| |
| --- |
| |
| ## Compute Allocation Membership Resource Overrides |
| |
| A `ComputeAllocationMembershipResourceOverride` records the resource amount |
| and wall-clock time of a specific resource (`ComputeAllocationResource`) |
| that has been granted to a specific membership, overriding whatever the |
| parent `ComputeAllocationResourceMapping` would otherwise provide. There can |
| be at most one override per |
| `(compute_allocation_membership_id, compute_allocation_resource_id)` pair |
| (enforced by a unique key). Overrides are cascade-deleted when either the |
| parent membership or the parent resource is removed. |
| |
| ### POST `/compute-allocation-membership-resource-overrides` |
| |
| Create a new override. |
| |
| **Request body** |
| |
| ```json |
| { |
| "compute_allocation_membership_id": "membership-123", |
| "compute_allocation_resource_id": "resource-456", |
| "override_resource_amount": 24, |
| "override_resource_time": 1440 |
| } |
| ``` |
| |
| - `compute_allocation_membership_id` and `compute_allocation_resource_id` are required. |
| - `override_resource_amount` and `override_resource_time` must be non-negative (default `0`). |
| - `id` is generated server-side when omitted. |
| |
| **Errors** |
| |
| - `400` — missing required fields, referenced membership/resource not found, |
| or negative `override_resource_amount` / `override_resource_time`. |
| - `409` — an override already exists for this `(membership, resource)` pair. |
| |
| ### GET `/compute-allocation-membership-resource-overrides/{id}` |
| |
| Retrieve an override by ID. |
| |
| ### PUT `/compute-allocation-membership-resource-overrides/{id}` |
| |
| Replace mutable fields of an override. Fields left blank/zero in the request |
| body fall back to the stored value (partial updates). |
| |
| ### DELETE `/compute-allocation-membership-resource-overrides/{id}` |
| |
| Remove an override. |
| |
| ### GET `/compute-allocation-resources/{id}/membership-overrides` |
| |
| List every membership override referencing the given resource. |
| |
| --- |
| |
| ## End-to-end example |
| |
| ```bash |
| BASE=http://localhost:8080 |
| |
| ORG_ID=$(curl -s -X POST $BASE/organizations \ |
| -H 'Content-Type: application/json' \ |
| -d '{"name":"Georgia Institute of Technology","originated_id":"ACCESS-ORG-001"}' \ |
| | jq -r .id) |
| |
| USER_ID=$(curl -s -X POST $BASE/users \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"organization_id\":\"$ORG_ID\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"email\":\"ada@example.edu\"}" \ |
| | jq -r .id) |
| |
| PROJ_ID=$(curl -s -X POST $BASE/projects \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"title\":\"Climate Simulation 2026\",\"origination\":\"ACCESS\",\"originated_id\":\"ACCESS-PRJ-9000\",\"project_pi_id\":\"$USER_ID\"}" \ |
| | jq -r .id) |
| |
| CLUSTER_ID=$(curl -s -X POST $BASE/compute-clusters \ |
| -H 'Content-Type: application/json' \ |
| -d '{"name":"nexus-dev"}' | jq -r .id) |
| |
| ALLOC_ID=$(curl -s -X POST $BASE/compute-allocations \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"project_id\":\"$PROJ_ID\",\"name\":\"Q2 2026 Climate Run\",\"compute_cluster_id\":\"$CLUSTER_ID\",\"initial_su_amount\":100000}" \ |
| | jq -r .id) |
| |
| RES_ID=$(curl -s -X POST $BASE/compute-allocation-resources \ |
| -H 'Content-Type: application/json' \ |
| -d '{"name":"debug","resource_type":"GrpTRES","resource_amount":24}' | jq -r .id) |
| |
| # Attach the resource to the allocation. |
| curl -s -X POST $BASE/compute-allocations/$ALLOC_ID/resources \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"compute_allocation_resource_id\":\"$RES_ID\",\"resource_amount\":24,\"resource_time\":1440}" | jq |
| |
| # Define a rate for the resource. |
| curl -s -X POST $BASE/compute-allocation-resource-rates \ |
| -H 'Content-Type: application/json' \ |
| -d "{ |
| \"compute_allocation_resource_id\":\"$RES_ID\", |
| \"rate\":2.0, |
| \"start_time\":\"2026-01-01T00:00:00Z\", |
| \"end_time\":\"2026-12-31T23:59:59Z\" |
| }" | jq |
| |
| # Look up the currently-effective rate. |
| curl -s $BASE/compute-allocation-resources/$RES_ID/rates/effective | jq |
| |
| # Create a Custos user that will be granted access on the cluster. |
| CLUSTER_USER_ACCT_ID=$(curl -s -X POST $BASE/users \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"organization_id\":\"$ORG_ID\",\"first_name\":\"Dimuthu\",\"last_name\":\"Wannipurage\",\"email\":\"dimuthu@example.edu\"}" \ |
| | jq -r .id) |
| |
| # Map the Custos user to a local UNIX account on the cluster. |
| CLUSTER_USER_ID=$(curl -s -X POST $BASE/compute-cluster-users \ |
| -H 'Content-Type: application/json' \ |
| -d "{\"compute_cluster_id\":\"$CLUSTER_ID\",\"user_id\":\"$CLUSTER_USER_ACCT_ID\",\"local_username\":\"dimuthu\"}" \ |
| | jq -r .id) |
| |
| # List all user mappings on the cluster. |
| curl -s $BASE/compute-clusters/$CLUSTER_ID/users | jq |
| |
| # Grant the user a sub-allocation (membership) on the compute allocation. |
| MEMBERSHIP_ID=$(curl -s -X POST $BASE/compute-allocation-memberships \ |
| -H 'Content-Type: application/json' \ |
| -d "{ |
| \"compute_allocation_id\":\"$ALLOC_ID\", |
| \"user_id\":\"$CLUSTER_USER_ACCT_ID\", |
| \"start_time\":\"2026-04-01T00:00:00Z\", |
| \"end_time\":\"2026-06-30T23:59:59Z\", |
| \"membership_status\":\"ACTIVE\" |
| }" | jq -r .id) |
| |
| # List every member of the allocation. |
| curl -s $BASE/compute-allocations/$ALLOC_ID/memberships | jq |
| |
| # List every allocation this user is a member of. |
| curl -s $BASE/users/$CLUSTER_USER_ACCT_ID/compute-allocation-memberships | jq |
| |
| # Grant the membership a per-resource override on the debug resource. |
| OVERRIDE_ID=$(curl -s -X POST $BASE/compute-allocation-membership-resource-overrides \ |
| -H 'Content-Type: application/json' \ |
| -d "{ |
| \"compute_allocation_membership_id\":\"$MEMBERSHIP_ID\", |
| \"compute_allocation_resource_id\":\"$RES_ID\", |
| \"override_resource_amount\":1, |
| \"override_resource_time\":20 |
| }" | jq -r .id) |
| |
| # Bump the override amount and time. |
| curl -s -X PUT $BASE/compute-allocation-membership-resource-overrides/$OVERRIDE_ID \ |
| -H 'Content-Type: application/json' \ |
| -d '{"override_resource_amount":16,"override_resource_time":960}' | jq |
| |
| # List every resource override for the membership. |
| curl -s $BASE/compute-allocation-memberships/$MEMBERSHIP_ID/resource-overrides | jq |
| |
| |
| # Record a usage diff against the allocation. |
| curl -s -X POST $BASE/compute-allocation-diffs \ |
| -H 'Content-Type: application/json' \ |
| -d "{ |
| \"compute_allocation_id\":\"$ALLOC_ID\", |
| \"diff_type\":\"USAGE_UPDATE\", |
| \"new_su_amount\":90000, |
| \"status\":\"ACTIVE\", |
| \"description\":\"Charged 10000 SUs for completed jobs\" |
| }" | jq |
| |
| # Inspect the diff history. |
| curl -s $BASE/compute-allocations/$ALLOC_ID/diffs | jq |
| curl -s $BASE/compute-allocations/$ALLOC_ID/diffs/latest | jq |
| |
| # Bidirectional lookups. |
| curl -s $BASE/compute-allocations/$ALLOC_ID/resources | jq |
| curl -s $BASE/compute-allocation-resources/$RES_ID/allocations | jq |
| |
| curl -s $BASE/projects/$PROJ_ID | jq |
| ``` |
| |
| --- |
| |
| ## Running the server |
| |
| The server is configured by a YAML file (default `config/custos.yaml`, override |
| with `CONFIG_PATH`). See [CONFIG.md](../CONFIG.md) for the full schema. |
| |
| ```bash |
| go run ./cmd/server |
| ``` |
| |
| A minimal `config/custos.yaml`: |
| |
| ```yaml |
| core: |
| database: |
| url: "custos:secret@tcp(127.0.0.1:3306)/custos?parseTime=true&charset=utf8mb4" |
| api: |
| port: 8080 |
| log_level: "info" |
| |
| connectors: {} |
| ``` |
| |
| Secrets can stay out of the file via `${VAR}` substitution — e.g. |
| `url: "${CUSTOS_DB_DSN}"` reads the DSN from the environment at startup. |
| |
| | Setting | Source | Default | Purpose | |
| |---------|--------|---------|---------| |
| | `core.database.url` | YAML | *(required)* | MariaDB / MySQL DSN. `parseTime=true` is mandatory. | |
| | `core.api.port` | YAML | `8080` | HTTP API port. | |
| | `core.log_level` | YAML | `info` | One of `debug`, `info`, `warn`, `error`. | |
| | `DB_MAX_OPEN_CONNS` | env | `25` | Maximum open database connections. | |
| | `DB_MAX_IDLE_CONNS` | env | `5` | Maximum idle database connections. | |
| | `CONFIG_PATH` | env | `config/custos.yaml` | Override the config file location. | |
| |
| Migrations from `internal/db/migrations/` are applied automatically on startup. |
| |
| The server handles `SIGINT` / `SIGTERM` gracefully, draining in-flight requests |
| for up to 15 seconds before exiting. |