Add host registration, heartbeat, and multi-host management
Implements the full host ↔ control plane connection flow:
- Host CRUD endpoints (POST/GET/DELETE /v1/hosts) with role-based access:
regular hosts admin-only, BYOC hosts for admins and team owners
- One-time registration token flow: admin creates host → gets token (1hr TTL
in Redis + Postgres audit trail) → host agent registers with specs → gets
long-lived JWT (1yr)
- Host agent registration client with automatic spec detection (arch, CPU,
memory, disk) and token persistence to disk
- Periodic heartbeat (30s) via POST /v1/hosts/{id}/heartbeat with X-Host-Token
auth and host ID cross-check
- Token regeneration endpoint (POST /v1/hosts/{id}/token) for retry after
failed registration
- Tag management (add/remove/list) with team-scoped access control
- Host JWT with typ:"host" claim, cross-use prevention in both VerifyJWT and
VerifyHostJWT
- requireHostToken middleware for host agent authentication
- DB-level race protection: RegisterHost uses AND status='pending' with
rows-affected check; Redis GetDel for atomic token consume
- Migration for future mTLS support (cert_fingerprint, mtls_enabled columns)
- Host agent flags: --register (one-time token), --address (required ip:port)
- serviceErrToHTTP extended with "forbidden" → 403 mapping
- OpenAPI spec, .env.example, and README updated
This commit is contained in:
@ -728,6 +728,290 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts:
|
||||
post:
|
||||
summary: Create a host
|
||||
operationId: createHost
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: |
|
||||
Creates a new host record and returns a one-time registration token.
|
||||
Regular hosts can only be created by admins. BYOC hosts can be created
|
||||
by admins or team owners.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateHostRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Host created with registration token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateHostResponse"
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
get:
|
||||
summary: List hosts
|
||||
operationId: listHosts
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: |
|
||||
Admins see all hosts. Non-admins see only BYOC hosts belonging to their team.
|
||||
responses:
|
||||
"200":
|
||||
description: List of hosts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Host"
|
||||
|
||||
/v1/hosts/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
get:
|
||||
summary: Get host details
|
||||
operationId: getHost
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Host details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Host"
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
delete:
|
||||
summary: Delete a host
|
||||
operationId: deleteHost
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: |
|
||||
Admins can delete any host. Team owners can delete BYOC hosts
|
||||
belonging to their team.
|
||||
responses:
|
||||
"204":
|
||||
description: Host deleted
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/{id}/token:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: Regenerate registration token
|
||||
operationId: regenerateHostToken
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: |
|
||||
Issues a new registration token for a host still in "pending" status.
|
||||
Use this when a previous registration attempt failed after consuming
|
||||
the original token. Same permission model as host creation.
|
||||
responses:
|
||||
"201":
|
||||
description: New registration token issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateHostResponse"
|
||||
"403":
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Host is not in pending status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/register:
|
||||
post:
|
||||
summary: Register a host agent
|
||||
operationId: registerHost
|
||||
tags: [hosts]
|
||||
description: |
|
||||
Called by the host agent on first startup. Validates the one-time
|
||||
registration token, records machine specs, sets the host status to
|
||||
"online", and returns a long-lived JWT for subsequent API calls
|
||||
(heartbeats).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterHostRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Host registered, JWT returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterHostResponse"
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Invalid or expired registration token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/{id}/heartbeat:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: Host agent heartbeat
|
||||
operationId: hostHeartbeat
|
||||
tags: [hosts]
|
||||
security:
|
||||
- hostTokenAuth: []
|
||||
description: |
|
||||
Updates the host's last_heartbeat_at timestamp. The host ID in the URL
|
||||
must match the host ID in the JWT.
|
||||
responses:
|
||||
"204":
|
||||
description: Heartbeat recorded
|
||||
"401":
|
||||
description: Invalid or missing host token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: Host ID mismatch
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/{id}/tags:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
get:
|
||||
summary: List host tags
|
||||
operationId: listHostTags
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: List of tags
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: Add a tag to a host
|
||||
operationId: addHostTag
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AddTagRequest"
|
||||
responses:
|
||||
"204":
|
||||
description: Tag added
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/hosts/{id}/tags/{tag}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: tag
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
delete:
|
||||
summary: Remove a tag from a host
|
||||
operationId: removeHostTag
|
||||
tags: [hosts]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Tag removed
|
||||
"404":
|
||||
description: Host not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
@ -742,6 +1026,12 @@ components:
|
||||
bearerFormat: JWT
|
||||
description: JWT token from /v1/auth/login or /v1/auth/signup. Valid for 6 hours.
|
||||
|
||||
hostTokenAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Host-Token
|
||||
description: Long-lived host JWT returned from POST /v1/hosts/register. Valid for 1 year.
|
||||
|
||||
schemas:
|
||||
SignupRequest:
|
||||
type: object
|
||||
@ -937,6 +1227,117 @@ components:
|
||||
type: string
|
||||
description: Absolute file path inside the sandbox
|
||||
|
||||
CreateHostRequest:
|
||||
type: object
|
||||
required: [type]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [regular, byoc]
|
||||
description: Host type. Regular hosts are shared; BYOC hosts belong to a team.
|
||||
team_id:
|
||||
type: string
|
||||
description: Required for BYOC hosts.
|
||||
provider:
|
||||
type: string
|
||||
description: Cloud provider (e.g. aws, gcp, hetzner, bare-metal).
|
||||
availability_zone:
|
||||
type: string
|
||||
description: Availability zone (e.g. us-east, eu-west).
|
||||
|
||||
CreateHostResponse:
|
||||
type: object
|
||||
properties:
|
||||
host:
|
||||
$ref: "#/components/schemas/Host"
|
||||
registration_token:
|
||||
type: string
|
||||
description: One-time registration token for the host agent. Expires in 1 hour.
|
||||
|
||||
RegisterHostRequest:
|
||||
type: object
|
||||
required: [token, address]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: One-time registration token from POST /v1/hosts.
|
||||
arch:
|
||||
type: string
|
||||
description: CPU architecture (e.g. x86_64, aarch64).
|
||||
cpu_cores:
|
||||
type: integer
|
||||
memory_mb:
|
||||
type: integer
|
||||
disk_gb:
|
||||
type: integer
|
||||
address:
|
||||
type: string
|
||||
description: Host agent address (ip:port).
|
||||
|
||||
RegisterHostResponse:
|
||||
type: object
|
||||
properties:
|
||||
host:
|
||||
$ref: "#/components/schemas/Host"
|
||||
token:
|
||||
type: string
|
||||
description: Long-lived host JWT for X-Host-Token header. Valid for 1 year.
|
||||
|
||||
Host:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [regular, byoc]
|
||||
team_id:
|
||||
type: string
|
||||
nullable: true
|
||||
provider:
|
||||
type: string
|
||||
nullable: true
|
||||
availability_zone:
|
||||
type: string
|
||||
nullable: true
|
||||
arch:
|
||||
type: string
|
||||
nullable: true
|
||||
cpu_cores:
|
||||
type: integer
|
||||
nullable: true
|
||||
memory_mb:
|
||||
type: integer
|
||||
nullable: true
|
||||
disk_gb:
|
||||
type: integer
|
||||
nullable: true
|
||||
address:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, online, offline, draining]
|
||||
last_heartbeat_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_by:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
AddTagRequest:
|
||||
type: object
|
||||
required: [tag]
|
||||
properties:
|
||||
tag:
|
||||
type: string
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in New Issue
Block a user