feat: initial open-source release of Pipely
OTA update server designed for high-concurrency, low-bandwidth deployments. GORM-backed PostgreSQL, JWT auth, device management, artefact versioning, deployment rollout with gated rate-limited downloads, and a React admin panel.
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: OTA Manifest — Daemon API
|
||||
description: |
|
||||
Device-side REST API for the OTA update platform.
|
||||
Each daemon (`mcd`) calls these endpoints to register, heartbeat,
|
||||
poll for updates, download artefacts, and report progress.
|
||||
|
||||
**10 Mbps bandwidth design points:**
|
||||
- Downloads are **rate-limited** (~25 KB/s per connection) and **gated** (max 50 concurrent).
|
||||
- The download endpoint supports **HTTP Range** (`206 Partial Content`) for resumable transfers.
|
||||
- `check-update` returns a delta patch when available to minimise bytes on the wire.
|
||||
version: "1.0.0"
|
||||
contact:
|
||||
name: Quanta OTA Team
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080/api/v1
|
||||
description: Local development server
|
||||
|
||||
tags:
|
||||
- name: Device
|
||||
description: Registration, heartbeat, and update checking
|
||||
- name: Package
|
||||
description: Artefact download
|
||||
- name: Deployment
|
||||
description: Lifecycle status reporting
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
paths:
|
||||
|
||||
# ═══ Device ═══
|
||||
|
||||
/devices/register:
|
||||
post:
|
||||
tags: [Device]
|
||||
summary: Register or refresh a device
|
||||
description: |
|
||||
Called by the daemon on startup. If the `deviceId` already exists
|
||||
this is an upsert — all fields are updated and `lastHeartbeat` is
|
||||
refreshed.
|
||||
operationId: registerDevice
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterDeviceRequest"
|
||||
example:
|
||||
deviceId: "mcd-7a3f-001"
|
||||
ipAddress: "192.168.1.42"
|
||||
hostname: "gateway-east-01"
|
||||
os: "Debian 12"
|
||||
currentVersion: "1.2.0"
|
||||
groupName: "east-region"
|
||||
responses:
|
||||
"200":
|
||||
description: Device registered or updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Device"
|
||||
example:
|
||||
id: 1
|
||||
deviceId: "mcd-7a3f-001"
|
||||
ipAddress: "192.168.1.42"
|
||||
hostname: "gateway-east-01"
|
||||
os: "Debian 12"
|
||||
currentVersion: "1.2.0"
|
||||
status: "online"
|
||||
groupName: "east-region"
|
||||
lastHeartbeat: "2026-07-03T10:30:00Z"
|
||||
createdAt: "2026-07-03T10:30:00Z"
|
||||
updatedAt: "2026-07-03T10:30:00Z"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/devices/heartbeat:
|
||||
post:
|
||||
tags: [Device]
|
||||
summary: Send periodic heartbeat
|
||||
description: |
|
||||
Called by the daemon every 30–60 s. Keeps the device marked `online`
|
||||
and updates the `currentVersion` so the server always knows which
|
||||
version each device is running.
|
||||
operationId: heartbeat
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HeartbeatRequest"
|
||||
example:
|
||||
deviceId: "mcd-7a3f-001"
|
||||
currentVersion: "1.2.0"
|
||||
status: "online"
|
||||
responses:
|
||||
"200":
|
||||
description: Heartbeat acknowledged
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/devices/check-update:
|
||||
get:
|
||||
tags: [Device]
|
||||
summary: Check whether an update is pending for this device
|
||||
description: |
|
||||
Polled by the daemon on a configurable interval (e.g. every 5 min).
|
||||
When `hasUpdate` is `true` the daemon should proceed to download the
|
||||
referenced artefact and then POST to `/deployments/{id}/status` as the
|
||||
install progresses.
|
||||
operationId: checkUpdate
|
||||
parameters:
|
||||
- name: deviceId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The device's unique identifier
|
||||
example: "mcd-7a3f-001"
|
||||
responses:
|
||||
"200":
|
||||
description: Update status for the device
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckUpdateResponse"
|
||||
examples:
|
||||
hasUpdate:
|
||||
summary: Update available
|
||||
value:
|
||||
hasUpdate: true
|
||||
artefactId: 5
|
||||
version: "1.3.0"
|
||||
fileSize: 4194304
|
||||
sha256Hash: "a1b2c3d4e5f6..."
|
||||
downloadUrl: "/api/v1/packages/download/5"
|
||||
noUpdate:
|
||||
summary: No update needed
|
||||
value:
|
||||
hasUpdate: false
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
# ═══ Package ═══
|
||||
|
||||
/packages/download/{id}:
|
||||
get:
|
||||
tags: [Package]
|
||||
summary: Download an artefact (full or delta patch)
|
||||
description: |
|
||||
Serves the artefact file with **per-connection bandwidth throttling**
|
||||
(~25 KB/s). Supports **HTTP Range** requests so interrupted downloads
|
||||
can be resumed via `Range: bytes=<offset>-`.
|
||||
|
||||
**Headers returned:**
|
||||
- `Content-Disposition: attachment; filename="<name>"`
|
||||
- `X-Checksum-SHA256: <hex>` — validate the file after download
|
||||
- `Accept-Ranges: bytes`
|
||||
- `Content-Length: <bytes>`
|
||||
|
||||
**Query parameters** are optional but recommended — they let the
|
||||
server track each device's download progress.
|
||||
operationId: downloadPackage
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Artefact ID (from `check-update` response)
|
||||
example: 5
|
||||
- name: deviceId
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Device identifier for progress tracking
|
||||
example: "mcd-7a3f-001"
|
||||
- name: deploymentId
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Deployment ID for progress tracking
|
||||
example: 3
|
||||
responses:
|
||||
"200":
|
||||
description: Full artefact file
|
||||
headers:
|
||||
Content-Disposition:
|
||||
schema:
|
||||
type: string
|
||||
description: Attachment filename
|
||||
X-Checksum-SHA256:
|
||||
schema:
|
||||
type: string
|
||||
description: SHA-256 hex digest of the file
|
||||
Accept-Ranges:
|
||||
schema:
|
||||
type: string
|
||||
example: bytes
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"206":
|
||||
description: Partial content (resumed download)
|
||||
headers:
|
||||
Content-Range:
|
||||
schema:
|
||||
type: string
|
||||
example: "bytes 1048576-4194303/4194304"
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"503":
|
||||
description: Server at download capacity — retry later
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
error: "Server at capacity. Please retry later."
|
||||
|
||||
# ═══ Deployment ═══
|
||||
|
||||
/deployments/{id}/status:
|
||||
post:
|
||||
tags: [Deployment]
|
||||
summary: Report deployment status for a single device
|
||||
description: |
|
||||
Called by the daemon at each lifecycle transition:
|
||||
|
||||
1. `downloading` — started the download
|
||||
2. `downloaded` — download complete (may be omitted for small files)
|
||||
3. `verifying` — checking SHA-256 hash
|
||||
4. `installing` — applying the update
|
||||
5. `completed` — update applied, device running new version
|
||||
6. `failed` — unrecoverable error (set `error` field)
|
||||
|
||||
The server tracks these transitions per-device so operators can
|
||||
monitor rollout progress.
|
||||
operationId: updateDeploymentStatus
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Deployment ID
|
||||
example: 3
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeploymentStatusRequest"
|
||||
examples:
|
||||
downloading:
|
||||
value:
|
||||
deviceId: "mcd-7a3f-001"
|
||||
status: "downloading"
|
||||
completed:
|
||||
value:
|
||||
deviceId: "mcd-7a3f-001"
|
||||
status: "completed"
|
||||
failed:
|
||||
value:
|
||||
deviceId: "mcd-7a3f-001"
|
||||
status: "failed"
|
||||
error: "SHA256 mismatch: expected a1b2.. got x9y0.."
|
||||
responses:
|
||||
"200":
|
||||
description: Status recorded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Component Schemas
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
# -- Requests --
|
||||
|
||||
RegisterDeviceRequest:
|
||||
type: object
|
||||
required: [deviceId]
|
||||
properties:
|
||||
deviceId:
|
||||
type: string
|
||||
maxLength: 256
|
||||
description: Unique device identifier (set by daemon config)
|
||||
ipAddress:
|
||||
type: string
|
||||
description: Device IP at registration time
|
||||
hostname:
|
||||
type: string
|
||||
description: OS hostname
|
||||
os:
|
||||
type: string
|
||||
description: Operating system name and version
|
||||
currentVersion:
|
||||
type: string
|
||||
description: Currently installed client version
|
||||
groupName:
|
||||
type: string
|
||||
description: Logical group for targeted rollouts (defaults to "default")
|
||||
|
||||
HeartbeatRequest:
|
||||
type: object
|
||||
required: [deviceId]
|
||||
properties:
|
||||
deviceId:
|
||||
type: string
|
||||
maxLength: 256
|
||||
currentVersion:
|
||||
type: string
|
||||
description: Currently running version
|
||||
status:
|
||||
type: string
|
||||
enum: [online, offline, upgrading]
|
||||
default: online
|
||||
|
||||
DeploymentStatusRequest:
|
||||
type: object
|
||||
required: [deviceId, status]
|
||||
properties:
|
||||
deviceId:
|
||||
type: string
|
||||
description: Device identifier
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- downloading
|
||||
- downloaded
|
||||
- verifying
|
||||
- installing
|
||||
- completed
|
||||
- failed
|
||||
error:
|
||||
type: string
|
||||
description: Error description (only meaningful when status = "failed")
|
||||
|
||||
# -- Responses --
|
||||
|
||||
Device:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
deviceId:
|
||||
type: string
|
||||
ipAddress:
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
os:
|
||||
type: string
|
||||
currentVersion:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [online, offline, upgrading]
|
||||
groupName:
|
||||
type: string
|
||||
lastHeartbeat:
|
||||
type: string
|
||||
format: date-time
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
CheckUpdateResponse:
|
||||
type: object
|
||||
properties:
|
||||
hasUpdate:
|
||||
type: boolean
|
||||
description: Whether an update is pending
|
||||
artefactId:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Artefact ID to pass to the download endpoint
|
||||
version:
|
||||
type: string
|
||||
description: Target version string
|
||||
fileSize:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total file size in bytes
|
||||
sha256Hash:
|
||||
type: string
|
||||
description: Expected SHA-256 hex digest (validate after download)
|
||||
downloadUrl:
|
||||
type: string
|
||||
description: Download path relative to the server
|
||||
|
||||
StatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: "acknowledged"
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human-readable error message
|
||||
|
||||
# -- Reusable responses --
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid request body or parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
error: "deviceId is required"
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
error: "Artefact not found"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Daemon update flow (non-normative)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
x-daemon-flow:
|
||||
polling:
|
||||
interval: "5m (configurable)"
|
||||
description: |
|
||||
1. **Startup** → `POST /devices/register`
|
||||
2. **Every N s** → `POST /devices/heartbeat`
|
||||
3. **Every 5 min** → `GET /devices/check-update?deviceId=xxx`
|
||||
4. **If hasUpdate==true** →
|
||||
a. `POST /deployments/{id}/status` → `downloading`
|
||||
b. `GET /packages/download/{artefactId}?deviceId=xxx&deploymentId={id}`
|
||||
- Use `Range: bytes=<offset>-` to resume interrupted transfers
|
||||
c. Verify SHA-256 hash against `sha256Hash` from step 3
|
||||
d. `POST /deployments/{id}/status` → `verifying`
|
||||
e. `POST /deployments/{id}/status` → `installing`
|
||||
f. Restart the Java process, confirm health
|
||||
g. `POST /deployments/{id}/status` → `completed`
|
||||
h. `POST /devices/heartbeat` with the new `currentVersion`
|
||||
- **On any failure** → `POST /deployments/{id}/status` → `failed`
|
||||
Reference in New Issue
Block a user