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:
2026-07-07 10:29:28 +08:00
commit e070a3fb5f
59 changed files with 6515 additions and 0 deletions
+475
View File
@@ -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 3060 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`