blob: 69674e582b0dfddaba120eb47f3981eb446ac884 [file] [view]
# 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 `HTTP_ADDR`)
- **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 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.
**Path parameters:** `{id}` — the compute allocation ID.
**Required body fields:** `compute_allocation_resource_id`
**Request**
```json
{ "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff" }
```
**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"
}
```
**Errors**
- `400` — `compute_allocation_resource_id` missing, or either the allocation or the resource does not exist.
- `409` — this resource is already attached to the allocation.
---
### `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.
---
## End-to-end example
```bash
BASE=http://localhost:8080
ORG_ID=$(curl -s -X POST $BASE/organizations \
-H 'Content-Type: application/json' \
-d '{"name":"University of Example","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":"Delta"}' | 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":"GPU B200","resource_type":"GPU","resource_amount":8}' | 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\"}" | 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
# 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
```bash
export DATABASE_DSN='custos:secret@tcp(127.0.0.1:3306)/custos?parseTime=true&charset=utf8mb4'
# optional
export HTTP_ADDR=:8080
export DB_MAX_OPEN_CONNS=25
export DB_MAX_IDLE_CONNS=5
go run ./cmd/server
```
| Environment variable | Default | Purpose |
|----------------------|---------|---------|
| `DATABASE_DSN` | *(required)* | MySQL/MariaDB DSN. `parseTime=true` is mandatory. |
| `HTTP_ADDR` | `:8080` | Address the HTTP server binds to. |
| `DB_MAX_OPEN_CONNS` | `25` | Maximum open database connections. |
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle database connections. |
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.