Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ a CI job fails when they disagree.

## [Unreleased]

### Added

- **Reserved devices.** `fleet device reserve <id> <label>` (and
`unreserve`, or `POST /api/v1/devices/{id}/reserve` · `/unreserve`, admin)
keeps a device for one kind of work: it is offered only to a job whose new
`reservation` field is that label **and** that pins the device by id
(`targets.device_id`, or a `targets.match` such as
`device_id == '<id>'`). Fan-out, pools, unpinned matches, the composer's
preview, device-executor claims and host-executor routing all pass it by,
and the host executor's own device selection applies the same rule from the
`reserved_devices` its claim now carries — so an `os ~ 'android'` job or the
explore night cannot open other apps on the growth pilot's signed-in
Instagram phone on ultra. A job pinning a reserved device without the label
is refused with the new error code `device_reserved`; validate warns when a
reserved device was left out. Reservations live in the brain's database, so a
phone can be reserved before it is cabled up. `fleet devices` gains a
`RESERVED` column, `fleet_devices` and `/api/v1/devices` a `reserved_for`
field, and `fleet run` a `--reservation` flag.
([#106](https://github.com/addisdev/fleet-runner/pull/106))

### Fixed

- **A host job pinned by `targets.match "device_id == '<serial>'"` matched
nothing once claimed.** The brain evaluated `device_id` when routing it, but
the executor described an Android target without one, so the job reached the
right Mac and then skipped with no targets. The executor's descriptor now
carries `device_id`. ([#106](https://github.com/addisdev/fleet-runner/pull/106))

### Changed

- **The reference lab's brain moved to ultra.** It answers on ultra's loopback
Expand Down
10 changes: 5 additions & 5 deletions collector/dash/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

247 changes: 243 additions & 4 deletions collector/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,147 @@
}
}
},
"/api/v1/devices/{id}/reserve": {
"post": {
"operationId": "reserveDevice",
"summary": "Keep a device for one kind of work: only jobs with this reservation label that pin the device may use it.",
"description": "A reserved device is skipped by fan-out, pools and every targets.match that does not pin its id, on the brain and in the host executor's own device selection. A job may use it only with `reservation` set to the label and the device named in targets.device_id or a targets.match such as device_id == '<id>'. The device need not have registered yet, so a phone can be reserved before it is cabled up; `registered` says whether it has. Reserving again with the same label updates the note; a different label is a conflict until the device is unreserved.",
"tags": [
"devices"
],
"x-fleet-scope": "admin",
"x-fleet-idempotent": true,
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The device id.",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"description": "The label, and an optional note for the people reading the shelf.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReserveInput"
}
}
}
},
"responses": {
"200": {
"description": "Reserved.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Reservation"
}
}
}
},
"400": {
"description": "The request was malformed or the spec is invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "A token is required (FLEET_AUTH=tokens).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "The token lacks the scope, or the job is someone else's.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "The state does not allow it (already finished, duplicate id).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/devices/{id}/unreserve": {
"post": {
"operationId": "unreserveDevice",
"summary": "Give a reserved device back to the shelf: every job that matches it may use it again.",
"description": "Not an error when the device was not reserved; `was` says what it was reserved for, or null.",
"tags": [
"devices"
],
"x-fleet-scope": "admin",
"x-fleet-idempotent": true,
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The device id.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Released.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Unreservation"
}
}
}
},
"401": {
"description": "A token is required (FLEET_AUTH=tokens).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "The token lacks the scope, or the job is someone else's.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/workloads": {
"get": {
"operationId": "listWorkloads",
Expand Down Expand Up @@ -620,7 +761,7 @@
}
},
"400": {
"description": "Malformed or refused before queueing: invalid_spec, no_matching_devices (no registered device matches the targets), no_build (no artifact for app `latest`), invalid_match, invalid_dependency, secret_in_spec, bad_request.",
"description": "Malformed or refused before queueing: invalid_spec, no_matching_devices (no registered device matches the targets; reserved devices are left out), no_build (no artifact for app `latest`), invalid_match, invalid_dependency, secret_in_spec, bad_request.",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -650,7 +791,7 @@
}
},
"409": {
"description": "The state does not allow it (already finished, duplicate id).",
"description": "duplicate_job (that job_id exists), or device_reserved (the targets pin a device reserved for other work; set `reservation` to its label if this is that work).",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -1742,6 +1883,7 @@
"get": {
"operationId": "nextJobForExecutor",
"summary": "A host executor long-polling for host work.",
"description": "The claimed spec carries `reserved_devices` (device id to reservation label) beside the granted lease: the executor picks devices itself and must not run on a reserved one unless the job holds that reservation and pins it.",
"tags": [
"protocol"
],
Expand Down Expand Up @@ -2381,6 +2523,13 @@
},
"description": "null: an agent that predates capabilities, offered everything."
},
"reserved_for": {
"type": [
"string",
"null"
],
"description": "The reservation this device is kept for, or null. A reserved device is offered only to a job whose reservation is this label and that pins the device by id (targets.device_id, or a targets.match such as device_id == '<id>'); fan-out, pools and other matches skip it."
},
"last_net": {
"type": [
"string",
Expand Down Expand Up @@ -2471,6 +2620,7 @@
"pools_reported",
"pools_override",
"capabilities",
"reserved_for",
"last_net",
"ttl_s",
"expired",
Expand Down Expand Up @@ -2780,6 +2930,7 @@
"duplicate_job",
"no_capable_agent",
"no_matching_devices",
"device_reserved",
"secret_in_spec",
"no_build",
"invalid_match",
Expand Down Expand Up @@ -4473,6 +4624,11 @@
},
"description": "Job ids this one waits for. The job is enqueued as 'waiting' and becomes 'queued' when the last of them finishes; if any of them fails or is cancelled, this one fails with last_error naming it. While waiting, a spec may reference a dependency's output: \"${jobs.<id>.artifact}\" resolves to the first sha256 in that job's final row, and \"${jobs.<id>.metrics.<key>}\" to one of its metrics. A whole-string reference keeps the value's type, so a metric stays a number. Referencing a job not listed here is refused at enqueue."
},
"reservation": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$",
"description": "The device reservation this job holds. A device reserved with `fleet device reserve <id> <label>` is offered only to a job whose reservation is that label and that names the device explicitly: targets.device_id, or a targets.match that pins its id (device_id == '<id>'). Fan-out, pools and every other match skip it, and a job that pins a reserved device without the label is refused with device_reserved. On an unreserved device the field changes nothing."
},
"preemptible": {
"type": "boolean",
"description": "Whether a higher-priority job may ask this one to step aside. When true, a beacon is answered with preempt:true once a strictly higher-priority queued job would go to the same claimant; the runner is expected to checkpoint, upload its progress, and post a final row with preempted:true. That requeues the job with params.resume_from set to the uploaded sha256 and hands the attempt back, so stepping aside never counts against max_attempts. Equal priority does not preempt."
Expand Down Expand Up @@ -4525,11 +4681,11 @@
},
"device_id": {
"type": "string",
"description": "pin this job to exactly one device-executor agent"
"description": "pin this job to exactly one device (a device-executor agent, or a device a host executor drives); the way to name a reserved device"
},
"match": {
"type": "string",
"description": "descriptor expression, e.g. \"ram_mb >= 4000 && os ~ 'android'\" — fields: model, soc, ram_mb, os, app_ver, device_id, pools; ops == != < <= > >= ~(regex) && || ! ( )"
"description": "descriptor expression, e.g. \"ram_mb >= 4000 && os ~ 'android'\" — fields: model, soc, ram_mb, os, app_ver, device_id, pools; a reserved device is matched only by a job carrying its reservation whose expression pins its id; ops == != < <= > >= ~(regex) && || ! ( )"
},
"exclusive": {
"type": "boolean",
Expand Down Expand Up @@ -4759,6 +4915,70 @@
"links"
]
},
"Reservation": {
"type": "object",
"required": [
"device_id",
"reserved_for",
"note",
"reserved_by",
"reserved_at",
"registered"
],
"properties": {
"device_id": {
"type": "string"
},
"reserved_for": {
"type": "string",
"description": "The label. A job holds the reservation by setting `reservation` to it."
},
"note": {
"type": [
"string",
"null"
]
},
"reserved_by": {
"type": [
"string",
"null"
],
"description": "Who reserved it, when tokens are on; otherwise the source."
},
"reserved_at": {
"type": [
"string",
"null"
]
},
"registered": {
"type": "boolean",
"description": "Whether the device has registered yet. false is allowed (reserve before cabling) but is also what a typo looks like."
}
}
},
"ReserveInput": {
"type": "object",
"required": [
"label"
],
"properties": {
"label": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$",
"description": "What the device is kept for; jobs carry it as `reservation`."
},
"note": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Why, for whoever reads the shelf."
}
}
},
"Result": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -5070,6 +5290,25 @@
"pages"
]
},
"Unreservation": {
"type": "object",
"required": [
"device_id",
"was"
],
"properties": {
"device_id": {
"type": "string"
},
"was": {
"type": [
"string",
"null"
],
"description": "The label it was reserved for, or null."
}
}
},
"Validation": {
"type": "object",
"properties": {
Expand Down
Loading
Loading