blob: 2a55a75768472c73cdaceb7d3cc34f5f42d752dd [file] [view]
<!--
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.