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
5 changes: 5 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ ostool board config
# List remote board types
ostool board ls

# Connect a specific remote board
ostool board connect -b OrangePi-5-Plus --board-id OrangePi-5-Plus-2

# Run on a remote board
ostool board run

Expand Down Expand Up @@ -352,6 +355,8 @@ ostool board config

`server` should be a complete URL including `http://` or `https://`; the optional `port` overrides the URL port. For legacy LAN configurations, a bare IPv4 or IPv6 address is interpreted as `http://`. The base release's persisted `server_ip` / `port` pair is also migrated to `server` / `port` when read; the next configuration save writes only the new format. Bare host names are not supported. Project-local `.board.toml` `server` / `port` fields still apply to `ostool board run`, with precedence lower than CLI flags and higher than the global config.

`ostool board connect -b <BOARD_TYPE>` allocates any available board of that type; pass `--board-id <BOARD_ID>` as well when you need to connect a specific board.

A `.board.toml` file can declare shared files relative to its own directory with
`session_files`. The caller supplies that directory through
`BoardRunRequest::with_session_files`; after the board session is created,
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ ostool board config
# 查看远端开发板类型
ostool board ls

# 连接指定开发板
ostool board connect -b OrangePi-5-Plus --board-id OrangePi-5-Plus-2

# 在远端开发板上运行
ostool board run

Expand Down Expand Up @@ -348,6 +351,8 @@ ostool board config

`server` 应使用包含 `http://` 或 `https://` 的完整 URL;可选的 `port` 会覆盖 URL 中的端口。为兼容旧的局域网配置,裸 IPv4 或 IPv6 地址会自动补为 `http://`。基线版本写出的 `server_ip` / `port` 也会在读取时迁移为 `server` / `port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。项目级 `.board.toml` 中的 `server` / `port` 仍可用于 `ostool board run`,其优先级低于命令行参数,高于全局配置。

`ostool board connect -b <BOARD_TYPE>` 会按类型分配任意空闲开发板;需要连接某一块具体开发板时,可以额外传入 `--board-id <BOARD_ID>`。

`.board.toml` 可以用 `session_files` 声明相对于配置文件目录的共享文件。调用方通过
`BoardRunRequest::with_session_files` 提供该目录,ostool 会在 board session
建立后按原相对路径上传,并在每个 `shell_check_steps` 的 `shell_cmd` 中展开
Expand Down
11 changes: 8 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须
| `ostool auth status [--server URL] [--port PORT]` | 显示当前 endpoint 的认证状态。 | 显示凭据类型、已知过期时间和 scope,不显示 Token。 | 无网络 API |
| `ostool logout [--server URL] [--port PORT]` | 退出登录。 | OAuth 凭据会尝试远端撤销;随后删除本地凭据。PAT 仅删除本地副本。 | `POST /oauth/revoke`(仅 OAuth) |
| `ostool board ls [--server URL] [--port PORT]` | 查询按类型聚合的可用开发板信息。 | 调用时携带 Bearer Token。 | `GET /api/v1/board-types` |
| `ostool board connect --board-type TYPE [--server URL] [--port PORT]` | 请求服务端从指定类型中自动分配一块开发板并打开串口终端。 | REST 和 WebSocket 请求均携带 Bearer Token。 | `POST /api/v1/sessions`;`POST /api/v1/sessions/{session_id}/heartbeat`;WebSocket `/api/v1/sessions/{session_id}/serial/ws`;`DELETE /api/v1/sessions/{session_id}` |
| `ostool board connect --board-type TYPE [--board-id BOARD_ID] [--server URL] [--port PORT]` | 请求服务端从指定类型中自动分配一块开发板,或指定一块开发板,并打开串口终端。 | REST 和 WebSocket 请求均携带 Bearer Token。 | `POST /api/v1/sessions`;`POST /api/v1/sessions/{session_id}/heartbeat`;WebSocket `/api/v1/sessions/{session_id}/serial/ws`;`DELETE /api/v1/sessions/{session_id}` |
| `ostool board run [--server URL] [--port PORT]` | 构建后请求服务端按 `.board.toml` 的 `board_type` 自动分配开发板并启动。 | REST 和 WebSocket 请求均携带 Bearer Token。 | 始终:`POST /api/v1/sessions`、`POST /api/v1/sessions/{session_id}/heartbeat`、`DELETE /api/v1/sessions/{session_id}`。U-Boot:`GET /boot-profile`、`GET /serial`、`GET /tftp`、`GET /dtb`、`GET /dtb/download`、`PUT /files`、WebSocket `/serial/ws`。HTTP Boot:`GET /boot-profile`、`GET /serial`、`PUT /http-boot/kernel`、WebSocket `/serial/ws`。 |

## OAuth Device Authorization API
Expand Down Expand Up @@ -627,12 +627,15 @@ Content-Type: application/json

{
"board_type": "rk3568",
"board_id": "rk3568-02",
"required_tags": [],
"client_name": "ostool"
}
```

用于 `ostool board connect` 和 `ostool board run`。`board_type` 必填,`required_tags` 由当前 CLI 固定发送空数组,`client_name` 固定为 `ostool`。服务端在满足类型和标签条件的空闲开发板中自动分配,不支持通过当前 `ostool` 指定 `board_id`。
用于 `ostool board connect` 和 `ostool board run`。`board_type` 必填,`board_id` 可选,`required_tags` 由当前 CLI 固定发送空数组,`client_name` 固定为 `ostool`。未提供 `board_id` 时,服务端在满足类型和标签条件的空闲开发板中自动分配;提供时只分配该 ID 对应的、类型匹配、未禁用且空闲的开发板。CLI 通过 `ostool board connect -b <BOARD_TYPE> --board-id <BOARD_ID>` 使用此能力;`--board-type` 仍是必填参数。

服务端会对 `board_id` 做首尾空白清理,清理后为空的值返回 `400`。不存在的开发板返回 `404`,开发板类型不匹配返回 `400`,指定开发板不可用时返回 `409`。客户端会将 CLI 参数规范化后发送,并在创建成功后校验响应中的 `board_id`;如果服务端返回了另一块开发板,客户端会尽力使用 `DELETE /api/v1/sessions/{session_id}` 释放刚创建的会话,然后报错,不会静默连接错误设备。

成功时返回 `201 Created`:

Expand All @@ -653,7 +656,9 @@ Content-Type: application/json

当前 `ostool-server` 的固定会话 TTL 为 10 秒,每次心跳会把到期时间更新为服务端当前时间之后 10 秒;`ostool` 在成功创建会话后每秒发送一次心跳。独立认证后端可以采用不同 TTL,但必须返回真实的 `lease_expires_at` 并在心跳时续租。

指定类型不存在时返回 `404`;类型存在但没有符合条件的空闲开发板时返回 `409`。只有结构化错误中的 `code` 恰好为 `conflict`,且 `message` 与服务端生成的 `no available board for type …` 完全匹配时,当前客户端才会每秒重试;其他 `409` 会直接返回给调用者。
指定类型不存在时返回 `404`;类型存在但没有符合条件的空闲开发板时返回 `409`。未指定 `board_id` 时,只有结构化错误中的 `code` 恰好为 `conflict`,且 `message` 与服务端生成的 `no available board for type …` 完全匹配,客户端才会每秒重试。指定 `board_id` 且该板卡忙时,服务端返回与 `board <BOARD_ID> is not available` 对应的结构化错误,客户端同样每秒重试;其他错误直接返回给调用者。

`board_id` 是向后兼容的可选 JSON 字段。旧客户端不发送该字段,仍按类型自动分配。新客户端连接会忽略未知 JSON 字段但仍按类型分配的旧服务端时,会通过成功响应中的 `board_id` 检测到服务端未执行指定分配,尽力释放该会话并报错;因此不会因为旧服务端静默忽略字段而连接到另一块真实设备。若旧服务端拒绝未知字段,则直接返回其错误。

### 查询会话详情

Expand Down
10 changes: 10 additions & 0 deletions ostool-server/src/api/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ pub struct CreateSessionRequest {
pub client_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSessionRequestWithBoardId {
pub board_type: String,
#[serde(default)]
pub board_id: Option<String>,
#[serde(default)]
pub required_tags: Vec<String>,
pub client_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionCreatedResponse {
pub session_id: String,
Expand Down
142 changes: 123 additions & 19 deletions ostool-server/src/api/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ use crate::{
AdminServerConfigEditable, AdminServerConfigReadonly, AdminServerConfigResponse,
AdminSessionsResponse, AdminTftpConfigResponse, AdminTftpStatusResponse,
BoardPowerAction, BoardPowerStatusResponse, BoardRuntimeStatusResponse,
BoardTypeSummary, BootProfileResponse, CreateSessionRequest,
BoardTypeSummary, BootProfileResponse, CreateSessionRequestWithBoardId,
CreateVirtualDeviceRequest, DtbFileResponse, HeartbeatResponse, HttpBootFileResponse,
KernelPublishResponse, LoaderDeviceSummary, NetworkInterfaceSummary, SerialPortSummary,
SerialStatusResponse, SessionCreatedResponse, SessionDetailResponse,
SessionDtbResponse, SharedSessionFileResponse, TftpSessionResponse,
UpdateServerConfigRequest, VirtualDeviceSummary, VirtualDevicesResponse,
},
},
board_pool::BoardAllocationStatus,
board_pool::{BoardAllocationStatus, BoardAllocationWithIdStatus},
config::{
BoardConfig, BootConfig, PowerManagementConfig, ServerConfig, TftpConfig, UbootNetworkMode,
},
Expand Down Expand Up @@ -1288,28 +1288,62 @@ async fn list_board_types(

async fn create_session(
State(state): State<AppState>,
axum::Json(request): axum::Json<CreateSessionRequest>,
axum::Json(request): axum::Json<CreateSessionRequestWithBoardId>,
) -> Result<(StatusCode, axum::Json<SessionCreatedResponse>), ApiError> {
if request.board_type.trim().is_empty() {
return Err(ApiError::bad_request("board_type must not be empty"));
}
let board_id = request.board_id.as_deref().map(str::trim);
if board_id == Some("") {
return Err(ApiError::bad_request(
"board_id must not be empty when provided",
));
}

let session = state
.create_session(
&request.board_type,
&request.required_tags,
request.client_name.clone(),
)
.await
.map_err(|err| match err {
BoardAllocationStatus::BoardTypeNotFound => {
ApiError::not_found(format!("board type `{}` not found", request.board_type))
}
BoardAllocationStatus::NoAvailableBoard => ApiError::conflict(format!(
"no available board for type `{}`",
request.board_type
)),
})?;
let session = match board_id {
Some(board_id) => state
.create_session_with_board_id(
&request.board_type,
board_id,
&request.required_tags,
request.client_name.clone(),
)
.await
.map_err(|err| match err {
BoardAllocationWithIdStatus::BoardTypeNotFound => {
ApiError::not_found(format!("board type `{}` not found", request.board_type))
}
BoardAllocationWithIdStatus::BoardNotFound => {
ApiError::not_found(format!("board `{board_id}` not found"))
}
BoardAllocationWithIdStatus::BoardTypeMismatch {
board_id,
board_type,
actual_board_type,
} => ApiError::bad_request(format!(
"board `{board_id}` has type `{actual_board_type}`, not `{board_type}`"
)),
BoardAllocationWithIdStatus::NoAvailableBoard => {
ApiError::conflict(format!("board `{board_id}` is not available"))
}
})?,
None => state
.create_session(
&request.board_type,
&request.required_tags,
request.client_name.clone(),
)
.await
.map_err(|err| match err {
BoardAllocationStatus::BoardTypeNotFound => {
ApiError::not_found(format!("board type `{}` not found", request.board_type))
}
BoardAllocationStatus::NoAvailableBoard => ApiError::conflict(format!(
"no available board for type `{}`",
request.board_type
)),
})?,
};

let board = state
.session_board(&session.id)
Expand Down Expand Up @@ -5077,6 +5111,76 @@ mod tests {
assert_eq!(value["serial_available"], true);
}

#[tokio::test]
async fn create_session_allocates_requested_board_id() {
let app = test_router().await;
for board_id in ["demo-01", "demo-02"] {
let mut board = sample_board(board_id);
board.board_type = "demo".to_string();
assert_eq!(
create_board(&app, serde_json::to_value(board).unwrap()).await,
StatusCode::CREATED
);
}

let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "demo",
"board_id": " demo-02 ",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["board_id"], "demo-02");
}

#[tokio::test]
async fn create_session_rejects_missing_requested_board_id() {
let app = test_router().await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/sessions")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"board_type": "demo",
"board_id": "missing-demo-01",
"required_tags": [],
"client_name": "test",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["code"], "not_found");
assert_eq!(value["message"], "board `missing-demo-01` not found");
}

#[tokio::test]
async fn create_session_returns_conflict_without_waiting_when_pool_is_busy() {
let app = test_router().await;
Expand Down
Loading