Compare commits
12 Commits
fc2e1a2f69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c191bd07ba | |||
| a3eb952931 | |||
| 2a425196e6 | |||
|
|
f6ae31c319 | ||
|
|
a76e62f431 | ||
|
|
fcb9679796 | ||
|
|
032476e21f | ||
|
|
90842f382a | ||
|
|
0ed056c7f1 | ||
|
|
aa79097c1f | ||
|
|
24714b01fc | ||
|
|
9cb8a7cf00 |
@@ -1,5 +1,5 @@
|
||||
# set Java
|
||||
ARG JAVA_VERSION=21.0.11_10
|
||||
ARG JAVA_VERSION=21
|
||||
# extracts spring layers from the app jar (to optimize boot)
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS build
|
||||
|
||||
|
||||
5
docker/build/Dockerfile.bak
Normal file
5
docker/build/Dockerfile.bak
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY app.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java","-jar","app.jar"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# set Java
|
||||
ARG JAVA_VERSION=21.0.11_10
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS build
|
||||
ARG JAVA_VERSION=21
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG HAWKBIT_APP=hawkbit-repository-jpa-init
|
||||
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# set Java
|
||||
ARG JAVA_VERSION=21.0.11_10
|
||||
ARG JAVA_VERSION=21
|
||||
# extracts spring layers from the app jar (to optimize boot)
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS build
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-alpine AS build
|
||||
|
||||
ARG HAWKBIT_APP=hawkbit-update-server
|
||||
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
||||
@@ -18,7 +18,7 @@ RUN set -x &&\
|
||||
java -Djarmode=tools -jar /${APP}-${VERSION}.jar extract --layers --launcher --destination . &&\
|
||||
rm /${APP}-${VERSION}.jar
|
||||
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-alpine
|
||||
ARG APP_DIR=/app
|
||||
WORKDIR ${APP_DIR}
|
||||
RUN addgroup -S hawkbit_group && adduser -D hawkbit -G hawkbit_group
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
# Postgres service
|
||||
# ---------------------
|
||||
postgres:
|
||||
image: "postgres:16.5"
|
||||
image: "postgres:16"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
deploy:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
# RabbitMQ service
|
||||
# ---------------------
|
||||
rabbitmq:
|
||||
image: "rabbitmq:4-management-alpine"
|
||||
image: "rabbitmq:management-alpine"
|
||||
hostname: "rabbitmq"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_VHOST: "/"
|
||||
@@ -44,4 +44,4 @@ services:
|
||||
- "5672:5672"
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
condition: on-failure
|
||||
|
||||
11
docker/postgres/docker-compose-h2.yml
Normal file
11
docker/postgres/docker-compose-h2.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
hawkbit:
|
||||
image: hawkbit/hawkbit-update-server:latest
|
||||
ports:
|
||||
- "9099:8080"
|
||||
# 启用内置H2内存数据库,不依赖外部PG/RabbitMQ
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:h2:mem:hawkbit;DB_CLOSE_DELAY=-1
|
||||
SPRING_JPA_HIBERNATE_DDL_AUTO: update
|
||||
SPRING_H2_CONSOLE_ENABLED: true
|
||||
restart: always
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
# HawkBit DDI
|
||||
# ---------------------
|
||||
hawkbit-ddi:
|
||||
image: "hawkbit/hawkbit-ddi-server:latest"
|
||||
image: "ghcr.io/eclipse-hawkbit/hawkbit-ddi-server:latest"
|
||||
environment:
|
||||
- 'PROFILES=postgresql'
|
||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
# HawkBit DMF
|
||||
# ---------------------
|
||||
hawkbit-dmf:
|
||||
image: "hawkbit/hawkbit-dmf-server:latest"
|
||||
image: "ghcr.io/eclipse-hawkbit/hawkbit-dmf-server:latest"
|
||||
environment:
|
||||
- 'PROFILES=postgresql'
|
||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
# HawkBit MGMT
|
||||
# ---------------------
|
||||
hawkbit-mgmt:
|
||||
image: "hawkbit/hawkbit-mgmt-server:latest"
|
||||
image: "ghcr.io/eclipse-hawkbit/hawkbit-mgmt-server:latest"
|
||||
environment:
|
||||
- 'PROFILES=postgresql'
|
||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
#
|
||||
# Copyright (c) 2018 Bosch Software Innovations GmbH and others
|
||||
#
|
||||
# This program and the accompanying materials are made
|
||||
# available under the terms of the Eclipse Public License 2.0
|
||||
# which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
#
|
||||
# SPDX-License-Identifier: EPL-2.0
|
||||
#
|
||||
version: '3.7'
|
||||
|
||||
include:
|
||||
- docker-compose-micro-services-postgres.yml
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------
|
||||
# HawkBit UI
|
||||
# ---------------------
|
||||
hawkbit-ui:
|
||||
image: "hawkbit/hawkbit-ui:latest"
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
- 'SPRING_APPLICATION_JSON={"hawkbit.server.mgmtUrl": "http://hawkbit-mgmt:8080"}'
|
||||
restart: always
|
||||
POSTGRES_DB: hawkbit
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 8088:8088
|
||||
labels:
|
||||
NAME: "hawkbit-ui"
|
||||
- "5432:5432"
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management-alpine
|
||||
ports:
|
||||
- "15672:15672"
|
||||
- "5672:5672"
|
||||
|
||||
hawkbit:
|
||||
image: hawkbit/hawkbit-update-server:latest
|
||||
ports:
|
||||
- "9099:8080" # 对应你截图9091端口访问
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/hawkbit
|
||||
SPRING_DATASOURCE_USERNAME: postgres
|
||||
SPRING_DATASOURCE_PASSWORD: postgres
|
||||
SPRING_RABBITMQ_HOST: rabbitmq
|
||||
depends_on:
|
||||
- postgres
|
||||
- rabbitmq
|
||||
|
||||
volumes:
|
||||
artifactrepo:
|
||||
driver: local
|
||||
pgdata:
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
# HawkBit UI service
|
||||
# ---------------------
|
||||
hawkbit-ui:
|
||||
image: "hawkbit/hawkbit-ui:latest"
|
||||
image: "ghcr.io/eclipse-hawkbit/hawkbit-ui:lates"
|
||||
environment:
|
||||
- 'SPRING_APPLICATION_JSON={"hawkbit.server.mgmtUrl": "http://hawkbit:8080"}'
|
||||
restart: always
|
||||
|
||||
746
hawkbit-compose/CURL-API.md
Normal file
746
hawkbit-compose/CURL-API.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# Hawkbit DDI API — curl 完整操作手册
|
||||
|
||||
基于实际验证的 Hawkbit DDI API v1 交互流程,所有命令均已跑通。
|
||||
|
||||
## 环境信息
|
||||
|
||||
```
|
||||
Hawkbit Server: http://192.168.142.128:9090
|
||||
Tenant: DEFAULT
|
||||
Auth: GatewayToken my-gateway-key (DDI 接口)
|
||||
admin:admin (Management 接口)
|
||||
Controller ID: test-curl-001
|
||||
DistributionSet: id=1 (r3576test:V1.0.0.0)
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
1. [认证配置](#1-认证配置)
|
||||
2. [自动注册与轮询](#2-自动注册与轮询)
|
||||
3. [分配 Distribution Set](#3-分配-distribution-set)
|
||||
4. [拉取部署详情](#4-拉取部署详情)
|
||||
5. [上报设备属性](#5-上报设备属性)
|
||||
6. [下载 artifact](#6-下载-artifact)
|
||||
7. [SHA256 文件校验](#7-sha256-文件校验)
|
||||
8. [上报执行状态](#8-上报执行状态)
|
||||
9. [取消任务](#9-取消任务)
|
||||
10. [确认任务流程](#10-确认任务流程)
|
||||
11. [查询验证](#11-查询验证)
|
||||
12. [完整流程速查](#12-完整流程速查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 认证配置
|
||||
|
||||
### 1.1 开启 GatewayToken 认证(服务端,一次性)
|
||||
|
||||
```bash
|
||||
# 设置 token key
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \
|
||||
-d '{"value": "my-gateway-key"}'
|
||||
|
||||
# 启用 gateway token 认证
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 1.2 开启 TargetToken 认证(服务端,一次性)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/system/configs/authentication.targettoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 1.3 查看当前所有认证配置
|
||||
|
||||
```bash
|
||||
curl -s -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/system/configs" \
|
||||
| python3 -m json.tool | grep -E "authentication\.(gateway|target)"
|
||||
```
|
||||
|
||||
### 1.4 认证方式对比
|
||||
|
||||
```
|
||||
DDI 请求头:
|
||||
GatewayToken → Authorization: GatewayToken my-gateway-key
|
||||
TargetToken → Authorization: TargetToken <设备securityToken>
|
||||
|
||||
Management API 请求头:
|
||||
Basic Auth → -u admin:admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 自动注册与轮询
|
||||
|
||||
### 2.1 首次轮询(自动注册)
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}
|
||||
```
|
||||
|
||||
设备 SN 在 Hawkbit 中不存在时,服务端自动调用 `findOrRegisterTargetIfItDoesNotExist()` 创建 target。
|
||||
|
||||
```bash
|
||||
curl -v \
|
||||
-H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Accept: application/json" \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001"
|
||||
```
|
||||
|
||||
**响应(无待处理 action):**
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {"polling": {"sleep": "00:05:00"}},
|
||||
"_links": {
|
||||
"configData": {
|
||||
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应(有待处理 action):**
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {"polling": {"sleep": "00:05:00"}},
|
||||
"_links": {
|
||||
"deploymentBase": {
|
||||
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15?c=424529173"
|
||||
},
|
||||
"configData": {
|
||||
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 响应中的 HAL 链接含义
|
||||
|
||||
| 链接 rel | 含义 | 下一步动作 |
|
||||
|----------|------|----------|
|
||||
| `deploymentBase` | 有待执行的部署任务 | 拉取部署详情 → 下载 → 安装 |
|
||||
| `cancelAction` | 有取消指令 | 确认取消 |
|
||||
| `confirmationBase` | 有需要确认的任务 | 发送确认 |
|
||||
| `configData` | 服务端要求上报属性 | PUT 属性数据 |
|
||||
| `installedBase` | 有历史安装记录 | 可查询 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 分配 Distribution Set
|
||||
|
||||
```
|
||||
POST /rest/v1/distributionsets/{distributionSetId}/assignedTargets
|
||||
```
|
||||
|
||||
> ⚠️ 这是 Management API,用 `-u admin:admin` 认证。
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id": "test-curl-001"}]'
|
||||
```
|
||||
|
||||
**可选参数(完整请求体)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "test-curl-001",
|
||||
"type": "forced",
|
||||
"forcetime": 1682408575278,
|
||||
"weight": 1000,
|
||||
"maintenanceWindow": {
|
||||
"schedule": "00:00-04:00",
|
||||
"duration": "02:00:00",
|
||||
"timezone": "+08:00"
|
||||
},
|
||||
"confirmationRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`200 OK`,返回 `MgmtTargetAssignmentResponseBody`。
|
||||
|
||||
分配成功后,设备下次轮询就会拿到 `deploymentBase` 链接。
|
||||
|
||||
---
|
||||
|
||||
## 4. 拉取部署详情
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Accept: application/json" \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15"
|
||||
```
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "15",
|
||||
"deployment": {
|
||||
"download": "forced",
|
||||
"update": "forced",
|
||||
"chunks": [
|
||||
{
|
||||
"part": "os",
|
||||
"version": "V1.0.0.0",
|
||||
"name": "r3576-OS-android",
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "update.zip",
|
||||
"hashes": {
|
||||
"sha1": "223377ea691bd7fc47865cccfd802771ff1b3fca",
|
||||
"md5": "28a17f5fb87d637756c0cc5d555d69b7",
|
||||
"sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
|
||||
},
|
||||
"size": 195411,
|
||||
"_links": {
|
||||
"download-http": {
|
||||
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
},
|
||||
"md5sum-http": {
|
||||
"href": "http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | action ID,后续反馈操作的关键参数 |
|
||||
| `deployment.download` | `forced`=强制下载 / `attempt`=尝<><E5B09D><EFBFBD>下载 / `skip`=跳过 |
|
||||
| `deployment.update` | `forced`=强制安装 / `attempt`=尝试安装 / `skip`=跳过 |
|
||||
| `chunks[].part` | 软件模块类型(os / app / …) |
|
||||
| `chunks[].version` | 软件版本号 |
|
||||
| `chunks[].artifacts[].filename` | 要下载的文件名 |
|
||||
| `chunks[].artifacts[].hashes` | SHA1 / MD5 / SHA256 校验值 |
|
||||
| `chunks[].artifacts[].size` | 文件大小(bytes) |
|
||||
| `_links.download-http.href` | **下载地址** |
|
||||
|
||||
### download / update handlingType 组合
|
||||
|
||||
| download | update | 含义 |
|
||||
|----------|--------|------|
|
||||
| `forced` | `forced` | 立即下载并安装 |
|
||||
| `attempt` | `attempt` | 设备自行决定是否下载/安装 |
|
||||
| `forced` | `skip` | 只下载不安装(download only) |
|
||||
| `skip` | `skip` | maintenance window 未到,等待下次轮询 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 上报设备属性
|
||||
|
||||
```
|
||||
PUT /{tenant}/controller/v1/{controllerId}/configData
|
||||
```
|
||||
|
||||
> ⚠️ 必须先轮询一次让 target 自动注册,再上报属性,否则 404。
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" -X PUT \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/configData" \
|
||||
-d '{
|
||||
"mode": "merge",
|
||||
"data": {
|
||||
"osType": "linux",
|
||||
"platform": "x86_64",
|
||||
"kernel": "6.17.0-35-generic",
|
||||
"hostname": "test-curl-001",
|
||||
"hwRevision": "VMware Virtual Platform",
|
||||
"manufacturer": "VMware, Inc.",
|
||||
"swVersion": "V1.0.0.0"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### mode 说明
|
||||
|
||||
| mode | 行为 |
|
||||
|------|------|
|
||||
| `merge` | 合并:只更新传入的 key,保留已有属性(推荐) |
|
||||
| `replace` | 替换:完全覆盖已有属性 |
|
||||
| `remove` | 删除:移除传入的 key |
|
||||
|
||||
**响应**:`200 OK`(空 body)
|
||||
|
||||
### 查询设备属性
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 下载 artifact
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/{fileName}
|
||||
```
|
||||
|
||||
### 6.1 完整下载
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-o /tmp/test-update.zip \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
```
|
||||
|
||||
**响应**:`200 OK`,二进制流。
|
||||
|
||||
### 6.2 断点续传(Range 请求)
|
||||
|
||||
```bash
|
||||
# 续传:从 byte 1048576 开始
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Range: bytes=1048576-" \
|
||||
-o /tmp/test-update.zip.part --append \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
```
|
||||
|
||||
**响应**:`206 Partial Content`,`Content-Range: bytes 1048576-195410/195411`。
|
||||
|
||||
### 6.3 条件请求(If-Match)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "If-Match: 223377ea691bd7fc47865cccfd802771ff1b3fca" \
|
||||
-o /tmp/test-update.zip \
|
||||
"http://..."
|
||||
# SHA1 不匹配 → 412 Precondition Failed
|
||||
```
|
||||
|
||||
### 6.4 下载 MD5SUM
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
|
||||
```
|
||||
|
||||
**响应**:`28a17f5fb87d637756c0cc5d555d69b7 update.zip`
|
||||
|
||||
### 6.5 从 HAL 链接提取下载地址
|
||||
|
||||
artifact 的 `_links` 中有两个下载链接,优先级:
|
||||
|
||||
```
|
||||
download → HTTPS(如果有,首选)
|
||||
download-http → HTTP(回退方案)
|
||||
md5sum-http → MD5 校验文件(HTTP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SHA256 文件校验
|
||||
|
||||
### 7.1 计算本地文件 SHA256
|
||||
|
||||
```bash
|
||||
sha256sum /tmp/test-update.zip
|
||||
```
|
||||
|
||||
### 7.2 对比部署详情中的哈希值
|
||||
|
||||
```bash
|
||||
# 部署详情中的 SHA256:
|
||||
# "sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
|
||||
|
||||
echo "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530 /tmp/test-update.zip" | sha256sum -c
|
||||
# → /tmp/test-update.zip: OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 上报执行状态
|
||||
|
||||
```
|
||||
POST /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback
|
||||
```
|
||||
|
||||
### 8.1 状态上报 JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1750000000000,
|
||||
"status": {
|
||||
"execution": "download",
|
||||
"result": {
|
||||
"finished": "none",
|
||||
"progress": {"cnt": 2, "of": 5}
|
||||
},
|
||||
"code": 200,
|
||||
"details": ["Starting download"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 四步标准流程
|
||||
|
||||
```bash
|
||||
# 定义变量,方便重复使用
|
||||
D="http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/deploymentBase/15/feedback"
|
||||
A="Authorization: GatewayToken my-gateway-key"
|
||||
J="Content-Type: application/json"
|
||||
|
||||
# ① download — 开始下载
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting download"]}}' \
|
||||
"$D"
|
||||
|
||||
# ② downloaded — 下载完成
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded update.zip"]}}' \
|
||||
"$D"
|
||||
|
||||
# ③ proceeding — 安装中
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing update"]}}' \
|
||||
"$D"
|
||||
|
||||
# ④ closed — 最终结果
|
||||
# 成功:
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' \
|
||||
"$D"
|
||||
|
||||
# 失败:
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"failure"},"details":["Installation failed"],"code":500}}' \
|
||||
"$D"
|
||||
```
|
||||
|
||||
### 8.3 execution 枚举值
|
||||
|
||||
| execution | Hawkbit Status | 说明 |
|
||||
|-----------|---------------|------|
|
||||
| `download` | `DOWNLOAD` | 开始下载 |
|
||||
| `downloaded` | `DOWNLOADED` | 下载完成 |
|
||||
| `proceeding` | `RUNNING` | 安装进行中 |
|
||||
| `closed` | `FINISHED` / `ERROR` | 最终状态(配合 result.finished) |
|
||||
| `canceled` | `CANCELED` | 已取消 |
|
||||
| `rejected` | `WARNING` | 已拒绝 |
|
||||
| `scheduled` | `RUNNING` | 已计划 |
|
||||
| `resumed` | `RUNNING` | 恢复执行 |
|
||||
|
||||
### 8.4 result.finished 枚举值
|
||||
|
||||
| finished | 含义 |
|
||||
|----------|------|
|
||||
| `none` | 尚未完成(中间状态) |
|
||||
| `success` | 执行成功 |
|
||||
| `failure` | 执行失败 |
|
||||
|
||||
### 8.5 带进度的上报
|
||||
|
||||
```bash
|
||||
# 进度: 第2个/总共5个 artifact
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none","progress":{"cnt":2,"of":5}},"details":["Downloading artifact 2/5"]}}' \
|
||||
"$D"
|
||||
```
|
||||
|
||||
### 8.6 取消反馈(cancelAction feedback)
|
||||
|
||||
```bash
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Cancel acknowledged"]}}' \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
|
||||
```
|
||||
|
||||
### 8.7 确认反馈(confirmationBase feedback)
|
||||
|
||||
```bash
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
|
||||
```
|
||||
|
||||
### 8.8 常见错误
|
||||
|
||||
| HTTP 状态码 | 原因 | 解决 |
|
||||
|------------|------|------|
|
||||
| `401` | 认证未通过 | 检查 Authorization header 格式 |
|
||||
| `404` | URL 路径错误或 target 不存在 | 确保 `/{tenant}/controller/v1/...` |
|
||||
| `405` | feedback URL 带了 `?c=xxx` 参数 | POST 前剥离查询参数 |
|
||||
| `410 Gone` | action 已不再活跃 | 已经 closed 或 canceled |
|
||||
| `400 Body Not Readable` | JSON 格式错误 | `"final"` 应为 `"finished"`,`"progress":{}` 应为 `null` 或省略 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 取消任务
|
||||
|
||||
### 9.1 服务端发起取消(Management API)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X DELETE \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15"
|
||||
```
|
||||
|
||||
### 9.2 设备端处理取消
|
||||
|
||||
```bash
|
||||
# ① 轮询拿到 cancelAction
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001"
|
||||
# → _links.cancelAction.href
|
||||
|
||||
# ② 确认取消
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Acknowledged"]}}' \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 确认任务流程
|
||||
|
||||
### 10.1 开启确认流程(服务端)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/system/configs/user.confirmation.flow.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 10.2 分配时要求确认
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.142.128:9090/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id":"test-curl-001","confirmationRequired":true}]'
|
||||
```
|
||||
|
||||
### 10.3 设备端自动确认
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
|
||||
"http://192.168.142.128:9090/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 查询验证
|
||||
|
||||
### 11.1 查询 target 基本信息
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001"
|
||||
```
|
||||
|
||||
### 11.2 查询 target 所有 action
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions"
|
||||
```
|
||||
|
||||
### 11.3 查询指定 action 详情
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15"
|
||||
```
|
||||
|
||||
### 11.4 查询 action 状态历史
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/actions/15/status"
|
||||
```
|
||||
|
||||
**响应示例(完成全流程后)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{"id":58, "type": "finished", "messages": ["Installation complete"], ...},
|
||||
{"id":57, "type": "running", "messages": ["Installing update"], ...},
|
||||
{"id":56, "type": "downloaded","messages": ["Downloaded update.zip"], ...},
|
||||
{"id":55, "type": "download", "messages": ["Starting download"], ...},
|
||||
{"id":54, "type": "download", "messages": ["Target downloads ..."], ...},
|
||||
{"id":53, "type": "retrieved", "messages": ["Target retrieved ..."], ...},
|
||||
{"id":52, "type": "running", "messages": ["Assignment initiated ..."]}
|
||||
],
|
||||
"total": 7, "size": 7
|
||||
}
|
||||
```
|
||||
|
||||
### 11.5 查询 target 属性
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets/test-curl-001/attributes"
|
||||
```
|
||||
|
||||
### 11.6 查询所有 target
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/targets"
|
||||
```
|
||||
|
||||
### 11.7 查询所有 Distribution Set
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.142.128:9090/rest/v1/distributionsets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 完整流程速查
|
||||
|
||||
### 12.1 一次完整的 OTA 生命周期(复制粘贴即用)
|
||||
|
||||
```bash
|
||||
# ═══ 变量设置 ═══
|
||||
HOST="http://192.168.142.128:9090"
|
||||
TENANT="DEFAULT"
|
||||
CID="test-curl-001"
|
||||
AUTH="Authorization: GatewayToken my-gateway-key"
|
||||
ADMIN="admin:admin"
|
||||
|
||||
# ═══ Step 1: 轮询(自动注册)═══════════════════════════════════════
|
||||
curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID" | python3 -m json.tool
|
||||
|
||||
# ═══ Step 2: 分配 Distribution Set(Management API)═════════════════
|
||||
curl -u $ADMIN -X POST -H "Content-Type: application/json" \
|
||||
"$HOST/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id": "'$CID'"}]'
|
||||
|
||||
# ═══ Step 3: 轮询(拿到 deploymentBase)═════════════════════════════
|
||||
RESP=$(curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID")
|
||||
echo "$RESP" | python3 -m json.tool
|
||||
|
||||
# 提取 actionId(假设第一次分配,取 deploymentBase URL 中的数字)
|
||||
DEPLOY_URL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['_links']['deploymentBase']['href'])")
|
||||
AID=$(echo "$DEPLOY_URL" | grep -oP 'deploymentBase/\K\d+')
|
||||
echo "Action ID: $AID"
|
||||
|
||||
# ═══ Step 4: 拉取部署详情 ══════════════════════════════════════════
|
||||
DEPLOY=$(curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID")
|
||||
echo "$DEPLOY" | python3 -m json.tool
|
||||
|
||||
# 提取下载地址和 SHA256
|
||||
DL_URL=$(echo "$DEPLOY" | python3 -c "
|
||||
import sys,json
|
||||
art = json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]
|
||||
print(art['_links']['download-http']['href'])
|
||||
")
|
||||
SHA256=$(echo "$DEPLOY" | python3 -c "
|
||||
import sys,json
|
||||
print(json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]['hashes']['sha256'])
|
||||
")
|
||||
echo "Download: $DL_URL"
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
# ═══ Step 5: 上报属性 ══════════════════════════════════════════════
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" -X PUT \
|
||||
"$HOST/$TENANT/controller/v1/$CID/configData" \
|
||||
-d '{"mode":"merge","data":{"osType":"linux","hostname":"'$CID'","swVersion":"V1.0.0.0"}}'
|
||||
|
||||
# ═══ Step 6: 下载 ══════════════════════════════════════════════════
|
||||
curl -H "$AUTH" -o /tmp/update.zip "$DL_URL"
|
||||
|
||||
# ═══ Step 7: 校验 ══════════════════════════════════════════════════
|
||||
echo "$SHA256 /tmp/update.zip" | sha256sum -c
|
||||
|
||||
# ═══ Step 8: 上报四步状态 ══════════════════════════════════════════
|
||||
FB="$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID/feedback"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' "$FB"
|
||||
|
||||
# ═══ Step 9: 验证 ══════════════════════════════════════════════════
|
||||
curl -s -u $ADMIN "$HOST/rest/v1/targets/$CID/actions/$AID/status" | python3 -m json.tool
|
||||
|
||||
echo "Done! Action $AID is now finished."
|
||||
```
|
||||
|
||||
### 12.2 Hawkbit 状态流转图
|
||||
|
||||
```
|
||||
管理员分配 DS
|
||||
│
|
||||
▼
|
||||
RUNNING WAITING_FOR_CONFIRMATION
|
||||
│ │
|
||||
▼ │ confirmed
|
||||
RETRIEVED ◄─── deploymentBase ▼
|
||||
│ (轮询返回) RUNNING
|
||||
▼ │
|
||||
DOWNLOAD ...
|
||||
│
|
||||
▼
|
||||
DOWNLOADED
|
||||
│
|
||||
▼
|
||||
PROCEEDING ──→ status: RUNNING
|
||||
│
|
||||
┌───┴───┐
|
||||
▼ ▼
|
||||
FINISHED ERROR ◄─── closed + success/failure
|
||||
(成功) (失败)
|
||||
|
||||
任意阶段 ──cancel──→ CANCELING ──→ CANCELED
|
||||
```
|
||||
|
||||
### 12.3 API 端点速查表
|
||||
|
||||
| 操作 | 方法 | 路径 | 认证 |
|
||||
|------|------|------|------|
|
||||
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | DDI |
|
||||
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | DDI |
|
||||
| 上报反馈 | POST | `.../deploymentBase/{actionId}/feedback` | DDI |
|
||||
| 取消反馈 | POST | `.../cancelAction/{actionId}/feedback` | DDI |
|
||||
| 确认反馈 | POST | `.../confirmationBase/{actionId}/feedback` | DDI |
|
||||
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | DDI |
|
||||
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | DDI |
|
||||
| 下载MD5 | GET | `.../softwaremodules/{id}/artifacts/{file}.MD5SUM` | DDI |
|
||||
| 分配 DS | POST | `/rest/v1/distributionsets/{id}/assignedTargets` | Basic |
|
||||
| 查询 target | GET | `/rest/v1/targets/{controllerId}` | Basic |
|
||||
| 查询 action | GET | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
|
||||
| 查询状态 | GET | `/rest/v1/targets/{controllerId}/actions/{id}/status` | Basic |
|
||||
| 查询属性 | GET | `/rest/v1/targets/{controllerId}/attributes` | Basic |
|
||||
| 取消 action | DELETE | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
|
||||
| 配置管理 | GET/PUT/DELETE | `/rest/v1/system/configs/{key}` | Basic |
|
||||
214
hawkbit-compose/README.md
Normal file
214
hawkbit-compose/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Hawkbit DDI Client
|
||||
|
||||
轻量级纯 Python OTA 更新代理,零依赖(仅 Python 3 标准库),可在 **Android adb shell** 和 **Linux 主机**上运行。
|
||||
|
||||
## 功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **轮询** | `GET /{tenant}/controller/v1/{controllerId}` 查询部署任务,自动适配服务端建议的轮询间隔 |
|
||||
| **断点下载** | HTTP `Range` 请求实现断点续传,临时 `.tmp` 文件保留至下载完成 |
|
||||
| **SHA256 校验** | 下载完成后自动与部署元数据中的哈希值比对 |
|
||||
| **状态上报** | 完整状态链:`download` → `downloaded` → `proceeding` → `closed(success/failure)` |
|
||||
| **设备 SN 检测** | 自动检测序列号:Android `ro.serialno` → DMI `product_serial` → `/etc/machine-id` → hostname |
|
||||
| **设备属性上报** | 首次连接自动上报设备属性(osType, platform, hwRevision, kernel…),供 Hawkbit target filter 匹配 |
|
||||
| **认证** | 支持 `GatewayToken` 和 `TargetToken`,也支持从文件读取:`--gateway-token @/etc/hawkbit/gateway.key` |
|
||||
| **取消/确认** | 处理 `cancelAction`(取消任务)和 `confirmationBase`(headless 自动确认) |
|
||||
|
||||
### 设备属性上报
|
||||
|
||||
脚本在**首次轮询**时自动调用 `PUT …/configData` 上报以下属性:
|
||||
|
||||
| 属性 | 来源 | 示例 |
|
||||
|------|------|------|
|
||||
| `osType` | `/system/build.prop` 或 uname | `android` / `linux` |
|
||||
| `platform` | `platform.machine()` | `aarch64` / `x86_64` |
|
||||
| `kernel` | `os.uname().release` | `6.17.0-35-generic` |
|
||||
| `hwRevision` | DMI product_name + product_version | `VMware Virtual Platform` |
|
||||
| `manufacturer` | DMI sys_vendor 或 Android ro.product.manufacturer | `VMware, Inc.` |
|
||||
| `hostname` | `socket.gethostname()` | `device-001` |
|
||||
| `swVersion` | `/etc/hawkbit/current_version`(可选) | `V1.0.0.0` |
|
||||
|
||||
**扩展属性**:创建 `/etc/hawkbit/device_attrs.json` 添加自定义属性:
|
||||
|
||||
```json
|
||||
{"dept": "production", "region": "east", "owner": "team-a"}
|
||||
```
|
||||
|
||||
这些属性在 Hawkbit 里就是 target 的 attributes,管理员创建 target filter 时可以直接用:
|
||||
|
||||
```
|
||||
属性 osType=android AND hwRevision=VMware → 自动分配 V2.0 更新
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 网关令牌,每 30 秒轮询
|
||||
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
|
||||
|
||||
# 网关令牌从文件读取(生产推荐)
|
||||
echo "s3cret" > /etc/hawkbit/gateway.key
|
||||
chmod 600 /etc/hawkbit/gateway.key
|
||||
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key -i 30
|
||||
|
||||
# 目标令牌,单次轮询
|
||||
python3 ddi-client.py -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
|
||||
|
||||
# 自定义设备 ID + 自定义属性文件,调试模式
|
||||
python3 ddi-client.py -u https://hawkbit:8443 -t prod \
|
||||
--gateway-token @/etc/hawkbit/gateway.key \
|
||||
--controller-id edge-042 -d /data/ota -v
|
||||
```
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
-u, --base-url Hawkbit 服务端地址(必填)
|
||||
-t, --tenant 租户名称,如 DEFAULT(必填)
|
||||
--gateway-token Gateway 安全令牌
|
||||
--target-token Target 安全令牌
|
||||
--controller-id 控制器 ID,默认自动检测设备序列号
|
||||
-d, --download-dir 下载目录,默认 /tmp/hawkbit
|
||||
-i, --polling-interval 轮询间隔(秒),默认 60,可被服务端覆盖
|
||||
--no-verify 跳过 SHA256 校验
|
||||
--no-resume 禁用断点续传
|
||||
--no-ssl-verify 禁用 TLS 证书验证(不安全)
|
||||
--once 仅轮询一次后退出
|
||||
-v, --verbose 调试级日志
|
||||
-h, --help 帮助信息
|
||||
```
|
||||
|
||||
## DDI API 流程
|
||||
|
||||
```
|
||||
┌─────────┐ ① GET /{tenant}/controller/v1/{controllerId} ┌──────────┐
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ 设备 │ ←──── HAL links (deploymentBase / cancel / …) │ Hawkbit │
|
||||
│ │ │ Server │
|
||||
│ │ ② GET …/deploymentBase/{actionId} │ │
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ │ ←──── chunks[].artifacts[] (filename, SHA256, │ │
|
||||
│ │ download URL) │ │
|
||||
│ │ │ │
|
||||
│ │ ③ GET …/softwaremodules/{id}/artifacts/{file} │ │
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ │ ←──── 二进制流(支持 Range 断点续传) │ │
|
||||
│ │ │ │
|
||||
│ │ ④ POST …/deploymentBase/{actionId}/feedback │ │
|
||||
│ │ ───── {execution, result, details} ────────────────→ │ │
|
||||
│ │ ←──── 200 OK │ │
|
||||
└─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 状态上报 JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1750000000000,
|
||||
"status": {
|
||||
"execution": "download",
|
||||
"result": {
|
||||
"finished": "none"
|
||||
},
|
||||
"details": ["Starting download"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| execution 值 | 含义 | result.finished |
|
||||
|-------------|------|-----------------|
|
||||
| `download` | 开始下载 | `none` |
|
||||
| `downloaded` | 下载完成 | `none` |
|
||||
| `proceeding` | 安装中 | `none` |
|
||||
| `closed` | 最终状态 | `success` / `failure` |
|
||||
| `canceled` | 已取消 | `none` |
|
||||
| `rejected` | 已拒绝 | `none` |
|
||||
|
||||
## 认证配置
|
||||
|
||||
### Target Token(推荐,每设备独立令牌)
|
||||
|
||||
在 Hawkbit Management API 中为目标设置 security token:
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/targets/<controllerId>" \
|
||||
-d '{"securityToken": "my-device-token"}'
|
||||
```
|
||||
|
||||
启用租户级 TargetToken 认证:
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.targettoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### Gateway Token(所有设备共享同一令牌)
|
||||
|
||||
```bash
|
||||
# 设置 token key
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \
|
||||
-d '{"value": "my-gateway-key"}'
|
||||
|
||||
# 启用
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
## 设备 SN 检测优先级
|
||||
|
||||
| 优先级 | 来源 | 适用平台 |
|
||||
|--------|------|---------|
|
||||
| 1 | `getprop ro.serialno` | Android |
|
||||
| 2 | `/sys/class/dmi/id/product_serial` | Linux (x86) |
|
||||
| 3 | `/sys/devices/virtual/dmi/id/product_serial` | Linux (ARM / 嵌入式) |
|
||||
| 4 | `/etc/machine-id` | systemd Linux |
|
||||
| 5 | `socket.gethostname()` | 通用回退 |
|
||||
|
||||
## 安装钩子
|
||||
|
||||
下载完成后,脚本调用 `_install(files, handling)` 方法执行实际安装。**默认实现仅做文件存在性检查,生产环境必须覆盖此方法**。
|
||||
|
||||
### 方式一:子类继承
|
||||
|
||||
```python
|
||||
from ddi_client import HawkbitDDIClient
|
||||
|
||||
class MyClient(HawkbitDDIClient):
|
||||
def _install(self, files, handling):
|
||||
import subprocess
|
||||
for f in files:
|
||||
subprocess.run(["unzip", "-o", f, "-d", "/data/ota"], check=True)
|
||||
return True
|
||||
```
|
||||
|
||||
### 方式二:Monkey-Patch
|
||||
|
||||
```python
|
||||
client = HawkbitDDIClient(...)
|
||||
client._install = lambda files, h: do_my_install(files)
|
||||
```
|
||||
|
||||
## 兼容性
|
||||
|
||||
- Python ≥ 3.7
|
||||
- Android (adb shell, 需安装 Python)
|
||||
- Linux (x86_64 / aarch64 / armv7l)
|
||||
- macOS
|
||||
- Hawkbit DDI API v1
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
ddi-client.py # 脚本本体(单文件,可直接部署)
|
||||
```
|
||||
|
||||
无其他依赖文件。
|
||||
902
hawkbit-compose/RUNBOOK.md
Normal file
902
hawkbit-compose/RUNBOOK.md
Normal file
@@ -0,0 +1,902 @@
|
||||
# Hawkbit DDI Client — 运行流程文档
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体架构](#1-整体架构)
|
||||
2. [首次注册流程](#2-首次注册流程)
|
||||
3. [守护进程轮询流程](#3-守护进程轮询流程)
|
||||
4. [单次轮询流程](#4-单次轮询流程)
|
||||
5. [OTA 部署流程](#5-ota-部署流程)
|
||||
6. [认证流程](#6-认证流程)
|
||||
7. [取消任务流程](#7-取消任务流程)
|
||||
8. [确认任务流程](#8-确认任务流程)
|
||||
9. [断点续传流程](#9-断点续传流程)
|
||||
10. [错误处理流程](#10-错误处理流程)
|
||||
11. [安装钩子定制](#11-安装钩子定制)
|
||||
12. [部署与运维](#12-部署与运维)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ddi-client.py │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 属性检测 │ │ 认证模块 │ │ 轮询模块 │ │ 安装钩子 │ │
|
||||
│ │ (SN/OS/ │ │ Gateway/ │ │ Poll/ │ │ _install()│ │
|
||||
│ │ HW/Kernel│ │ Target │ │ Deploy/ │ │ 可覆盖 │ │
|
||||
│ │ ...) │ │ Token │ │ Download │ │ │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP 层 │ │
|
||||
│ │ (urllib + SSL context + Range 支持) │ │
|
||||
│ └──────────────────────┬───────────────────────────┘ │
|
||||
└─────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Hawkbit Server │
|
||||
│ DDI API v1 (9090) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
属性文件 ──┐
|
||||
├──→ 属性检测 ──→ PUT /configData ──→ Hawkbit 记录 target attributes
|
||||
SN 检测 ──┘
|
||||
|
||||
轮询 ──→ GET /controller/v1/{SN}
|
||||
←── deploymentBase / cancelAction / confirmationBase / configData / 无
|
||||
|
||||
部署 ──→ GET /deploymentBase/{id} ←── chunks[].artifacts[]
|
||||
下载 ──→ GET /softwaremodules/{id}/... ←── 二进制流 (Range 支持)
|
||||
反馈 ──→ POST /feedback ←── 200 OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 首次注册流程
|
||||
|
||||
**触发条件**:设备 SN 在 Hawkbit 中不存在(或脚本首次连接后重启)
|
||||
|
||||
```
|
||||
启动参数:
|
||||
ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 1: 读取 Gateway Token
|
||||
═══════════════════════════════════════════════════════════════
|
||||
输入: @/etc/hawkbit/gateway.key
|
||||
动作: open("/etc/hawkbit/gateway.key") → read() → strip()
|
||||
输出: Authorization: GatewayToken <文件内容>
|
||||
|
||||
备选: --gateway-token <明文key> 直接传入
|
||||
--target-token <明文token> 直接传入
|
||||
--target-token @/etc/hawkbit/target.token 文件读取
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 2: 检测设备 SN
|
||||
═══════════════════════════════════════════════════════════════
|
||||
优先级:
|
||||
① getprop ro.serialno → Android 序列号
|
||||
② /sys/class/dmi/id/product_serial → Linux DMI (x86)
|
||||
③ /sys/devices/virtual/dmi/id/... → Linux DMI (ARM/嵌入式)
|
||||
④ /etc/machine-id → systemd 机器 ID
|
||||
⑤ socket.gethostname() → 兜底
|
||||
|
||||
跳过条件: --controller-id 显式指定
|
||||
输出: controllerId = "89aa36c5b6744dc2910479f7ecd6d2c3"
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 3: 检测设备属性
|
||||
═══════════════════════════════════════════════════════════════
|
||||
自动检测:
|
||||
osType → /system/build.prop 存在 ? "android" : "linux"
|
||||
platform → platform.machine() # x86_64 / aarch64
|
||||
kernel → os.uname().release # 6.17.0-35-generic
|
||||
hostname → socket.gethostname()
|
||||
hwRevision → DMI product_name + product_version
|
||||
manufacturer→ DMI sys_vendor / Android ro.product.manufacturer
|
||||
|
||||
Android 额外属性:
|
||||
androidVersion → ro.build.version.release
|
||||
productModel → ro.product.model
|
||||
buildNumber → ro.build.version.incremental
|
||||
|
||||
用户属性文件 (/etc/hawkbit/device_attrs.json):
|
||||
{"dept": "production", "region": "east", "owner": "team-a"}
|
||||
|
||||
版本文件 (/etc/hawkbit/current_version) :
|
||||
V1.0.0.0 → swVersion
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 4: 上报属性
|
||||
═══════════════════════════════════════════════════════════════
|
||||
PUT http://host/DEFAULT/controller/v1/{SN}/configData
|
||||
Body:
|
||||
{
|
||||
"mode": "merge",
|
||||
"data": {
|
||||
"osType": "linux",
|
||||
"platform": "x86_64",
|
||||
"kernel": "6.17.0-35-generic",
|
||||
"hostname": "device-001",
|
||||
"hwRevision": "VMware Virtual Platform",
|
||||
"manufacturer": "VMware, Inc.",
|
||||
"swVersion": "V1.0.0.0",
|
||||
"dept": "production",
|
||||
"region": "east"
|
||||
}
|
||||
}
|
||||
|
||||
mode 说明:
|
||||
"merge" → 只更新传入的 key,保留已有属性 (推荐)
|
||||
"replace" → 完全替换
|
||||
"remove" → 删除传入的 key
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 5: 首次轮询(自动注册)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
GET http://host/DEFAULT/controller/v1/{SN}
|
||||
Hawkbit 行为:
|
||||
findOrRegisterTargetIfItDoesNotExist(SN)
|
||||
→ 不存在 → INSERT INTO target (controller_id, ip_address, ...)
|
||||
→ 存在 → UPDATE last_target_query
|
||||
|
||||
响应:
|
||||
无 action:
|
||||
{"config": {"polling": {"sleep": "00:05:00"}}, "_links": {}}
|
||||
→ sleep 5分钟
|
||||
|
||||
有 deployment:
|
||||
"_links": {
|
||||
"deploymentBase": {"href": "http://.../deploymentBase/1?c=..."},
|
||||
"configData": {"href": "http://.../configData"}
|
||||
}
|
||||
→ 进入 OTA 部署流程
|
||||
|
||||
有 cancel:
|
||||
"_links": {"cancelAction": {"href": "http://.../cancelAction/..."}}
|
||||
→ 进入取消流程
|
||||
|
||||
有 confirmation:
|
||||
"_links": {"confirmationBase": {"href": "http://.../confirmationBase"}}
|
||||
→ 进入确认流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 守护进程轮询流程
|
||||
|
||||
```
|
||||
启动命令:
|
||||
ddi-client.py -u http://192.168.142.128:9090 -t DEFAULT \
|
||||
--gateway-token my-key -i 60
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 守护模式 (daemon=True) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 启动/打印 │ ╔═══════════════════╗ │
|
||||
│ │ banner │ ║ Server: host ║ │
|
||||
│ └────┬─────┘ ║ Tenant: DEFAULT ║ │
|
||||
│ │ ║ Device: <SN> ║ │
|
||||
│ ▼ ║ Auth: Gateway ║ │
|
||||
│ ┌──────────┐ ╚═══════════════════╝ │
|
||||
│ │ 上报属性 │ PUT /configData │
|
||||
│ │(首次) │ (仅一次, 成功即跳过) │
|
||||
│ └────┬─────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 轮询 │ GET /controller/v1/{SN} │
|
||||
│ │ Poll │ │
|
||||
│ └────┬─────┘ │
|
||||
│ │ │
|
||||
│ ┌───┴───┬───────────┬───────────┐ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ deploy cancel confirm 无任务 │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ │ │
|
||||
│ OTA流程 取消流程 确认流程 │ │
|
||||
│ │ │ │ │ │
|
||||
│ └───┴───┴─────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ sleep(N 秒) │ N = max(server建议, │
|
||||
│ │ │ 默认60s) │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ └──────→ 回到轮询 ◄── 循环 │
|
||||
│ │
|
||||
│ 退出: Ctrl+C (KeyboardInterrupt) │
|
||||
│ max_cycles 达到上限 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
轮询间隔策略:
|
||||
- 默认: --polling-interval 60 (或 -i 指定)
|
||||
- 服务端可覆盖: response.config.polling.sleep → "00:05:00" → 5分钟
|
||||
- 有活跃 action 时: 服务端可能缩短间隔 (getPollingTimeForAction)
|
||||
- 轮询失败时: 退避到 min(polling_interval, 30s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 单次轮询流程
|
||||
|
||||
```
|
||||
启动命令:
|
||||
ddi-client.py -u http://host:9090 -t DEFAULT \
|
||||
--gateway-token my-key --once
|
||||
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 单次模式 (daemon=False, --once) │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① 上报属性 (首次) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ② 轮询 GET /controller/v1/{SN} │
|
||||
│ │ │
|
||||
│ ┌───┴───┬───────────┬───────────┐ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ deploy cancel confirm 无任务 │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ │ │
|
||||
│ OTA流程 取消流程 确认流程 │ │
|
||||
│ │ │ │ │ │
|
||||
│ └───┴───┴─────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ③ sys.exit(0) │
|
||||
│ │
|
||||
│ 用途: │
|
||||
│ - systemd timer / cron 定时触发 │
|
||||
│ - 测试/调试 │
|
||||
│ - 脚本化: && 链式调用后续操作 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
cron 示例:
|
||||
*/5 * * * * /usr/bin/python3 /opt/hawkbit/ddi-client.py \
|
||||
-u https://hawkbit:9090 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key --once
|
||||
|
||||
systemd timer 示例:
|
||||
[Unit]
|
||||
Description=Hawkbit DDI poll
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \
|
||||
-u https://hawkbit:9090 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key --once
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=300s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. OTA 部署流程
|
||||
|
||||
```
|
||||
前置条件: 轮询返回 _links.deploymentBase
|
||||
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ OTA 部署完整生命周期 │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step A: 拉取部署详情 │ │
|
||||
│ │ │ │
|
||||
│ │ GET /deploymentBase/{actionId}?c=... │ │
|
||||
│ │ ← { │ │
|
||||
│ │ "id": "2", │ │
|
||||
│ │ "deployment": { │ │
|
||||
│ │ "download": "forced", │ │
|
||||
│ │ "update": "forced", │ │
|
||||
│ │ "chunks": [{ │ │
|
||||
│ │ "part": "os", │ │
|
||||
│ │ "version": "V1.0.0.0", │ │
|
||||
│ │ "name": "r3576-OS-android", │ │
|
||||
│ │ "artifacts": [{ │ │
|
||||
│ │ "filename": "update.zip", │ │
|
||||
│ │ "size": 195411, │ │
|
||||
│ │ "hashes": { │ │
|
||||
│ │ "sha256": "438b8f9e...", │ │
|
||||
│ │ "md5": "28a17f5f...", │ │
|
||||
│ │ "sha1": "223377ea..." │ │
|
||||
│ │ }, │ │
|
||||
│ │ "_links": { │ │
|
||||
│ │ "download-http": { │ │
|
||||
│ │ "href": "http://.../update.zip" │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ │ }] │ │
|
||||
│ │ }] │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step B: 上报 DOWNLOAD (开始下载) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "download", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Starting download"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step C: 下载 artifacts (逐个) │ │
|
||||
│ │ │ │
|
||||
│ │ 对每个 chunk.artifacts[]: │ │
|
||||
│ │ │ │
|
||||
│ │ ① 提取 download URL (优先级) │ │
|
||||
│ │ download (HTTPS) > download-http (HTTP) │ │
|
||||
│ │ 失败 → fallback: 遍历所有 HAL link 提取 smId │ │
|
||||
│ │ │ │
|
||||
│ │ ② 检查本地缓存 /tmp/hawkbit/{filename} │ │
|
||||
│ │ 存在 + SHA256 匹配 → 跳过下载 │ │
|
||||
│ │ 存在 + SHA256 不匹配 → 重新下载 │ │
|
||||
│ │ │ │
|
||||
│ │ ③ 检查 .tmp 断点文件 │ │
|
||||
│ │ 存在 → 加 Range: bytes={size}- 头续传 │ │
|
||||
│ │ │ │
|
||||
│ │ ④ GET /softwaremodules/{id}/artifacts/{filename} │ │
|
||||
│ │ 分块读取 64KB, 写入 .tmp │ │
|
||||
│ │ 打印下载进度百分比 │ │
|
||||
│ │ │ │
|
||||
│ │ ⑤ SHA256 校验 .tmp 文件 │ │
|
||||
│ │ 成功 → rename .tmp → 最终文件 │ │
|
||||
│ │ 失败 → 返回 None │ │
|
||||
│ │ │ │
|
||||
│ │ ⑥ 上报 PROCEEDING (每 artifact) │ │
|
||||
│ │ execution=proceeding, progress={cnt:1, of:1} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step D: 上报 DOWNLOADED (全部下载完成) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "downloaded", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Downloaded 1 artifact(s)"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step E: 上报 PROCEEDING (开始安装) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "proceeding", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Installing update …"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step F: 执行安装 _install(files, handling) │ │
|
||||
│ │ │ │
|
||||
│ │ 默认行为: 检测到 *.zip 文件 → 返回 True │ │
|
||||
│ │ 生产: 覆盖此方法执行实际刷写 │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────┴────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ 成功 失败 │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Step G: 上报 CLOSED (最终) │ │
|
||||
│ │ │ │
|
||||
│ │ 成功: │ │
|
||||
│ │ execution=closed │ │
|
||||
│ │ result.finished=success │ │
|
||||
│ │ code=200 │ │
|
||||
│ │ │ │
|
||||
│ │ 失败: │ │
|
||||
│ │ execution=closed │ │
|
||||
│ │ result.finished=failure │ │
|
||||
│ │ code=500 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ → 返回主循环 sleep → 下次轮询 │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Hawkbit Action Status 映射:
|
||||
execution=download → Status.DOWNLOAD
|
||||
execution=downloaded → Status.DOWNLOADED
|
||||
execution=proceeding → Status.RUNNING
|
||||
execution=closed → result.success → Status.FINISHED
|
||||
→ result.failure → Status.ERROR
|
||||
execution=canceled → Status.CANCELED
|
||||
execution=rejected → Status.WARNING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 认证流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 认证方式选择 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① GatewayToken (推荐,所有设备共享) │
|
||||
│ ────────────────────────────────────── │
|
||||
│ 服务端配置: │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.gatewaytoken.key \│
|
||||
│ -d '{"value": "my-shared-key"}' │
|
||||
│ │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.gatewaytoken.enabled \│
|
||||
│ -d '{"value": true}' │
|
||||
│ │
|
||||
│ 客户端: │
|
||||
│ 方式 A: 命令行参数 │
|
||||
│ --gateway-token my-shared-key │
|
||||
│ │
|
||||
│ 方式 B: 从文件读取 │
|
||||
│ echo "my-shared-key" > /etc/hawkbit/gateway.key │
|
||||
│ chmod 600 /etc/hawkbit/gateway.key │
|
||||
│ --gateway-token @/etc/hawkbit/gateway.key │
|
||||
│ │
|
||||
│ HTTP: Authorization: GatewayToken my-shared-key │
|
||||
│ │
|
||||
│ │
|
||||
│ ② TargetToken (每设备独立令牌) │
|
||||
│ ──────────────────────────────── │
|
||||
│ 服务端配置: │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.targettoken.enabled \│
|
||||
│ -d '{"value": true}' │
|
||||
│ │
|
||||
│ # 每个 target 的 securityToken 自动生成或手动设置 │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../targets/{controllerId} \ │
|
||||
│ -d '{"securityToken": "device-unique-token"}' │
|
||||
│ │
|
||||
│ 客户端: │
|
||||
│ --target-token device-unique-token │
|
||||
│ 或 --target-token @/etc/hawkbit/target.token │
|
||||
│ │
|
||||
│ HTTP: Authorization: TargetToken device-unique-token │
|
||||
│ │
|
||||
│ │
|
||||
│ ③ SecurityHeader (反向代理模式, 脚本不支持) │
|
||||
│ ────────────────────────────────────────── │
|
||||
│ 适用于 nginx/Envoy 前置认证场景 │
|
||||
│ │
|
||||
│ │
|
||||
│ 优先级 (客户端): --gateway-token > --target-token │
|
||||
│ 无认证: 服务器返回 401 │
|
||||
│ 验证失败: 服务器返回 401 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 取消任务流程
|
||||
|
||||
```
|
||||
前置条件: 轮询返回 _links.cancelAction
|
||||
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ 取消任务处理 │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① GET /cancelAction/{actionId} │
|
||||
│ ← {"id": "11", │
|
||||
│ "cancelAction": {"stopId": "11"}} │
|
||||
│ │
|
||||
│ ② POST /cancelAction/{actionId}/feedback │
|
||||
│ Body: │
|
||||
│ {"status": { │
|
||||
│ "execution": "canceled", │
|
||||
│ "result": {"finished": "none"}, │
|
||||
│ "details": ["Cancel acknowledged"] │
|
||||
│ }} │
|
||||
│ │
|
||||
│ → 回到主循环 sleep → 下次轮询 │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 确认任务流程
|
||||
|
||||
```
|
||||
前置条件: action 状态为 WAITING_FOR_CONFIRMATION
|
||||
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ 确认任务处理 (headless) │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Headless 设备自动确认,不等待人工: │
|
||||
│ │
|
||||
│ POST /confirmationBase/{actionId}/feedback │
|
||||
│ Body: │
|
||||
│ {"confirmation": "confirmed", │
|
||||
│ "code": 200, │
|
||||
│ "details": ["Auto-confirmed by DDI client"]} │
|
||||
│ │
|
||||
│ → Action 状态从 WAITING_FOR_CONFIRMATION │
|
||||
│ 转为 RUNNING │
|
||||
│ → 下次轮询拿到 deploymentBase 链接 │
|
||||
│ → 进入正常 OTA 流程 │
|
||||
│ │
|
||||
│ 如果 autoConfirm.active=true: │
|
||||
│ 下次轮询时服务端直接返回 deploymentBase │
|
||||
│ 跳过确认步骤 │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 断点续传流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 断点续传 (Range 请求) │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 触发条件: --no-resume 未设置 (默认启用) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 下载 {filename} 之前 │ │
|
||||
│ │ │ │
|
||||
│ │ ① 检查 /tmp/hawkbit/{filename} │ │
|
||||
│ │ 存在 + SHA256 匹配 │ │
|
||||
│ │ → 跳过下载,直接返回本地路径 │ │
|
||||
│ │ │ │
|
||||
│ │ ② 检查 /tmp/hawkbit/{filename}.tmp │ │
|
||||
│ │ 存在 → os.path.getsize(tmp_path) = 1048576 │ │
|
||||
│ │ 添加头: Range: bytes=1048576- │ │
|
||||
│ │ 打开模式: "ab" (追加) │ │
|
||||
│ │ │ │
|
||||
│ │ ③ 文件不存在 │ │
|
||||
│ │ 无 Range 头 │ │
|
||||
│ │ 打开模式: "wb" (新建) │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ GET /softwaremodules/{id}/artifacts/{filename} │ │
|
||||
│ │ Range: bytes=1048576- │ │
|
||||
│ │ ← 206 Partial Content │ │
|
||||
│ │ Content-Range: bytes 1048576-195410/195411 │ │
|
||||
│ │ (服务端从 offset 开始发送) │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 分块写入 + 进度报告 │ │
|
||||
│ │ │ │
|
||||
│ │ while chunk = resp.read(64KB): │ │
|
||||
│ │ fh.write(chunk) │ │
|
||||
│ │ downloaded += len(chunk) │ │
|
||||
│ │ pct = downloaded / total * 100 │ │
|
||||
│ │ │ │
|
||||
│ │ 中断 (Exception / 网络断): │ │
|
||||
│ │ .tmp 文件保留 │ │
|
||||
│ │ 下次启动时自动续传 │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ SHA256 校验通过 │ │
|
||||
│ │ → os.rename(tmp_path, local_path) 原子重命名 │ │
|
||||
│ │ │ │
|
||||
│ │ SHA256 校验失败 │ │
|
||||
│ │ → 日志记录 mismatch │ │
|
||||
│ │ → 返回 None │ │
|
||||
│ │ → 调用方删除 .tmp 或手动恢复 │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 禁用续传: │
|
||||
│ --no-resume → 每次都从头下载 │
|
||||
│ → 每次 .tmp 从 0 开始 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 错误处理流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 错误处理矩阵 │
|
||||
├───────────────┬───────────────────────┬───────────────────────┤
|
||||
│ 场景 │ 行为 │ 恢复 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 轮询 HTTP 错误 │ 日志记录,退避 sleep │ 下次轮询自动重试 │
|
||||
│ (network/500) │ min(interval, 30s) │ │
|
||||
│ │ 守护模式继续 │ │
|
||||
│ │ --once: exit(1) │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 获取部署详情 │ send_feedback(closed, │ 下次轮询后 action │
|
||||
│ 失败 │ failure, "Cannot │ 可能还在 pending │
|
||||
│ │ fetch deployment") │ 状态,重新处理 │
|
||||
│ │ return False │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 下载失败 │ send_feedback(closed, │ .tmp 保留 │
|
||||
│ (HTTP error) │ failure, "Download │ 下次轮询自动续传 │
|
||||
│ │ failed for xxx") │ │
|
||||
│ │ return False │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ SHA256 不匹配 │ 日志 error │ .tmp 保留,可手动删除 │
|
||||
│ │ "SHA256 MISMATCH" │ 下次重新下载 │
|
||||
│ │ return None │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 下载中断 │ 日志 error │ .tmp 保留 │
|
||||
│ (Exception) │ "Download interrupted" │ 下次自动续传 │
|
||||
│ │ return None │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 安装失败 │ send_feedback(closed, │ 下次轮询后 action │
|
||||
│ (_install 返 │ failure, code=500) │ 状态 → ERROR │
|
||||
│ 回 False) │ return False │ 管理端可见 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ SSL 证书错误 │ 捕获后当作普通 HTTP 错误 │ --no-ssl-verify │
|
||||
│ │ │ 跳过证书验证(不安全) │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 属性上报失败 │ 日志 error │ _attrs_reported=False │
|
||||
│ │ 不影响轮询继续 │ 下次轮询自动重试 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ Ctrl+C 中断 │ 日志 "Stopped" │ return True │
|
||||
│ │ sys.exit(0) │ │
|
||||
└───────────────┴───────────────────────┴───────────────────────┘
|
||||
|
||||
日志级别:
|
||||
DEBUG - HTTP 请求/响应详情, 下载进度, 属性内容 (--verbose / -v)
|
||||
INFO - 状态变化, action 处理, 睡眠倒计时 (默认)
|
||||
WARNING- 非致命错误 (poll 失败, 缓存失效, 无认证) (默认)
|
||||
ERROR - 致命错误 (下载失败, SHA256 不匹配, HTTP 错误) (默认)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 安装钩子定制
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ _install() 钩子定制 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 默认行为: │
|
||||
│ 检测下载文件列表中是否有 *.zip 文件 → 返回 True │
|
||||
│ (适合测试,不做实际操作) │
|
||||
│ │
|
||||
│ 输入: │
|
||||
│ files: List[str] → ["/tmp/hawkbit/update.zip"] │
|
||||
│ handling: dict → {"download":"forced", │
|
||||
│ "update":"forced", │
|
||||
│ "maintenanceWindow":null} │
|
||||
│ │
|
||||
│ 返回: │
|
||||
│ True → send_feedback(closed, success) │
|
||||
│ False → send_feedback(closed, failure) │
|
||||
│ │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 方式 A: 子类继承 │
|
||||
│ ───────────────── │
|
||||
│ │
|
||||
│ # my_client.py │
|
||||
│ from ddi_client import HawkbitDDIClient │
|
||||
│ │
|
||||
│ class AndroidOTAClient(HawkbitDDIClient): │
|
||||
│ def _install(self, files, handling): │
|
||||
│ import subprocess │
|
||||
│ for fp in files: │
|
||||
│ if fp.endswith('.zip'): │
|
||||
│ # 写 recovery 命令 │
|
||||
│ subprocess.run([ │
|
||||
│ 'update_engine_client', │
|
||||
│ '--update', │
|
||||
│ f'file://{fp}' │
|
||||
│ ], check=True) │
|
||||
│ # 写版本号 │
|
||||
│ with open('/etc/hawkbit/current_version', │
|
||||
│ 'w') as f: │
|
||||
│ f.write('V2.0.0.0') │
|
||||
│ return True │
|
||||
│ │
|
||||
│ if __name__ == '__main__': │
|
||||
│ # 用自定义子类替换 CLI 入口 │
|
||||
│ client = AndroidOTAClient(...) │
|
||||
│ client.run() │
|
||||
│ │
|
||||
│ │
|
||||
│ 方式 B: Monkey-Patch (快速原型) │
|
||||
│ ───────────────────────────── │
|
||||
│ │
|
||||
│ client = HawkbitDDIClient(...) │
|
||||
│ original_install = client._install │
|
||||
│ def my_install(files, handling): │
|
||||
│ result = subprocess.run(['unzip', '-o', files[0], │
|
||||
│ '-d', '/data/ota']) │
|
||||
│ with open('/etc/hawkbit/current_version','w') as f: │
|
||||
│ f.write('V2.0.0.0') │
|
||||
│ return result.returncode == 0 │
|
||||
│ client._install = my_install │
|
||||
│ client.run() │
|
||||
│ │
|
||||
│ │
|
||||
│ 方式 C: 简单模式 (不需要 Python 继承) │
|
||||
│ ─────────────────────────────────── │
|
||||
│ │
|
||||
│ 创建安装脚本: /etc/hawkbit/install.sh │
|
||||
│ │
|
||||
│ #!/bin/sh │
|
||||
│ unzip -o "$1" -d /data/ota │
|
||||
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
|
||||
│ exit $? │
|
||||
│ │
|
||||
│ 修改 _install: │
|
||||
│ def _install(self, files, handling): │
|
||||
│ for fp in files: │
|
||||
│ if fp.endswith('.zip'): │
|
||||
│ import subprocess │
|
||||
│ result = subprocess.run( │
|
||||
│ ['/etc/hawkbit/install.sh', fp]) │
|
||||
│ return result.returncode == 0 │
|
||||
│ return True │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 部署与运维
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 部署方案 │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Android 设备部署: │
|
||||
│ ──────────────── │
|
||||
│ │
|
||||
│ # 1. 推送脚本 │
|
||||
│ adb push ddi-client.py /data/local/tmp/ │
|
||||
│ │
|
||||
│ # 2. 创建 token 文件 │
|
||||
│ adb shell "echo 'my-gateway-key' > /data/local/tmp/gateway.key" │
|
||||
│ adb shell "chmod 600 /data/local/tmp/gateway.key" │
|
||||
│ │
|
||||
│ # 3. 创建属性文件 (可选) │
|
||||
│ adb push device_attrs.json /data/local/tmp/ │
|
||||
│ adb shell "mkdir -p /etc/hawkbit" │
|
||||
│ adb shell "cp /data/local/tmp/device_attrs.json /etc/hawkbit/" │
|
||||
│ │
|
||||
│ # 4. 运行 │
|
||||
│ adb shell "python3 /data/local/tmp/ddi-client.py \ │
|
||||
│ -u https://hawkbit:9090 -t DEFAULT \ │
|
||||
│ --gateway-token @/data/local/tmp/gateway.key \ │
|
||||
│ -d /data/local/tmp/hawkbit" │
|
||||
│ │
|
||||
│ │
|
||||
│ Linux 设备部署 (systemd): │
|
||||
│ ───────────────────────── │
|
||||
│ │
|
||||
│ # /opt/hawkbit/ddi-client.py │
|
||||
│ # /etc/hawkbit/gateway.key │
|
||||
│ # /etc/hawkbit/device_attrs.json │
|
||||
│ │
|
||||
│ # /etc/systemd/system/hawkbit-ddi.service │
|
||||
│ [Unit] │
|
||||
│ Description=Hawkbit DDI Client │
|
||||
│ After=network-online.target │
|
||||
│ Wants=network-online.target │
|
||||
│ │
|
||||
│ [Service] │
|
||||
│ Type=simple │
|
||||
│ ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \ │
|
||||
│ -u https://hawkbit:9090 -t DEFAULT \ │
|
||||
│ --gateway-token @/etc/hawkbit/gateway.key \ │
|
||||
│ -d /var/cache/hawkbit -i 30 │
|
||||
│ Restart=always │
|
||||
│ RestartSec=30 │
|
||||
│ User=root │
|
||||
│ │
|
||||
│ [Install] │
|
||||
│ WantedBy=multi-user.target │
|
||||
│ │
|
||||
│ systemctl enable --now hawkbit-ddi │
|
||||
│ │
|
||||
│ │
|
||||
│ 版本管理: │
|
||||
│ ──────── │
|
||||
│ │
|
||||
│ 安装成功后写入版本号: │
|
||||
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
|
||||
│ │
|
||||
│ 下次轮询时属性中自动包含 swVersion=V2.0.0.0 │
|
||||
│ 管理员可在 Hawkbit 中按版本筛选设备 │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: HTTP 请求/响应速查
|
||||
|
||||
| 操作 | 方法 | 路径 | Content-Type |
|
||||
|------|------|------|-------------|
|
||||
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | application/hal+json |
|
||||
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | application/hal+json |
|
||||
| 上报反馈 | POST | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback` | application/json |
|
||||
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | application/octet-stream |
|
||||
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | application/json |
|
||||
| 取消反馈 | POST | `/{tenant}/controller/v1/{controllerId}/cancelAction/{id}/feedback` | application/json |
|
||||
| 确认反馈 | POST | `/{tenant}/controller/v1/{controllerId}/confirmationBase/{id}/feedback` | application/json |
|
||||
|
||||
## 附录 B: 命令行参数完整列表
|
||||
|
||||
```
|
||||
-u, --base-url Hawkbit 服务端地址 (必填)
|
||||
-t, --tenant 租户名称 (必填)
|
||||
--gateway-token Gateway Token 或 @文件路径
|
||||
--target-token Target Token 或 @文件路径
|
||||
--controller-id 控制器ID (默认自动检测SN)
|
||||
-d, --download-dir 下载目录 (/tmp/hawkbit)
|
||||
-i, --polling-interval 轮询间隔秒数 (60)
|
||||
--no-verify 跳过 SHA256 校验
|
||||
--no-resume 禁用断点续传
|
||||
--no-ssl-verify 跳过 TLS 证书验证
|
||||
--once 单次轮询后退出
|
||||
-v, --verbose 调试日志
|
||||
-h, --help 帮助
|
||||
```
|
||||
|
||||
## 附录 C: 设备属性文件格式
|
||||
|
||||
```json
|
||||
# /etc/hawkbit/device_attrs.json
|
||||
{
|
||||
"dept": "production",
|
||||
"region": "east-1",
|
||||
"owner": "team-a",
|
||||
"rack": "A12",
|
||||
"environment": "staging"
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 D: 状态流转图 (Hawkbit 视角)
|
||||
|
||||
```
|
||||
assignment
|
||||
│
|
||||
▼
|
||||
READY ──→ RETRIEVED ──→ DOWNLOAD ──→ DOWNLOADED ──→ RUNNING ──→ FINISHED
|
||||
│ │ │ (成功)
|
||||
│ │ │
|
||||
│ │ └──→ ERROR
|
||||
│ │ (失败)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
CANCELING ──→ CANCELED
|
||||
│
|
||||
CANCEL_REJECTED
|
||||
|
||||
WAITING_FOR_CONFIRMATION ──confirmed──→ RUNNING → ...
|
||||
│
|
||||
└──denied──→ DENIED
|
||||
```
|
||||
BIN
hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc
Normal file
BIN
hawkbit-compose/__pycache__/ddi-client.cpython-312.pyc
Normal file
Binary file not shown.
844
hawkbit-compose/ddi-client.py
Executable file
844
hawkbit-compose/ddi-client.py
Executable file
@@ -0,0 +1,844 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hawkbit DDI Client — lightweight pure-Python OTA update agent.
|
||||
=================================================================
|
||||
No dependencies beyond Python 3 standard library.
|
||||
Runs on Android (adb shell) and Linux hosts.
|
||||
|
||||
DDI API version: v1
|
||||
Ref: https://github.com/eclipse-hawkbit/hawkbit
|
||||
|
||||
Capabilities:
|
||||
• Poll Hawkbit for deployment actions
|
||||
• Resumable (Range-request) artifact download
|
||||
• SHA256 file integrity verification
|
||||
• Status progression: DOWNLOAD → DOWNLOADED → PROCEEDING → FINISHED / FAILURE
|
||||
• Auto-detects device serial number as controllerId (Android ro.serialno, DMI, machine-id)
|
||||
• Reports device attributes (osType, platform, hwRevision, swVersion, kernel…) to Hawkbit
|
||||
• Authentication: GatewayToken or TargetToken (also from file: --gateway-token @/path/to/key)
|
||||
|
||||
Quick start:
|
||||
# Gateway token
|
||||
ddi-client.py --base-url https://hawkbit:8443 --tenant DEFAULT --gateway-token mykey
|
||||
|
||||
# Target token (per-device)
|
||||
ddi-client.py --base-url http://10.0.0.1:8080 --tenant DEFAULT --target-token abc123
|
||||
|
||||
# One-shot poll
|
||||
ddi-client.py --base-url http://localhost:8080 --tenant DEFAULT --gateway-token k --once
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ── logging ─────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("hawkbit-ddi")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# HawkbitDDIClient
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
class HawkbitDDIClient:
|
||||
"""Lightweight DDI (Direct Device Integration) client for Eclipse Hawkbit."""
|
||||
|
||||
# DDI execution statuses (mapped to Hawkbit action states)
|
||||
EXEC_DOWNLOAD = "download"
|
||||
EXEC_DOWNLOADED = "downloaded"
|
||||
EXEC_PROCEEDING = "proceeding"
|
||||
EXEC_CLOSED = "closed"
|
||||
EXEC_CANCELED = "canceled"
|
||||
EXEC_REJECTED = "rejected"
|
||||
EXEC_SCHEDULED = "scheduled"
|
||||
EXEC_RESUMED = "resumed"
|
||||
|
||||
RESULT_SUCCESS = "success"
|
||||
RESULT_FAILURE = "failure"
|
||||
RESULT_NONE = "none"
|
||||
|
||||
# ── constructor ──────────────────────────────────────────────────────
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
tenant: str,
|
||||
controller_id: Optional[str] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
download_dir: str = "/tmp/hawkbit",
|
||||
verify_sha256: bool = True,
|
||||
polling_interval: int = 60,
|
||||
ssl_verify: bool = True,
|
||||
resume_download: bool = True,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.tenant = tenant
|
||||
self.controller_id = controller_id or self._detect_device_sn()
|
||||
self.auth_header = auth_header
|
||||
self.download_dir = download_dir
|
||||
self.verify_sha256 = verify_sha256
|
||||
self.polling_interval = polling_interval
|
||||
self.ssl_verify = ssl_verify
|
||||
self.resume_download = resume_download
|
||||
self._attrs_reported = False
|
||||
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
# ── device serial detection ───────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _detect_device_sn() -> str:
|
||||
"""Best-effort device serial number detection.
|
||||
|
||||
Priority: Android ro.serialno → DMI product_serial → /etc/machine-id → hostname
|
||||
"""
|
||||
# 1. Android property
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["getprop", "ro.serialno"],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
sn = out.stdout.strip()
|
||||
if sn:
|
||||
logger.info("Detected Android serial: %s", sn)
|
||||
return sn
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# 2. Linux DMI product serial
|
||||
for p in ("/sys/class/dmi/id/product_serial",
|
||||
"/sys/devices/virtual/dmi/id/product_serial"):
|
||||
try:
|
||||
with open(p) as fh:
|
||||
sn = fh.read().strip()
|
||||
if sn and sn not in ("0", "Not Specified", "None", ""):
|
||||
logger.info("Detected DMI serial: %s", sn)
|
||||
return sn
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# 3. /etc/machine-id (systemd)
|
||||
try:
|
||||
with open("/etc/machine-id") as fh:
|
||||
mid = fh.read().strip()
|
||||
if mid:
|
||||
logger.info("Using machine-id: %s", mid)
|
||||
return mid
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# 4. Fallback — hostname
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
logger.warning("No serial found; falling back to hostname: %s", hostname)
|
||||
return hostname
|
||||
|
||||
# ── device attributes detection ─────────────────────────────────────
|
||||
@staticmethod
|
||||
def _detect_device_attrs() -> Dict[str, str]:
|
||||
"""Collect device metadata for Hawkbit target attributes.
|
||||
|
||||
These attributes are used by Hawkbit target filters to auto-assign updates.
|
||||
Returns a dict of key-value pairs.
|
||||
"""
|
||||
attrs: Dict[str, str] = {}
|
||||
|
||||
# ── OS type ─────────────────────────────────────────────────
|
||||
attrs["hostname"] = HawkbitDDIClient._safe_hostname()
|
||||
|
||||
if os.path.exists("/system/build.prop"):
|
||||
attrs["osType"] = "android"
|
||||
else:
|
||||
attrs["osType"] = "linux"
|
||||
|
||||
# ── platform / architecture ─────────────────────────────────
|
||||
try:
|
||||
import platform
|
||||
attrs["platform"] = platform.machine()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── kernel version ──────────────────────────────────────────
|
||||
try:
|
||||
attrs["kernel"] = os.uname().release
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Android: detailed attributes ────────────────────────────
|
||||
if attrs.get("osType") == "android":
|
||||
for prop, attr_key in (
|
||||
("ro.build.version.release", "androidVersion"),
|
||||
("ro.product.model", "productModel"),
|
||||
("ro.product.manufacturer", "manufacturer"),
|
||||
("ro.build.version.incremental", "buildNumber"),
|
||||
("ro.build.fingerprint", "buildFingerprint"),
|
||||
):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["getprop", prop], capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
val = out.stdout.strip()
|
||||
if val:
|
||||
attrs[attr_key] = val
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# ── Linux: DMI hardware info ────────────────────────────────
|
||||
if attrs.get("osType") == "linux":
|
||||
for path, attr_key in (
|
||||
("/sys/class/dmi/id/product_name", "productName"),
|
||||
("/sys/class/dmi/id/product_version", "productVersion"),
|
||||
("/sys/class/dmi/id/product_sku", "productSku"),
|
||||
("/sys/class/dmi/id/sys_vendor", "manufacturer"),
|
||||
):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
val = fh.read().strip()
|
||||
if val and val not in ("0", "Not Specified", "None", "", "System Product Name"):
|
||||
attrs[attr_key] = val
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# hwRevision: combine product name + version
|
||||
if "productName" in attrs:
|
||||
rev = attrs["productName"]
|
||||
if "productVersion" in attrs:
|
||||
rev += " " + attrs["productVersion"]
|
||||
attrs["hwRevision"] = rev
|
||||
|
||||
# ── current software version (if installed) ─────────────────
|
||||
version_file = "/etc/hawkbit/current_version"
|
||||
try:
|
||||
with open(version_file) as fh:
|
||||
v = fh.read().strip()
|
||||
if v:
|
||||
attrs["swVersion"] = v
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# ── merge user-supplied attrs from file ─────────────────────
|
||||
attrs_file = "/etc/hawkbit/device_attrs.json"
|
||||
try:
|
||||
with open(attrs_file) as fh:
|
||||
extra = json.load(fh)
|
||||
if isinstance(extra, dict):
|
||||
attrs.update({k: str(v) for k, v in extra.items()})
|
||||
except (FileNotFoundError, PermissionError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return attrs
|
||||
|
||||
@staticmethod
|
||||
def _safe_hostname() -> str:
|
||||
try:
|
||||
import socket
|
||||
return socket.gethostname()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
# ── report device attributes to Hawkbit ──────────────────────────────
|
||||
def report_attributes(self) -> bool:
|
||||
"""PUT /{tenant}/controller/v1/{controllerId}/configData
|
||||
|
||||
Reports device metadata so Hawkbit can use target filters to assign updates.
|
||||
Uses ``mode: merge`` — only updates the keys sent, preserves existing attributes.
|
||||
"""
|
||||
attrs = self._detect_device_attrs()
|
||||
url = self._ddi_url(f"{self.controller_id}/configData")
|
||||
payload = {"mode": "merge", "data": attrs}
|
||||
logger.info("Reporting %d device attributes → %s", len(attrs), url)
|
||||
logger.debug("Attributes: %s", json.dumps(attrs))
|
||||
resp = self._http(url, method="PUT", data=payload)
|
||||
return resp is not None
|
||||
def _ddi_url(self, path: str) -> str:
|
||||
"""Build a DDI v1 URL: <base>/<tenant>/controller/v1/<path>"""
|
||||
return f"{self.base_url}/{self.tenant}/controller/v1/{path}"
|
||||
|
||||
# ── low-level HTTP ────────────────────────────────────────────────────
|
||||
def _http(
|
||||
self,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
data: Any = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
stream: bool = False,
|
||||
):
|
||||
"""Make an HTTP request. Returns decoded JSON, or raw file handle when
|
||||
``stream=True``. Returns ``None`` on HTTP errors."""
|
||||
req_headers: Dict[str, str] = {"Accept": "application/hal+json, application/json"}
|
||||
if self.auth_header:
|
||||
req_headers["Authorization"] = self.auth_header
|
||||
if data is not None:
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
body = None if data is None else json.dumps(data).encode("utf-8")
|
||||
|
||||
ctx = None
|
||||
if not self.ssl_verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
except urllib.error.HTTPError as exc:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
logger.error("HTTP %d %s %s → %s", exc.code, method, url, err_body)
|
||||
return None
|
||||
|
||||
if stream:
|
||||
return resp
|
||||
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
# ── helper: extract best download link from artifact HAL links ─────────
|
||||
@staticmethod
|
||||
def _pick_download_url(artifact: dict) -> Optional[str]:
|
||||
"""Pick the preferred download URL from the artifact's _links.
|
||||
|
||||
Priority: download (HTTPS) → download-http (HTTP) → construct from filename
|
||||
"""
|
||||
links = artifact.get("_links", {})
|
||||
if not isinstance(links, dict):
|
||||
return None
|
||||
|
||||
# HAL links are keyed by rel; values can be a single object or a list.
|
||||
for rel in ("download", "download-http"):
|
||||
entry = links.get(rel)
|
||||
if isinstance(entry, list):
|
||||
for item in entry:
|
||||
if isinstance(item, dict) and "href" in item:
|
||||
return item["href"]
|
||||
elif isinstance(entry, dict) and "href" in entry:
|
||||
return entry["href"]
|
||||
return None
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Public API
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── poll ──────────────────────────────────────────────────────────────
|
||||
def poll(self) -> Optional[dict]:
|
||||
"""GET /{tenant}/controller/v1/{controllerId}
|
||||
|
||||
Returns parsed controller base or None on failure.
|
||||
"""
|
||||
url = self._ddi_url(self.controller_id)
|
||||
logger.debug("Polling GET %s", url)
|
||||
body = self._http(url)
|
||||
if body is None:
|
||||
return None
|
||||
return self._parse_poll(body)
|
||||
|
||||
def _parse_poll(self, body: dict) -> dict:
|
||||
result: Dict[str, Any] = {
|
||||
"config": body.get("config", {}),
|
||||
"links": {},
|
||||
}
|
||||
|
||||
links = body.get("_links", {})
|
||||
if isinstance(links, dict):
|
||||
for rel in ("deploymentBase", "cancelAction",
|
||||
"confirmationBase", "installedBase", "configData"):
|
||||
entry = links.get(rel)
|
||||
href = None
|
||||
if isinstance(entry, dict):
|
||||
href = entry.get("href")
|
||||
elif isinstance(entry, list) and entry:
|
||||
href = entry[0].get("href") if isinstance(entry[0], dict) else None
|
||||
if href:
|
||||
result["links"][rel] = href
|
||||
|
||||
# Adopt server-suggested polling interval
|
||||
sleep_str = body.get("config", {}).get("polling", {}).get("sleep", "")
|
||||
if sleep_str:
|
||||
try:
|
||||
h, m, s = (int(x) for x in sleep_str.split(":"))
|
||||
seconds = h * 3600 + m * 60 + s
|
||||
if seconds > 0:
|
||||
self.polling_interval = seconds
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
# ── get deployment base ───────────────────────────────────────────────
|
||||
def get_deployment(self, deployment_url: str) -> Optional[dict]:
|
||||
"""GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
|
||||
|
||||
Returns structured deployment info or None.
|
||||
"""
|
||||
logger.debug("Fetching deployment: %s", deployment_url)
|
||||
body = self._http(deployment_url)
|
||||
if body is None:
|
||||
return None
|
||||
return self._parse_deployment(body)
|
||||
|
||||
def _parse_deployment(self, body: dict) -> dict:
|
||||
info = body.get("deployment", {})
|
||||
|
||||
chunks = []
|
||||
for ch in info.get("chunks", []):
|
||||
artifacts = []
|
||||
for art in ch.get("artifacts", []):
|
||||
artifacts.append({
|
||||
"filename": art["filename"],
|
||||
"size": art.get("size", 0),
|
||||
"hashes": art.get("hashes", {}),
|
||||
"_links": art.get("_links", {}),
|
||||
})
|
||||
chunks.append({
|
||||
"part": ch.get("part", ""),
|
||||
"version": ch.get("version", ""),
|
||||
"name": ch.get("name", ""),
|
||||
"artifacts": artifacts,
|
||||
})
|
||||
|
||||
return {
|
||||
"action_id": body.get("id"),
|
||||
"chunks": chunks,
|
||||
"handling": {
|
||||
"download": info.get("download", "attempt"),
|
||||
"update": info.get("update", "attempt"),
|
||||
"maintenanceWindow": info.get("maintenanceWindow"),
|
||||
},
|
||||
}
|
||||
|
||||
# ── download artifact ─────────────────────────────────────────────────
|
||||
def download_artifact(self, artifact: dict, action_id: str = "") -> Optional[str]:
|
||||
"""Download one artifact with Range-request resume and SHA256 verification.
|
||||
|
||||
Returns the local file path on success, or None on failure.
|
||||
"""
|
||||
filename = artifact["filename"]
|
||||
expected_sha256 = artifact.get("hashes", {}).get("sha256")
|
||||
expected_size = artifact.get("size", 0)
|
||||
|
||||
# Resolve the download URL
|
||||
download_url = self._pick_download_url(artifact)
|
||||
if not download_url:
|
||||
# Fallback — construct from DDI path
|
||||
# Try to extract smId from any artifact HAL link
|
||||
logger.warning("No download link in artifact — using fallback URL")
|
||||
import re
|
||||
sm_id = "0"
|
||||
links = artifact.get("_links", {})
|
||||
for entry in links.values():
|
||||
href = None
|
||||
if isinstance(entry, dict) and "href" in entry:
|
||||
href = entry["href"]
|
||||
elif isinstance(entry, list):
|
||||
for item in entry:
|
||||
if isinstance(item, dict) and "href" in item:
|
||||
href = item["href"]
|
||||
break
|
||||
if href:
|
||||
m = re.search(r"/softwaremodules/(\d+)/", href)
|
||||
if m:
|
||||
sm_id = m.group(1)
|
||||
break
|
||||
download_url = self._ddi_url(
|
||||
f"{self.controller_id}/softwaremodules/{sm_id}/artifacts/{filename}"
|
||||
)
|
||||
|
||||
local_path = os.path.join(self.download_dir, filename)
|
||||
tmp_path = local_path + ".tmp"
|
||||
|
||||
# Already downloaded and verified?
|
||||
if os.path.exists(local_path):
|
||||
if self._sha256_check(local_path, expected_sha256):
|
||||
logger.info("Already cached & verified: %s", local_path)
|
||||
return local_path
|
||||
logger.warning("Cached file fails SHA256 — re-downloading")
|
||||
|
||||
# ── resume ────────────────────────────────────────────────────
|
||||
downloaded = 0
|
||||
if self.resume_download and os.path.exists(tmp_path):
|
||||
downloaded = os.path.getsize(tmp_path)
|
||||
logger.info("Resuming from byte %d", downloaded)
|
||||
|
||||
req_headers: Dict[str, str] = {}
|
||||
if downloaded > 0:
|
||||
req_headers["Range"] = f"bytes={downloaded}-"
|
||||
|
||||
logger.info("Downloading %s (%s bytes) → %s",
|
||||
filename, expected_size or "?", tmp_path)
|
||||
|
||||
resp = self._http(download_url, headers=req_headers, stream=True)
|
||||
if resp is None:
|
||||
logger.error("Download request failed")
|
||||
return None
|
||||
|
||||
# Determine total size (from Content-Range header)
|
||||
total = expected_size
|
||||
cr = resp.headers.get("Content-Range", "")
|
||||
if cr:
|
||||
try:
|
||||
total = int(cr.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
mode = "ab" if downloaded > 0 else "wb"
|
||||
try:
|
||||
with open(tmp_path, mode) as fh:
|
||||
while True:
|
||||
chunk = resp.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
pct = downloaded / total * 100
|
||||
logger.debug(" %6.1f %% (%d / %d)", pct, downloaded, total)
|
||||
except Exception as exc:
|
||||
logger.error("Download interrupted: %s (tmp kept for resume)", exc)
|
||||
return None
|
||||
|
||||
logger.info("Download finished — %d bytes", downloaded)
|
||||
|
||||
# SHA256
|
||||
if self.verify_sha256 and expected_sha256:
|
||||
if not self._sha256_check(tmp_path, expected_sha256):
|
||||
logger.error("SHA256 MISMATCH for %s", filename)
|
||||
return None
|
||||
|
||||
# Atomic rename
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
os.rename(tmp_path, local_path)
|
||||
return local_path
|
||||
|
||||
@staticmethod
|
||||
def _sha256_check(filepath: str, expected: Optional[str]) -> bool:
|
||||
if not expected:
|
||||
return True # nothing to compare against
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
with open(filepath, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
except OSError:
|
||||
return False
|
||||
actual = h.hexdigest()
|
||||
ok = actual.lower() == expected.lower()
|
||||
if ok:
|
||||
logger.info("SHA256 verified %s", actual)
|
||||
else:
|
||||
logger.error("SHA256 mismatch!\n expected: %s\n actual: %s", expected, actual)
|
||||
return ok
|
||||
|
||||
# ── send feedback ─────────────────────────────────────────────────────
|
||||
def send_feedback(
|
||||
self,
|
||||
deployment_url: str,
|
||||
execution: str,
|
||||
result: str = RESULT_NONE,
|
||||
progress: Optional[Tuple[int, int]] = None,
|
||||
details: Optional[List[str]] = None,
|
||||
code: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""POST …/feedback — report execution status to the server.
|
||||
|
||||
Args:
|
||||
deployment_url: full URL to the deployment base / cancel action / confirmation base.
|
||||
execution: one of the EXEC_* constants.
|
||||
result: ``success``, ``failure``, or ``none``.
|
||||
progress: ``(cnt, of)`` e.g. ``(2, 5)``.
|
||||
details: human-readable messages (shows in Hawkbit UI).
|
||||
code: optional numeric status code.
|
||||
"""
|
||||
# strip query params — they belong to GET, not POST /feedback
|
||||
base = deployment_url.split("?")[0]
|
||||
url = base + "/feedback"
|
||||
|
||||
result_obj: Dict[str, Any] = {"finished": result}
|
||||
if progress:
|
||||
result_obj["progress"] = {"cnt": progress[0], "of": progress[1]}
|
||||
|
||||
status: Dict[str, Any] = {
|
||||
"execution": execution,
|
||||
"result": result_obj,
|
||||
"details": details or [],
|
||||
}
|
||||
if code is not None:
|
||||
status["code"] = code
|
||||
|
||||
payload = {
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
logger.info("Feedback %-12s / %-7s → %s", execution, result, url)
|
||||
return self._http(url, method="POST", data=payload)
|
||||
|
||||
# ── process deployment ────────────────────────────────────────────────
|
||||
def process_deployment(self, deployment_url: str) -> bool:
|
||||
"""Run the full deployment lifecycle: fetch metadata → download → verify → report."""
|
||||
|
||||
dep = self.get_deployment(deployment_url)
|
||||
if not dep:
|
||||
logger.error("Cannot fetch deployment details")
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=["Failed to fetch deployment base"])
|
||||
return False
|
||||
|
||||
action_id = dep["action_id"]
|
||||
handling = dep["handling"]
|
||||
logger.info("══ Action %s ══ download=%s update=%s",
|
||||
action_id, handling["download"], handling["update"])
|
||||
|
||||
# 1. DOWNLOAD phase — report start
|
||||
self.send_feedback(deployment_url, self.EXEC_DOWNLOAD, self.RESULT_NONE,
|
||||
details=["Starting download"])
|
||||
|
||||
# 2. Fetch every artifact
|
||||
downloaded: List[str] = []
|
||||
for chunk in dep["chunks"]:
|
||||
logger.info("Chunk: %s v%s (%d artifacts)",
|
||||
chunk["name"], chunk["version"], len(chunk["artifacts"]))
|
||||
for idx, art in enumerate(chunk["artifacts"], 1):
|
||||
# Per-artifact progress
|
||||
self.send_feedback(
|
||||
deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
|
||||
progress=(idx, len(chunk["artifacts"])),
|
||||
details=[f"Downloading {art['filename']}"],
|
||||
)
|
||||
local = self.download_artifact(art, action_id)
|
||||
if local is None:
|
||||
self.send_feedback(
|
||||
deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=[f"Download failed for {art['filename']}"],
|
||||
)
|
||||
return False
|
||||
downloaded.append(local)
|
||||
|
||||
# 3. DOWNLOADED — all artifacts on disk, hashes ok
|
||||
self.send_feedback(deployment_url, self.EXEC_DOWNLOADED, self.RESULT_NONE,
|
||||
details=[f"Downloaded {len(downloaded)} artifact(s)"])
|
||||
|
||||
# 4. PROCEEDING — install
|
||||
self.send_feedback(deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
|
||||
details=["Installing update …"])
|
||||
|
||||
success = self._install(downloaded, handling)
|
||||
|
||||
# 5. CLOSED — success or failure
|
||||
if success:
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_SUCCESS,
|
||||
details=["Installation complete"], code=200)
|
||||
else:
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=["Installation failed"], code=500)
|
||||
return success
|
||||
|
||||
# ── install hook — override this for your device ──────────────────────
|
||||
def _install(self, files: List[str], handling: dict) -> bool:
|
||||
"""Apply the downloaded update. **Override in subclass or monkey-patch.**"""
|
||||
logger.info("Install hook — files: %s", files)
|
||||
# Default: look for update.zip and report success.
|
||||
for fp in files:
|
||||
if os.path.basename(fp).lower().endswith(".zip"):
|
||||
# Example: subprocess.run(["unzip", "-o", fp, "-d", "/data/ota"], check=True)
|
||||
logger.info("Would install: %s", fp)
|
||||
return True
|
||||
return bool(files)
|
||||
|
||||
# ── cancel action ─────────────────────────────────────────────────────
|
||||
def process_cancel(self, cancel_url: str) -> None:
|
||||
"""Acknowledge a cancel action."""
|
||||
self.send_feedback(cancel_url + "/feedback", self.EXEC_CANCELED,
|
||||
self.RESULT_NONE, details=["Cancel acknowledged"])
|
||||
|
||||
# ── confirmation (auto-confirm for headless devices) ──────────────────
|
||||
def process_confirmation(self, confirmation_url: str) -> None:
|
||||
"""Auto-confirm a WAITING_FOR_CONFIRMATION action."""
|
||||
logger.info("Auto-confirming: %s", confirmation_url)
|
||||
body = {
|
||||
"confirmation": "confirmed",
|
||||
"code": 200,
|
||||
"details": ["Auto-confirmed by DDI client"],
|
||||
}
|
||||
self._http(confirmation_url + "/feedback", method="POST", data=body)
|
||||
|
||||
# ── main loop ─────────────────────────────────────────────────────────
|
||||
def run(self, *, daemon: bool = True, max_cycles: Optional[int] = None) -> bool:
|
||||
"""Run the polling loop.
|
||||
|
||||
Args:
|
||||
daemon: If False, poll once and return.
|
||||
max_cycles: Maximum number of poll cycles (None = forever).
|
||||
"""
|
||||
logger.info("╔══════════════════════════════════════════════════════════╗")
|
||||
logger.info("║ Hawkbit DDI Client v1.0 ║")
|
||||
logger.info("╠══════════════════════════════════════════════════════════╣")
|
||||
logger.info("║ Server: %s", self.base_url)
|
||||
logger.info("║ Tenant: %s", self.tenant)
|
||||
logger.info("║ Device: %s", self.controller_id)
|
||||
logger.info("║ Auth: %s",
|
||||
self.auth_header.split(" ", 1)[0] if self.auth_header else "none")
|
||||
logger.info("╚══════════════════════════════════════════════════════════╝")
|
||||
|
||||
cycle = 0
|
||||
while max_cycles is None or cycle < max_cycles:
|
||||
cycle += 1
|
||||
logger.info("── Poll cycle %d ──", cycle)
|
||||
|
||||
poll_result = self.poll()
|
||||
if poll_result is None:
|
||||
logger.warning("Poll failed; retrying in %ds", min(self.polling_interval, 30))
|
||||
if not daemon:
|
||||
return False
|
||||
time.sleep(min(self.polling_interval, 30))
|
||||
continue
|
||||
|
||||
# ── report device attributes after first successful poll ──
|
||||
# Target is auto-created by GET /controller/v1/{SN} (findOrRegister…);
|
||||
# configData requires an existing target → poll first, then report.
|
||||
if not self._attrs_reported:
|
||||
self.report_attributes()
|
||||
self._attrs_reported = True
|
||||
|
||||
links = poll_result.get("links", {})
|
||||
|
||||
if "deploymentBase" in links:
|
||||
self.process_deployment(links["deploymentBase"])
|
||||
|
||||
elif "cancelAction" in links:
|
||||
self.process_cancel(links["cancelAction"])
|
||||
|
||||
elif "confirmationBase" in links:
|
||||
self.process_confirmation(links["confirmationBase"])
|
||||
|
||||
if "deploymentBase" not in links and "cancelAction" not in links and "confirmationBase" not in links:
|
||||
logger.debug("No pending action.")
|
||||
|
||||
if not daemon:
|
||||
return True
|
||||
|
||||
logger.info("Sleeping %ds …", self.polling_interval)
|
||||
time.sleep(self.polling_interval)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI entry point
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def main(argv: Optional[List[str]] = None) -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Hawkbit DDI Client — lightweight OTA agent (pure Python)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Gateway token, poll every 30s
|
||||
%(prog)s -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
|
||||
|
||||
# Target token, one-shot
|
||||
%(prog)s -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
|
||||
|
||||
# Custom controller-id, insecure SSL, custom download dir
|
||||
%(prog)s -u https://hawkbit:8443 -t prod --gateway-token k \\
|
||||
--controller-id edge-042 -d /data/ota --no-ssl-verify -v
|
||||
""",
|
||||
)
|
||||
|
||||
# ── required ──────────────────────────────────────────────────────
|
||||
ap.add_argument("-u", "--base-url", required=True,
|
||||
help="Hawkbit base URL, e.g. https://hawkbit.example.com")
|
||||
ap.add_argument("-t", "--tenant", required=True,
|
||||
help="Tenant name, e.g. DEFAULT")
|
||||
|
||||
# ── authentication (mutually-exclusive group would be nice but accepts either) ─
|
||||
ap.add_argument("--gateway-token", metavar="TOKEN",
|
||||
help="Gateway security token → Authorization: GatewayToken TOKEN")
|
||||
ap.add_argument("--target-token", metavar="TOKEN",
|
||||
help="Target security token → Authorization: TargetToken TOKEN")
|
||||
|
||||
# ── optional ──────────────────────────────────────────────────────
|
||||
ap.add_argument("--controller-id",
|
||||
help="Controller ID (default: auto-detect from device)")
|
||||
ap.add_argument("-d", "--download-dir", default="/tmp/hawkbit",
|
||||
help="Directory for downloaded artifacts [default: /tmp/hawkbit]")
|
||||
ap.add_argument("-i", "--polling-interval", type=int, default=60,
|
||||
help="Default polling interval in seconds (overridden by server) [default: 60]")
|
||||
ap.add_argument("--no-verify", action="store_true",
|
||||
help="Skip SHA256 verification")
|
||||
ap.add_argument("--no-resume", action="store_true",
|
||||
help="Disable resumable download")
|
||||
ap.add_argument("--no-ssl-verify", action="store_true",
|
||||
help="Disable TLS certificate verification (INSECURE)")
|
||||
ap.add_argument("--once", action="store_true",
|
||||
help="Poll exactly once then exit")
|
||||
ap.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Debug-level logging")
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# ── logging ───────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# ── auth header ───────────────────────────────────────────────────
|
||||
auth = None
|
||||
if args.gateway_token:
|
||||
token = args.gateway_token
|
||||
if token.startswith("@"):
|
||||
with open(token[1:]) as f:
|
||||
token = f.read().strip()
|
||||
logger.info("Read gateway token from file: %s", args.gateway_token)
|
||||
auth = f"GatewayToken {token}"
|
||||
elif args.target_token:
|
||||
token = args.target_token
|
||||
if token.startswith("@"):
|
||||
with open(token[1:]) as f:
|
||||
token = f.read().strip()
|
||||
logger.info("Read target token from file: %s", args.target_token)
|
||||
auth = f"TargetToken {token}"
|
||||
|
||||
if not auth:
|
||||
logger.warning("No authentication token provided — server may reject requests")
|
||||
|
||||
# ── run ───────────────────────────────────────────────────────────
|
||||
client = HawkbitDDIClient(
|
||||
base_url=args.base_url,
|
||||
tenant=args.tenant,
|
||||
controller_id=args.controller_id,
|
||||
auth_header=auth,
|
||||
download_dir=args.download_dir,
|
||||
verify_sha256=not args.no_verify,
|
||||
polling_interval=args.polling_interval,
|
||||
ssl_verify=not args.no_ssl_verify,
|
||||
resume_download=not args.no_resume,
|
||||
)
|
||||
|
||||
try:
|
||||
ok = client.run(daemon=not args.once)
|
||||
sys.exit(0 if ok else 1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Stopped (Ctrl+C)")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
31
hawkbit-compose/docker-compose.yml
Normal file
31
hawkbit-compose/docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
hawkbit-backend:
|
||||
image: hawkbit/hawkbit-update-server:local
|
||||
container_name: hawkbit-backend
|
||||
ports:
|
||||
- "6060:8080"
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
--hawkbit.dmf.rabbitmq.enabled=false
|
||||
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true
|
||||
environment:
|
||||
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m"
|
||||
networks:
|
||||
hawk_default_net:
|
||||
|
||||
hawkbit-ui:
|
||||
image: hawkbit/hawkbit-ui:local
|
||||
container_name: hawkbit-ui
|
||||
ports:
|
||||
- "6081:8088"
|
||||
restart: unless-stopped
|
||||
command: -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080
|
||||
environment:
|
||||
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
|
||||
networks:
|
||||
hawk_default_net:
|
||||
|
||||
# 显式定义共享网桥,两个服务强制加入同一网络
|
||||
networks:
|
||||
hawk_default_net:
|
||||
driver: bridge
|
||||
25
hawkbit-compose/docker-compose.yml.bak
Normal file
25
hawkbit-compose/docker-compose.yml.bak
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
hawkbit-backend:
|
||||
image: hawkbit/hawkbit-update-server:local
|
||||
container_name: hawkbit-backend
|
||||
ports:
|
||||
- "6060:8080"
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
--hawkbit.dmf.rabbitmq.enabled=false
|
||||
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true
|
||||
environment:
|
||||
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1024m"
|
||||
|
||||
hawkbit-ui:
|
||||
image: hawkbit/hawkbit-ui:local
|
||||
container_name: hawkbit-ui
|
||||
ports:
|
||||
- "6081:8088"
|
||||
restart: unless-stopped
|
||||
# 修复shell语法,单条完整命令,无换行拆分bug
|
||||
command: /bin/sh -c "sleep 15; java $JAVA_TOOL_OPTIONS -jar app.jar --hawkbit.server.url=http://hawkbit-backend:8080"
|
||||
environment:
|
||||
JAVA_TOOL_OPTIONS: "-Xms256m -Xmx512m"
|
||||
depends_on:
|
||||
- hawkbit-backend
|
||||
3240
hawkbit-compose/logs/hawkbit-monolith.log
Normal file
3240
hawkbit-compose/logs/hawkbit-monolith.log
Normal file
File diff suppressed because it is too large
Load Diff
731
hawkbit-compose/logs/hawkbit-ui.log
Normal file
731
hawkbit-compose/logs/hawkbit-ui.log
Normal file
@@ -0,0 +1,731 @@
|
||||
______ _ _ _ _ ____ _ _
|
||||
| ____| | (_) | | | | | _ \(_) |
|
||||
| |__ ___| |_ _ __ ___ ___ | |__ __ ___ _| | _| |_) |_| |_
|
||||
| __| / __| | | '_ \/ __|/ _ \ | '_ \ / _` \ \ /\ / / |/ / _ <| | __|
|
||||
| |___| (__| | | |_) \__ \ __/ | | | | (_| |\ V V /| <| |_) | | |_
|
||||
|______\___|_|_| .__/|___/\___|_|_| |_|\__,_|_\_/\_/ |_|\_\____/|_|\__|
|
||||
| | | |_ _|
|
||||
| | | | | |
|
||||
| | | | | |
|
||||
| |__| |_| |_
|
||||
\____/|_____|
|
||||
|
||||
|
||||
Eclipse hawkBit UI (v0-SNAPSHOT)
|
||||
using Spring Boot (v4.1.0)
|
||||
|
||||
Go to https://hawkbit.eclipse.dev for more information.
|
||||
|
||||
2026-06-22T13:57:39.827+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] org.eclipse.hawkbit.ui.HawkbitUiApp : Starting HawkbitUiApp v0-SNAPSHOT using Java 21.0.11 with PID 4724 (/home/huaxu/hawkbit/hawkbit-ui/target/hawkbit-ui.jar started by huaxu in /home/huaxu/hawkbit/hawkbit-compose)
|
||||
2026-06-22T13:57:39.835+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] org.eclipse.hawkbit.ui.HawkbitUiApp : No active profile set, falling back to 1 default profile: "default"
|
||||
2026-06-22T13:57:42.868+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] o.s.cloud.context.scope.GenericScope : BeanFactory id=c4ec676e-69b2-3fd6-9610-ab9952076abc
|
||||
2026-06-22T13:57:43.953+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 9091 (http)
|
||||
2026-06-22T13:57:43.986+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
|
||||
2026-06-22T13:57:43.986+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.22]
|
||||
2026-06-22T13:57:44.073+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 4072 ms
|
||||
2026-06-22T13:57:48.674+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] c.v.f.s.DefaultDeploymentConfiguration : Vaadin is running in production mode.
|
||||
2026-06-22T13:57:48.776+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 9091 (http) with context path '/'
|
||||
2026-06-22T13:57:48.815+08:00 INFO 4724 --- [hawkbit-ui] [:] [ main] org.eclipse.hawkbit.ui.HawkbitUiApp : Started HawkbitUiApp in 10.233 seconds (process running for 11.905)
|
||||
2026-06-22T14:00:37.878+08:00 INFO 4724 --- [hawkbit-ui] [:] [nio-9091-exec-2] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
|
||||
2026-06-22T14:00:37.879+08:00 INFO 4724 --- [hawkbit-ui] [:] [nio-9091-exec-2] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
|
||||
2026-06-22T14:00:37.881+08:00 INFO 4724 --- [hawkbit-ui] [:] [nio-9091-exec-2] o.s.web.servlet.DispatcherServlet : Completed initialization in 2 ms
|
||||
2026-06-22T14:00:43.280+08:00 ERROR 4724 --- [hawkbit-ui] [:] [nio-9091-exec-9] c.v.flow.router.InternalServerError : There was an exception while trying to navigate to 'targets'
|
||||
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.eclipse.hawkbit.ui.view.TargetView': Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1380) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:325) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at com.vaadin.flow.spring.SpringInstantiator.getOrCreate(SpringInstantiator.java:170) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at com.vaadin.flow.di.Instantiator.createRouteTarget(Instantiator.java:170) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.lambda$getRouteTarget$1(AbstractNavigationStateRenderer.java:147) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.Optional.orElseGet(Optional.java:364) ~[na:na]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.getRouteTarget(AbstractNavigationStateRenderer.java:146) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.sendBeforeEnterEventAndPopulateChain(AbstractNavigationStateRenderer.java:698) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.createChainIfEmptyAndExecuteBeforeEnterNavigation(AbstractNavigationStateRenderer.java:679) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handleBeforeNavigationEvents(AbstractNavigationStateRenderer.java:388) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:226) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.renderViewForRoute(UI.java:2042) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.browserNavigate(UI.java:1927) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:242) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:494) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:301) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.lambda$fireEvent$2(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.fireEvent(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.EventRpcHandler.handleNode(EventRpcHandler.java:62) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.AbstractRpcInvocationHandler.handle(AbstractRpcInvocationHandler.java:80) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocationData(ServerRpcHandler.java:619) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.lambda$handleInvocations$6(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocations(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleRpc(ServerRpcHandler.java:424) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.UidlRequestHandler.synchronizedHandleRequest(UidlRequestHandler.java:137) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.SynchronizedRequestHandler.handleRequest(SynchronizedRequestHandler.java:63) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinService.handleRequest(VaadinService.java:1835) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinServlet.service(VaadinServlet.java:402) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.spring.SpringServlet.service(SpringServlet.java:106) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:513) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:333) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:262) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.mvc.ServletForwardingController.handleRequestInternal(ServletForwardingController.java:139) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.AbstractController.handleRequest(AbstractController.java:177) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter.handle(SimpleControllerHandlerAdapter.java:49) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:866) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1000) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:903) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:649) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:874) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.eclipse.hawkbit.ui.security.UserDetailsSetter.doFilterInternal(UserDetailsSetter.java:73) ~[!/:0-SNAPSHOT]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317) ~[spring-security-config-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:113) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1272) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1801) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
|
||||
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:220) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:129) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 132 common frames omitted
|
||||
Caused by: feign.FeignException$InternalServerError: [500 ] during [GET] to [http://127.0.0.1:9090/rest/v1/targetfilters?offset=0&limit=30] [MgmtTargetFilterQueryRestApi#getFilters(String,int,int,String,String)]: [{"timestamp":"2026-06-22T06:00:43.260Z","status":500,"error":"Internal Server Error"}]
|
||||
at feign.FeignException.serverErrorStatus(FeignException.java:281) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:226) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:213) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:103) ~[feign-core-13.6.1.jar!/:na]
|
||||
at org.eclipse.hawkbit.ui.HawkbitUiApp.lambda$static$1(HawkbitUiApp.java:92) ~[!/:0-SNAPSHOT]
|
||||
at feign.InvocationContext.decodeError(InvocationContext.java:133) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.InvocationContext.proceed(InvocationContext.java:80) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ResponseHandler.handleResponse(ResponseHandler.java:69) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:109) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:53) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:104) ~[feign-core-13.6.1.jar!/:na]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
|
||||
at org.eclipse.hawkbit.sdk.HawkbitClient.lambda$wrapWithReachabilityFence$5(HawkbitClient.java:273) ~[hawkbit-sdk-commons-0-SNAPSHOT.jar!/:0-SNAPSHOT]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.listFilters(TargetView.java:259) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.<init>(TargetView.java:240) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView.<init>(TargetView.java:117) ~[!/:0-SNAPSHOT]
|
||||
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486) ~[na:na]
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:207) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 134 common frames omitted
|
||||
|
||||
2026-06-22T14:00:48.815+08:00 ERROR 4724 --- [hawkbit-ui] [:] [nio-9091-exec-6] c.v.flow.router.InternalServerError : There was an exception while trying to navigate to 'targets'
|
||||
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.eclipse.hawkbit.ui.view.TargetView': Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1380) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:325) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at com.vaadin.flow.spring.SpringInstantiator.getOrCreate(SpringInstantiator.java:170) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at com.vaadin.flow.di.Instantiator.createRouteTarget(Instantiator.java:170) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.lambda$getRouteTarget$1(AbstractNavigationStateRenderer.java:147) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.Optional.orElseGet(Optional.java:364) ~[na:na]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.getRouteTarget(AbstractNavigationStateRenderer.java:146) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.sendBeforeEnterEventAndPopulateChain(AbstractNavigationStateRenderer.java:698) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.createChainIfEmptyAndExecuteBeforeEnterNavigation(AbstractNavigationStateRenderer.java:679) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handleBeforeNavigationEvents(AbstractNavigationStateRenderer.java:388) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:226) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1268) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1223) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1034) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:944) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at org.eclipse.hawkbit.ui.MainLayout.showRouterLayoutContent(MainLayout.java:93) ~[!/:0-SNAPSHOT]
|
||||
at com.vaadin.flow.component.internal.UIInternals.showRouteTarget(UIInternals.java:903) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:247) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.renderViewForRoute(UI.java:2042) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.browserNavigate(UI.java:1927) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:242) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:494) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:301) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.lambda$fireEvent$2(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.fireEvent(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.EventRpcHandler.handleNode(EventRpcHandler.java:62) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.AbstractRpcInvocationHandler.handle(AbstractRpcInvocationHandler.java:80) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocationData(ServerRpcHandler.java:619) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.lambda$handleInvocations$6(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocations(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleRpc(ServerRpcHandler.java:424) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.UidlRequestHandler.synchronizedHandleRequest(UidlRequestHandler.java:137) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.SynchronizedRequestHandler.handleRequest(SynchronizedRequestHandler.java:63) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinService.handleRequest(VaadinService.java:1835) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinServlet.service(VaadinServlet.java:402) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.spring.SpringServlet.service(SpringServlet.java:106) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:513) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:333) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:262) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.mvc.ServletForwardingController.handleRequestInternal(ServletForwardingController.java:139) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.AbstractController.handleRequest(AbstractController.java:177) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter.handle(SimpleControllerHandlerAdapter.java:49) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:866) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1000) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:903) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:649) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:874) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.eclipse.hawkbit.ui.security.UserDetailsSetter.doFilterInternal(UserDetailsSetter.java:73) ~[!/:0-SNAPSHOT]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317) ~[spring-security-config-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:113) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1272) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1801) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
|
||||
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:220) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:129) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 142 common frames omitted
|
||||
Caused by: feign.FeignException$InternalServerError: [500 ] during [GET] to [http://127.0.0.1:9090/rest/v1/targetfilters?offset=0&limit=30] [MgmtTargetFilterQueryRestApi#getFilters(String,int,int,String,String)]: [{"timestamp":"2026-06-22T06:00:48.808Z","status":500,"error":"Internal Server Error"}]
|
||||
at feign.FeignException.serverErrorStatus(FeignException.java:281) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:226) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:213) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:103) ~[feign-core-13.6.1.jar!/:na]
|
||||
at org.eclipse.hawkbit.ui.HawkbitUiApp.lambda$static$1(HawkbitUiApp.java:92) ~[!/:0-SNAPSHOT]
|
||||
at feign.InvocationContext.decodeError(InvocationContext.java:133) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.InvocationContext.proceed(InvocationContext.java:80) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ResponseHandler.handleResponse(ResponseHandler.java:69) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:109) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:53) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:104) ~[feign-core-13.6.1.jar!/:na]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
|
||||
at org.eclipse.hawkbit.sdk.HawkbitClient.lambda$wrapWithReachabilityFence$5(HawkbitClient.java:273) ~[hawkbit-sdk-commons-0-SNAPSHOT.jar!/:0-SNAPSHOT]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.listFilters(TargetView.java:259) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.<init>(TargetView.java:240) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView.<init>(TargetView.java:117) ~[!/:0-SNAPSHOT]
|
||||
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486) ~[na:na]
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:207) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 144 common frames omitted
|
||||
|
||||
2026-06-22T14:01:50.056+08:00 ERROR 4724 --- [hawkbit-ui] [:] [nio-9091-exec-6] c.v.flow.router.InternalServerError : There was an exception while trying to navigate to 'targets'
|
||||
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.eclipse.hawkbit.ui.view.TargetView': Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1380) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:325) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at com.vaadin.flow.spring.SpringInstantiator.getOrCreate(SpringInstantiator.java:170) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at com.vaadin.flow.di.Instantiator.createRouteTarget(Instantiator.java:170) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.lambda$getRouteTarget$1(AbstractNavigationStateRenderer.java:147) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.Optional.orElseGet(Optional.java:364) ~[na:na]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.getRouteTarget(AbstractNavigationStateRenderer.java:146) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.sendBeforeEnterEventAndPopulateChain(AbstractNavigationStateRenderer.java:698) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.createChainIfEmptyAndExecuteBeforeEnterNavigation(AbstractNavigationStateRenderer.java:679) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handleBeforeNavigationEvents(AbstractNavigationStateRenderer.java:388) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:226) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1268) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1223) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1034) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:944) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at org.eclipse.hawkbit.ui.MainLayout.showRouterLayoutContent(MainLayout.java:93) ~[!/:0-SNAPSHOT]
|
||||
at com.vaadin.flow.component.internal.UIInternals.showRouteTarget(UIInternals.java:903) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:247) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.renderViewForRoute(UI.java:2042) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.browserNavigate(UI.java:1927) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:242) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:494) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:301) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.lambda$fireEvent$2(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.fireEvent(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.EventRpcHandler.handleNode(EventRpcHandler.java:62) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.AbstractRpcInvocationHandler.handle(AbstractRpcInvocationHandler.java:80) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocationData(ServerRpcHandler.java:619) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.lambda$handleInvocations$6(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocations(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleRpc(ServerRpcHandler.java:424) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.UidlRequestHandler.synchronizedHandleRequest(UidlRequestHandler.java:137) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.SynchronizedRequestHandler.handleRequest(SynchronizedRequestHandler.java:63) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinService.handleRequest(VaadinService.java:1835) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinServlet.service(VaadinServlet.java:402) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.spring.SpringServlet.service(SpringServlet.java:106) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:513) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:333) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:262) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.mvc.ServletForwardingController.handleRequestInternal(ServletForwardingController.java:139) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.AbstractController.handleRequest(AbstractController.java:177) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter.handle(SimpleControllerHandlerAdapter.java:49) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:866) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1000) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:903) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:649) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:874) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.eclipse.hawkbit.ui.security.UserDetailsSetter.doFilterInternal(UserDetailsSetter.java:73) ~[!/:0-SNAPSHOT]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317) ~[spring-security-config-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:113) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1272) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1801) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
|
||||
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:220) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:129) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 142 common frames omitted
|
||||
Caused by: feign.FeignException$InternalServerError: [500 ] during [GET] to [http://127.0.0.1:9090/rest/v1/targetfilters?offset=0&limit=30] [MgmtTargetFilterQueryRestApi#getFilters(String,int,int,String,String)]: [{"timestamp":"2026-06-22T06:01:50.045Z","status":500,"error":"Internal Server Error"}]
|
||||
at feign.FeignException.serverErrorStatus(FeignException.java:281) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:226) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:213) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:103) ~[feign-core-13.6.1.jar!/:na]
|
||||
at org.eclipse.hawkbit.ui.HawkbitUiApp.lambda$static$1(HawkbitUiApp.java:92) ~[!/:0-SNAPSHOT]
|
||||
at feign.InvocationContext.decodeError(InvocationContext.java:133) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.InvocationContext.proceed(InvocationContext.java:80) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ResponseHandler.handleResponse(ResponseHandler.java:69) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:109) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:53) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:104) ~[feign-core-13.6.1.jar!/:na]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
|
||||
at org.eclipse.hawkbit.sdk.HawkbitClient.lambda$wrapWithReachabilityFence$5(HawkbitClient.java:273) ~[hawkbit-sdk-commons-0-SNAPSHOT.jar!/:0-SNAPSHOT]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.listFilters(TargetView.java:259) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.<init>(TargetView.java:240) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView.<init>(TargetView.java:117) ~[!/:0-SNAPSHOT]
|
||||
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486) ~[na:na]
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:207) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 144 common frames omitted
|
||||
|
||||
2026-06-22T14:01:58.509+08:00 ERROR 4724 --- [hawkbit-ui] [:] [io-9091-exec-10] c.v.flow.router.InternalServerError : There was an exception while trying to navigate to 'targets'
|
||||
|
||||
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.eclipse.hawkbit.ui.view.TargetView': Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1380) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:325) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at com.vaadin.flow.spring.SpringInstantiator.getOrCreate(SpringInstantiator.java:170) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at com.vaadin.flow.di.Instantiator.createRouteTarget(Instantiator.java:170) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.lambda$getRouteTarget$1(AbstractNavigationStateRenderer.java:147) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.Optional.orElseGet(Optional.java:364) ~[na:na]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.getRouteTarget(AbstractNavigationStateRenderer.java:146) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.sendBeforeEnterEventAndPopulateChain(AbstractNavigationStateRenderer.java:698) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.createChainIfEmptyAndExecuteBeforeEnterNavigation(AbstractNavigationStateRenderer.java:679) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handleBeforeNavigationEvents(AbstractNavigationStateRenderer.java:388) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:226) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1268) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1223) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:1034) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.navigate(UI.java:944) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at org.eclipse.hawkbit.ui.MainLayout.showRouterLayoutContent(MainLayout.java:93) ~[!/:0-SNAPSHOT]
|
||||
at com.vaadin.flow.component.internal.UIInternals.showRouteTarget(UIInternals.java:903) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:247) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:78) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.router.Router.executeNavigation(Router.java:399) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2081) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.renderViewForRoute(UI.java:2042) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.UI.browserNavigate(UI.java:1927) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:242) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:494) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:301) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.lambda$fireEvent$2(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.internal.nodefeature.ElementListenerMap.fireEvent(ElementListenerMap.java:501) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.EventRpcHandler.handleNode(EventRpcHandler.java:62) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.rpc.AbstractRpcInvocationHandler.handle(AbstractRpcInvocationHandler.java:80) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocationData(ServerRpcHandler.java:619) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.lambda$handleInvocations$6(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleInvocations(ServerRpcHandler.java:600) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.ServerRpcHandler.handleRpc(ServerRpcHandler.java:424) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.communication.UidlRequestHandler.synchronizedHandleRequest(UidlRequestHandler.java:137) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.SynchronizedRequestHandler.handleRequest(SynchronizedRequestHandler.java:63) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinService.handleRequest(VaadinService.java:1835) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.server.VaadinServlet.service(VaadinServlet.java:402) ~[flow-server-25.1.10.jar!/:25.1.10]
|
||||
at com.vaadin.flow.spring.SpringServlet.service(SpringServlet.java:106) ~[vaadin-spring-25.1.10.jar!/:na]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:513) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:333) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:262) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.mvc.ServletForwardingController.handleRequestInternal(ServletForwardingController.java:139) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.AbstractController.handleRequest(AbstractController.java:177) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter.handle(SimpleControllerHandlerAdapter.java:49) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:866) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1000) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:903) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:649) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:874) ~[spring-webmvc-7.0.8.jar!/:7.0.8]
|
||||
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:128) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.eclipse.hawkbit.ui.security.UserDetailsSetter.doFilterInternal(UserDetailsSetter.java:73) ~[!/:0-SNAPSHOT]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:181) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:245) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:239) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:96) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195) ~[spring-security-web-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317) ~[spring-security-config-7.1.0.jar!/:7.1.0]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.8.jar!/:7.0.8]
|
||||
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:107) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:77) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:113) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1272) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1801) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:946) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:480) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:57) ~[tomcat-embed-core-11.0.22.jar!/:na]
|
||||
at java.base/java.lang.Thread.run(Thread.java:1583) ~[na:na]
|
||||
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.eclipse.hawkbit.ui.view.TargetView]: Constructor threw exception
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:220) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:129) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 142 common frames omitted
|
||||
Caused by: feign.FeignException$InternalServerError: [500 ] during [GET] to [http://127.0.0.1:9090/rest/v1/targetfilters?offset=0&limit=30] [MgmtTargetFilterQueryRestApi#getFilters(String,int,int,String,String)]: [{"timestamp":"2026-06-22T06:01:58.502Z","status":500,"error":"Internal Server Error"}]
|
||||
at feign.FeignException.serverErrorStatus(FeignException.java:281) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:226) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.FeignException.errorStatus(FeignException.java:213) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.codec.ErrorDecoder$Default.decode(ErrorDecoder.java:103) ~[feign-core-13.6.1.jar!/:na]
|
||||
at org.eclipse.hawkbit.ui.HawkbitUiApp.lambda$static$1(HawkbitUiApp.java:92) ~[!/:0-SNAPSHOT]
|
||||
at feign.InvocationContext.decodeError(InvocationContext.java:133) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.InvocationContext.proceed(InvocationContext.java:80) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ResponseHandler.handleResponse(ResponseHandler.java:69) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:109) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:53) ~[feign-core-13.6.1.jar!/:na]
|
||||
at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:104) ~[feign-core-13.6.1.jar!/:na]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
|
||||
at org.eclipse.hawkbit.sdk.HawkbitClient.lambda$wrapWithReachabilityFence$5(HawkbitClient.java:273) ~[hawkbit-sdk-commons-0-SNAPSHOT.jar!/:0-SNAPSHOT]
|
||||
at jdk.proxy2/jdk.proxy2.$Proxy102.getFilters(Unknown Source) ~[na:na]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.listFilters(TargetView.java:259) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView$RawFilter.<init>(TargetView.java:240) ~[!/:0-SNAPSHOT]
|
||||
at org.eclipse.hawkbit.ui.view.TargetView.<init>(TargetView.java:117) ~[!/:0-SNAPSHOT]
|
||||
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) ~[na:na]
|
||||
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486) ~[na:na]
|
||||
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:207) ~[spring-beans-7.0.8.jar!/:7.0.8]
|
||||
... 144 common frames omitted
|
||||
|
||||
2026-06-22T14:02:21.841+08:00 INFO 4724 --- [hawkbit-ui] [:] [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
|
||||
2026-06-22T14:02:21.843+08:00 INFO 4724 --- [hawkbit-ui] [:] [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
|
||||
407
hawkbit-compose/start-hawkbit.sh
Executable file
407
hawkbit-compose/start-hawkbit.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Hawkbit Monolith + UI 本地启动脚本(替代 docker-compose)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# 两种模式:
|
||||
# h2 H2 内嵌数据库(零依赖,开发测试用)
|
||||
# postgres PostgreSQL + RabbitMQ(模拟生产环境)
|
||||
#
|
||||
# 用法:
|
||||
# ./start-hawkbit.sh h2 # H2 模式,端口 9090/9091
|
||||
# ./start-hawkbit.sh postgres # PostgreSQL 模式,端口 9090/9091
|
||||
# ./start-hawkbit.sh stop # 停止所有
|
||||
# ./start-hawkbit.sh status # 查看状态
|
||||
# ./start-hawkbit.sh dbinit # 仅执行数据库初始化(postgres 模式)
|
||||
# ./start-hawkbit.sh help # 帮助
|
||||
#
|
||||
# 对应 docker-compose 文件:
|
||||
# h2 → docker-compose-monolith-with-ui-postgres.yml (但用 H2)
|
||||
# postgres → docker-compose-monolith-dbinit-with-ui-postgres.yml
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HAWKBIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
PID_DIR="$SCRIPT_DIR/pid"
|
||||
|
||||
# ── 配置 ─────────────────────────────────────────────────────────────────────
|
||||
MONOLITH_JAR="$HAWKBIT_ROOT/hawkbit-monolith/hawkbit-update-server/target/hawkbit-update-server-0-SNAPSHOT.jar"
|
||||
UI_JAR="$HAWKBIT_ROOT/hawkbit-ui/target/hawkbit-ui.jar"
|
||||
DBINIT_JAR="$HOME/.m2/repository/org/eclipse/hawkbit/hawkbit-repository-jpa-init/0-SNAPSHOT/hawkbit-repository-jpa-init-0-SNAPSHOT.jar"
|
||||
|
||||
# 端口
|
||||
MONOLITH_PORT=9090
|
||||
UI_PORT=9091
|
||||
|
||||
# PostgreSQL 配置(postgres 模式)
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5432
|
||||
PG_DB=hawkbit
|
||||
PG_USER=postgres
|
||||
PG_PASSWORD=hawkbit
|
||||
|
||||
# RabbitMQ 配置(postgres 模式)
|
||||
RABBIT_HOST=localhost
|
||||
RABBIT_PORT=5672
|
||||
|
||||
# H2 配置
|
||||
H2_DB_DIR="$SCRIPT_DIR/h2data"
|
||||
|
||||
# JVM 参数
|
||||
MONOLITH_XMS=512m
|
||||
MONOLITH_XMX=1024m
|
||||
UI_XMS=256m
|
||||
UI_XMX=512m
|
||||
|
||||
# ── 颜色 ─────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "\n${BLUE}═══ $* ═══${NC}"; }
|
||||
|
||||
# ── 初始化 ───────────────────────────────────────────────────────────────────
|
||||
init() {
|
||||
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||
}
|
||||
|
||||
# ── 清理 ─────────────────────────────────────────────────────────────────────
|
||||
cleanup_pid() {
|
||||
local pid_file="$1"
|
||||
local name="$2"
|
||||
if [ -f "$pid_file" ]; then
|
||||
local pid=$(cat "$pid_file")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
info "停止 $name (PID: $pid)"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 停止 ─────────────────────────────────────────────────────────────────────
|
||||
do_stop() {
|
||||
step "停止 Hawkbit 服务"
|
||||
|
||||
cleanup_pid "$PID_DIR/hawkbit-monolith.pid" "hawkbit-monolith"
|
||||
cleanup_pid "$PID_DIR/hawkbit-ui.pid" "hawkbit-ui"
|
||||
|
||||
if [ -f "$PID_DIR/postgres.pid" ]; then
|
||||
local pg_pid=$(cat "$PID_DIR/postgres.pid")
|
||||
if kill -0 "$pg_pid" 2>/dev/null; then
|
||||
info "停止 PostgreSQL (PID: $pg_pid)"
|
||||
pg_ctl -D "$SCRIPT_DIR/pgdata" stop 2>/dev/null || kill "$pg_pid"
|
||||
fi
|
||||
rm -f "$PID_DIR/postgres.pid"
|
||||
fi
|
||||
|
||||
if [ -f "$PID_DIR/rabbitmq.pid" ]; then
|
||||
local rb_pid=$(cat "$PID_DIR/rabbitmq.pid")
|
||||
if kill -0 "$rb_pid" 2>/dev/null; then
|
||||
info "停止 RabbitMQ (PID: $rb_pid)"
|
||||
kill "$rb_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PID_DIR/rabbitmq.pid"
|
||||
fi
|
||||
|
||||
info "全部已停止"
|
||||
}
|
||||
|
||||
# ── 状态 ─────────────────────────────────────────────────────────────────────
|
||||
do_status() {
|
||||
echo "Hawkbit 服务状态:"
|
||||
echo "─────────────────"
|
||||
|
||||
for svc in "hawkbit-monolith:${MONOLITH_PORT}:$PID_DIR/hawkbit-monolith.pid" \
|
||||
"hawkbit-ui:${UI_PORT}:$PID_DIR/hawkbit-ui.pid"; do
|
||||
IFS=':' read -r name port pid_file <<< "$svc"
|
||||
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
|
||||
echo -e " ${GREEN}●${NC} $name (port $port, PID $(cat "$pid_file"))"
|
||||
else
|
||||
echo -e " ${RED}○${NC} $name (port $port)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ -f "$PID_DIR/postgres.pid" ] && kill -0 "$(cat "$PID_DIR/postgres.pid")" 2>/dev/null; then
|
||||
echo -e " ${GREEN}●${NC} PostgreSQL (port $PG_PORT)"
|
||||
else
|
||||
echo -e " ${YELLOW}○${NC} PostgreSQL (not managed by this script)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 等待端口 ─────────────────────────────────────────────────────────────────
|
||||
wait_for_port() {
|
||||
local host=$1 port=$2 timeout=${3:-30} label=${4:-"$host:$port"}
|
||||
local waited=0
|
||||
while ! (echo >"/dev/tcp/$host/$port") 2>/dev/null; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
if [ $waited -ge $timeout ]; then
|
||||
error "$label 启动超时(${timeout}s)"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
info "$label 就绪"
|
||||
}
|
||||
|
||||
# ── H2 模式 ──────────────────────────────────────────────────────────────────
|
||||
start_h2() {
|
||||
step "H2 模式启动"
|
||||
|
||||
if [ -f "$PID_DIR/hawkbit-monolith.pid" ] && kill -0 "$(cat "$PID_DIR/hawkbit-monolith.pid")" 2>/dev/null; then
|
||||
warn "hawkbit-monolith 已在运行"
|
||||
else
|
||||
mkdir -p "$H2_DB_DIR"
|
||||
|
||||
info "启动 hawkbit-update-server (H2, 端口 $MONOLITH_PORT)..."
|
||||
nohup java \
|
||||
-Xms${MONOLITH_XMS} -Xmx${MONOLITH_XMX} \
|
||||
-jar "$MONOLITH_JAR" \
|
||||
--server.port=$MONOLITH_PORT \
|
||||
--hawkbit.dmf.rabbitmq.enabled=false \
|
||||
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true \
|
||||
--hawkbit.server.ui.demo.user=admin \
|
||||
--hawkbit.server.ui.demo.password=admin \
|
||||
--spring.datasource.url="jdbc:h2:file:$H2_DB_DIR/hawkbit;DB_CLOSE_DELAY=-1" \
|
||||
>> "$LOG_DIR/hawkbit-monolith.log" 2>&1 &
|
||||
echo $! > "$PID_DIR/hawkbit-monolith.pid"
|
||||
fi
|
||||
|
||||
start_ui
|
||||
}
|
||||
|
||||
# ── PostgreSQL 模式 ──────────────────────────────────────────────────────────
|
||||
start_postgres() {
|
||||
step "PostgreSQL 模式启动"
|
||||
|
||||
# ── 检查 PostgreSQL ────────────────────────────────────────────────
|
||||
if ! command -v pg_isready &>/dev/null; then
|
||||
error "PostgreSQL 未安装。请先执行: sudo apt install postgresql-16 rabbitmq-server"
|
||||
echo ""
|
||||
echo "或者用 H2 模式: $0 h2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 确保 PostgreSQL 运行
|
||||
if ! pg_isready -h $PG_HOST -p $PG_PORT &>/dev/null; then
|
||||
info "启动 PostgreSQL..."
|
||||
sudo pg_ctlcluster 16 main start 2>/dev/null || \
|
||||
sudo service postgresql start 2>/dev/null || {
|
||||
error "无法启动 PostgreSQL"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
info "PostgreSQL 已就绪"
|
||||
|
||||
# ── 创建数据库和用户 ──────────────────────────────────────────────
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$PG_USER'" 2>/dev/null | grep -q 1 || {
|
||||
info "创建数据库用户 $PG_USER"
|
||||
sudo -u postgres psql -c "CREATE USER $PG_USER WITH PASSWORD '$PG_PASSWORD';" 2>/dev/null
|
||||
sudo -u postgres psql -c "ALTER USER $PG_USER CREATEDB;" 2>/dev/null
|
||||
}
|
||||
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$PG_DB'" 2>/dev/null | grep -q 1 || {
|
||||
info "创建数据库 $PG_DB"
|
||||
sudo -u postgres psql -c "CREATE DATABASE $PG_DB OWNER $PG_USER;" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── RabbitMQ ───────────────────────────────────────────────────────
|
||||
if ! rabbitmqctl status &>/dev/null 2>&1; then
|
||||
info "启动 RabbitMQ..."
|
||||
sudo service rabbitmq-server start 2>/dev/null || {
|
||||
warn "RabbitMQ 启动失败,禁用 DMF"
|
||||
RABBIT_DISABLED=true
|
||||
}
|
||||
fi
|
||||
[ -z "$RABBIT_DISABLED" ] && info "RabbitMQ 已就绪"
|
||||
|
||||
# ── DB Init (Flyway 迁移) ──────────────────────────────────────────
|
||||
if [ -f "$DBINIT_JAR" ]; then
|
||||
step "数据库初始化 (Flyway 迁移)"
|
||||
info "执行 Hawkbit DB Init..."
|
||||
java \
|
||||
-Xms256m -Xmx512m \
|
||||
-classpath "$DBINIT_JAR" \
|
||||
-Dspring.datasource.url="jdbc:postgresql://${PG_HOST}:${PG_PORT}/${PG_DB}" \
|
||||
-Dspring.datasource.username="$PG_USER" \
|
||||
-Dspring.datasource.password="$PG_PASSWORD" \
|
||||
org.eclipse.hawkbit.repository.jpa.init.HawkbitFlywayDbInit \
|
||||
migrate 2>&1 | tee "$LOG_DIR/dbinit.log"
|
||||
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
error "DB 初始化失败,查看日志: $LOG_DIR/dbinit.log"
|
||||
exit 1
|
||||
fi
|
||||
info "DB 初始化完成"
|
||||
else
|
||||
warn "DB Init JAR 未找到: $DBINIT_JAR"
|
||||
warn "跳过 DB 初始化,由 Monolith 内嵌 Flyway 执行"
|
||||
fi
|
||||
|
||||
# ── 启动 Monolith ─────────────────────────────────────────────────
|
||||
info "启动 hawkbit-update-server (PostgreSQL, 端口 $MONOLITH_PORT)..."
|
||||
|
||||
local rabbit_args=""
|
||||
if [ "$RABBIT_DISABLED" = true ]; then
|
||||
rabbit_args="--hawkbit.dmf.rabbitmq.enabled=false"
|
||||
else
|
||||
rabbit_args="--spring.rabbitmq.host=$RABBIT_HOST"
|
||||
fi
|
||||
|
||||
nohup java \
|
||||
-Xms${MONOLITH_XMS} -Xmx${MONOLITH_XMX} \
|
||||
-Dspring.profiles.active=postgresql \
|
||||
-jar "$MONOLITH_JAR" \
|
||||
--server.port=$MONOLITH_PORT \
|
||||
--spring.datasource.url="jdbc:postgresql://${PG_HOST}:${PG_PORT}/${PG_DB}" \
|
||||
--spring.datasource.username="$PG_USER" \
|
||||
--spring.datasource.password="$PG_PASSWORD" \
|
||||
$rabbit_args \
|
||||
--hawkbit.server.ddi.security.authentication.anonymous.enabled=true \
|
||||
--hawkbit.server.ui.demo.user=admin \
|
||||
--hawkbit.server.ui.demo.password=admin \
|
||||
>> "$LOG_DIR/hawkbit-monolith.log" 2>&1 &
|
||||
echo $! > "$PID_DIR/hawkbit-monolith.pid"
|
||||
|
||||
wait_for_port localhost $MONOLITH_PORT 60 "hawkbit-monolith"
|
||||
|
||||
start_ui
|
||||
}
|
||||
|
||||
# ── 启动 UI ──────────────────────────────────────────────────────────────────
|
||||
start_ui() {
|
||||
if [ -f "$PID_DIR/hawkbit-ui.pid" ] && kill -0 "$(cat "$PID_DIR/hawkbit-ui.pid")" 2>/dev/null; then
|
||||
warn "hawkbit-ui 已在运行"
|
||||
else
|
||||
info "启动 hawkbit-ui (端口 $UI_PORT)..."
|
||||
nohup java \
|
||||
-Xms${UI_XMS} -Xmx${UI_XMX} \
|
||||
-jar "$UI_JAR" \
|
||||
--server.port=$UI_PORT \
|
||||
--hawkbit.server.mgmturl="http://127.0.0.1:${MONOLITH_PORT}" \
|
||||
>> "$LOG_DIR/hawkbit-ui.log" 2>&1 &
|
||||
echo $! > "$PID_DIR/hawkbit-ui.pid"
|
||||
fi
|
||||
|
||||
wait_for_port localhost $UI_PORT 30 "hawkbit-ui"
|
||||
|
||||
step "启动完成"
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────────────────────┐"
|
||||
echo " │ Hawkbit Management API : http://localhost:${MONOLITH_PORT}/rest/v1 │"
|
||||
echo " │ Hawkbit DDI API : http://localhost:${MONOLITH_PORT}/DEFAULT/controller/v1 │"
|
||||
echo " │ Hawkbit UI : http://localhost:${UI_PORT} │"
|
||||
echo " │ Admin 账号 : admin / admin │"
|
||||
echo " ├─────────────────────────────────────────────────────┤"
|
||||
echo " │ 日志目录 : $LOG_DIR"
|
||||
echo " │ PID 目录 : $PID_DIR"
|
||||
echo " │ 停止 : $0 stop"
|
||||
echo " │ 状态 : $0 status"
|
||||
echo " └─────────────────────────────────────────────────────┘"
|
||||
}
|
||||
|
||||
# ── 仅 DB 初始化 ─────────────────────────────────────────────────────────────
|
||||
do_dbinit() {
|
||||
step "数据库初始化"
|
||||
|
||||
if ! pg_isready -h $PG_HOST -p $PG_PORT &>/dev/null; then
|
||||
error "PostgreSQL 未运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$DBINIT_JAR" ]; then
|
||||
error "DB Init JAR 未找到: $DBINIT_JAR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
java \
|
||||
-Xms256m -Xmx512m \
|
||||
-classpath "$DBINIT_JAR" \
|
||||
-Dspring.datasource.url="jdbc:postgresql://${PG_HOST}:${PG_PORT}/${PG_DB}" \
|
||||
-Dspring.datasource.username="$PG_USER" \
|
||||
-Dspring.datasource.password="$PG_PASSWORD" \
|
||||
org.eclipse.hawkbit.repository.jpa.init.HawkbitFlywayDbInit \
|
||||
migrate
|
||||
|
||||
info "DB 初始化完成"
|
||||
}
|
||||
|
||||
# ── 帮助 ─────────────────────────────────────────────────────────────────────
|
||||
do_help() {
|
||||
cat << 'EOF'
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ Hawkbit Monolith + UI 本地启动脚本 ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
用法: start-hawkbit.sh <command>
|
||||
|
||||
命令:
|
||||
h2 H2 内嵌数据库模式(零依赖,开发测试推荐)
|
||||
postgres PostgreSQL 模式(需先: sudo apt install postgresql-16 rabbitmq-server)
|
||||
stop 停止所有服务
|
||||
status 查看服务状态
|
||||
dbinit 仅执行 DB 初始化(postgres 模式)
|
||||
help 显示此帮助
|
||||
|
||||
示例:
|
||||
./start-hawkbit.sh h2 # 快速启动,用 H2 数据库
|
||||
./start-hawkbit.sh postgres # 生产模式,用 PostgreSQL
|
||||
./start-hawkbit.sh stop # 停止
|
||||
./start-hawkbit.sh status # 查看
|
||||
|
||||
对应 Docker Compose:
|
||||
h2 ≈ docker-compose-monolith-with-ui-postgres.yml (但用 H2)
|
||||
postgres ≈ docker-compose-monolith-dbinit-with-ui-postgres.yml
|
||||
|
||||
区别:
|
||||
H2 PostgreSQL
|
||||
DB 文件存储 独立服务
|
||||
安装 零依赖 sudo apt install postgresql-16
|
||||
数据路径 h2data/ PostgreSQL pgdata/
|
||||
适用场景 开发/测试 生产模拟
|
||||
数据持久化 是 是
|
||||
|
||||
H2 模式启动后:
|
||||
MGMT API: http://localhost:9090/rest/v1
|
||||
DDI API: http://localhost:9090/DEFAULT/controller/v1
|
||||
UI: http://localhost:9091
|
||||
Admin: admin / admin
|
||||
EOF
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
init
|
||||
|
||||
case "${1:-help}" in
|
||||
h2)
|
||||
start_h2
|
||||
;;
|
||||
postgres)
|
||||
start_postgres
|
||||
;;
|
||||
stop)
|
||||
do_stop
|
||||
;;
|
||||
status)
|
||||
do_status
|
||||
;;
|
||||
dbinit)
|
||||
do_dbinit
|
||||
;;
|
||||
help|--help|-h)
|
||||
do_help
|
||||
;;
|
||||
*)
|
||||
error "未知命令: $1"
|
||||
do_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -11,12 +11,14 @@ package org.eclipse.hawkbit.amqp;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import lombok.ToString;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrlResolver;
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.repository.ConfirmationManagement;
|
||||
import org.eclipse.hawkbit.repository.ControllerManagement;
|
||||
import org.eclipse.hawkbit.repository.DeploymentManagement;
|
||||
@@ -27,13 +29,15 @@ import org.eclipse.hawkbit.repository.TargetManagement;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSet;
|
||||
import org.eclipse.hawkbit.repository.model.SoftwareModule;
|
||||
import org.eclipse.hawkbit.repository.model.Target;
|
||||
import org.springframework.amqp.listener.ConditionalRejectingErrorHandler;
|
||||
import org.springframework.amqp.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler;
|
||||
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory;
|
||||
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.amqp.support.converter.MessageConverter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
@@ -46,6 +50,7 @@ import org.springframework.context.annotation.PropertySource;
|
||||
import org.springframework.core.retry.RetryPolicy;
|
||||
import org.springframework.core.retry.RetryTemplate;
|
||||
import org.springframework.util.ErrorHandler;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
@@ -89,13 +94,25 @@ public class DmfApiConfiguration {
|
||||
return new ConditionalRejectingErrorHandler(new RequeueExceptionStrategy(fatalExceptionStrategies, fatalExceptionTypes));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "dmfMessageConverter") // override it if needed to add / edit trusted packages or need other customization
|
||||
public MessageConverter dmfMessageConverter(
|
||||
final JsonMapper jsonMapper,
|
||||
@Value("${hawkbit.dmf.trusted-packages:}") final String trustedPackages) {
|
||||
return new DmfMessageConverter(
|
||||
jsonMapper,
|
||||
ObjectUtils.isEmpty(trustedPackages)
|
||||
? new String[0]
|
||||
: Arrays.stream(trustedPackages.split(",")).map(String::trim).toArray(String[]::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@link RabbitTemplate} with automatic retry, published confirms and {@link JacksonJsonMessageConverter}.
|
||||
*/
|
||||
@Bean
|
||||
public RabbitTemplate rabbitTemplate(final JsonMapper jsonMapper) {
|
||||
public RabbitTemplate rabbitTemplate(@Qualifier("dmfMessageConverter") final MessageConverter dmfMessageConverter) {
|
||||
final RabbitTemplate rabbitTemplate = new RabbitTemplate(rabbitConnectionFactory);
|
||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter(jsonMapper));
|
||||
rabbitTemplate.setMessageConverter(dmfMessageConverter);
|
||||
|
||||
// the same policy the previously used default ExponentialBackOffPolicy applied
|
||||
rabbitTemplate.setRetryTemplate(new RetryTemplate(RetryPolicy.builder()
|
||||
@@ -168,7 +185,8 @@ public class DmfApiConfiguration {
|
||||
final SystemManagement systemManagement,
|
||||
final TargetManagement<? extends Target> targetManagement,
|
||||
final DistributionSetManagement<? extends DistributionSet> distributionSetManagement,
|
||||
final SoftwareModuleManagement<? extends SoftwareModule> softwareModuleManagement, final DeploymentManagement deploymentManagement) {
|
||||
final SoftwareModuleManagement<? extends SoftwareModule> softwareModuleManagement,
|
||||
final DeploymentManagement deploymentManagement) {
|
||||
return new AmqpMessageDispatcherService(rabbitTemplate, amqpSenderService, artifactUrlHandler,
|
||||
systemManagement, targetManagement, softwareModuleManagement, distributionSetManagement,
|
||||
deploymentManagement);
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException;
|
||||
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
|
||||
import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException;
|
||||
import org.eclipse.hawkbit.repository.exception.TenantNotExistException;
|
||||
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||
import org.springframework.messaging.MessageHandlingException;
|
||||
import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException;
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.eclipse.hawkbit.artifact.model.ArtifactHashes;
|
||||
import org.eclipse.hawkbit.artifact.model.StoredArtifactInfo;
|
||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrl;
|
||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrlResolver;
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.EventTopic;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
||||
@@ -60,7 +61,6 @@ import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
@@ -94,7 +94,7 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest {
|
||||
Create.builder().controllerId(CONTROLLER_ID).securityToken(TEST_TOKEN).address(AMQP_URI.toString()).build());
|
||||
|
||||
this.rabbitTemplate = Mockito.mock(RabbitTemplate.class);
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new JacksonJsonMessageConverter());
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new DmfMessageConverter());
|
||||
|
||||
senderService = Mockito.mock(DefaultAmqpMessageSenderService.class);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.EventTopic;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
||||
@@ -54,7 +55,6 @@ import org.springframework.amqp.AmqpRejectAndDontRequeueException;
|
||||
import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||
import org.springframework.amqp.support.converter.MessageConverter;
|
||||
|
||||
@@ -108,7 +108,7 @@ class AmqpMessageHandlerServiceTest {
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
void before() {
|
||||
TenantConfigHelper.setTenantConfigurationManagement(tenantConfigurationManagement);
|
||||
messageConverter = new JacksonJsonMessageConverter();
|
||||
messageConverter = new DmfMessageConverter();
|
||||
lenient().when(rabbitTemplate.getMessageConverter()).thenReturn(messageConverter);
|
||||
|
||||
amqpMessageHandlerService = new AmqpMessageHandlerService(
|
||||
|
||||
@@ -15,6 +15,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus;
|
||||
import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus;
|
||||
import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent;
|
||||
@@ -28,7 +29,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ class BaseAmqpServiceTest {
|
||||
@Test
|
||||
void convertMessageTest() {
|
||||
final DmfActionUpdateStatus actionUpdateStatus = createActionStatus();
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new JacksonJsonMessageConverter());
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new DmfMessageConverter());
|
||||
|
||||
final Message message = rabbitTemplate.getMessageConverter().toMessage(actionUpdateStatus, createJsonProperties());
|
||||
final DmfActionUpdateStatus convertedActionUpdateStatus = baseAmqpService.convertMessage(message, DmfActionUpdateStatus.class);
|
||||
@@ -91,7 +91,7 @@ class BaseAmqpServiceTest {
|
||||
@ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 0) })
|
||||
void updateActionStatusWithInvalidJsonContent() {
|
||||
final Message message = createMessage("Invalid Json".getBytes());
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new JacksonJsonMessageConverter());
|
||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new DmfMessageConverter());
|
||||
|
||||
assertThatExceptionOfType(MessageConversionException.class)
|
||||
.as("Expected MessageConversionException for invalid JSON")
|
||||
@@ -111,5 +111,4 @@ class BaseAmqpServiceTest {
|
||||
private DmfActionUpdateStatus createActionStatus() {
|
||||
return new DmfActionUpdateStatus(1L, DmfActionStatus.RUNNING, null, 2L, List.of("Message 1", "Message 2"), 2);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.listener.FatalExceptionStrategy;
|
||||
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,5 +30,22 @@
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- DmfMessageConverter dependencies -->
|
||||
<dependency>
|
||||
<groupId>tools.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-beans</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.amqp</groupId>
|
||||
<artifactId>spring-amqp</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Contributors to the Eclipse Foundation
|
||||
*
|
||||
* This program and the accompanying materials are made
|
||||
* available under the terms of the Eclipse Public License 2.0
|
||||
* which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.eclipse.hawkbit.dmf;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.amqp.core.Message;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
// - since spring-amqp 4.0.4 not all packages are assumed trusted for type converter (only java.land and java.util)
|
||||
// so e need to add hawkbit DMF model package (and eventual extension packages) as trusted
|
||||
// - also (again since spring-amqp 4.0.4) the conversion from empty payload fail (which probably is fine since it is JSON)
|
||||
// however, (for backward compatibility, e.g. THING_REMOVED doesn't define payload and could be empty byte[]) we assume that
|
||||
// empty payload is empty byte[] and not try to convert it to Object (which fail since it is not JSON)
|
||||
public class DmfMessageConverter extends JacksonJsonMessageConverter {
|
||||
|
||||
private static final String DMF_JSON_MODEL_PACKAGE = "org.eclipse.hawkbit.dmf.json.model";
|
||||
|
||||
/**
|
||||
* Constructor unsing default {@link JsonMapper}, i.e. <code>new JsonMapper()</code>
|
||||
*
|
||||
* @param trustedPackagesExt {@link DmfMessageConverter} always trust {@link #DMF_JSON_MODEL_PACKAGE}. If any additional packages
|
||||
* shall be trusted provided here
|
||||
*/
|
||||
public DmfMessageConverter(final String... trustedPackagesExt) {
|
||||
this(new JsonMapper(), trustedPackagesExt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor with specified {@link JsonMapper}
|
||||
*
|
||||
* @param jsonMapper the {@link JsonMapper} to use for conversion
|
||||
* @param trustedPackagesExt {@link DmfMessageConverter} always trust {@link #DMF_JSON_MODEL_PACKAGE}. If any additional packages
|
||||
* shall be trusted provided here
|
||||
*/
|
||||
public DmfMessageConverter(final JsonMapper jsonMapper, final String... trustedPackagesExt) {
|
||||
super(
|
||||
jsonMapper,
|
||||
trustedPackagesExt == null || trustedPackagesExt.length == 0
|
||||
? new String[] { DMF_JSON_MODEL_PACKAGE }
|
||||
: Stream.concat(Stream.of(DMF_JSON_MODEL_PACKAGE), Arrays.stream(trustedPackagesExt))
|
||||
.distinct()
|
||||
.toArray(String[]::new));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Object fromMessage(@NonNull final Message message, final @Nullable Object conversionHint) {
|
||||
// default converter tries to convert empty body payload to Object (since rabbit 4.0.4)
|
||||
// which probably is correct since it has to be JSON - however, in this case we assume - empty byte[]
|
||||
if (message.getBody().length == 0) {
|
||||
return message.getBody();
|
||||
} else {
|
||||
return super.fromMessage(message, conversionHint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.awaitility.core.ConditionFactory;
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.repository.jpa.JpaRepositoryConfiguration;
|
||||
import org.eclipse.hawkbit.repository.test.TestConfiguration;
|
||||
import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest;
|
||||
@@ -25,7 +26,6 @@ import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.rabbit.junit.RabbitAvailable;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.annotation.DirtiesContext.ClassMode;
|
||||
@@ -88,7 +88,7 @@ public abstract class AbstractAmqpIntegrationTest extends AbstractIntegrationTes
|
||||
|
||||
private RabbitTemplate createDmfClient() {
|
||||
final RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||
template.setMessageConverter(new JacksonJsonMessageConverter());
|
||||
template.setMessageConverter(new DmfMessageConverter());
|
||||
template.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||
template.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||
template.setExchange(getExchange());
|
||||
|
||||
@@ -14,9 +14,9 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
@@ -32,7 +32,7 @@ public class AmqpTestConfiguration {
|
||||
@Primary
|
||||
public RabbitTemplate rabbitTemplateForTest(final ConnectionFactory connectionFactory) {
|
||||
final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
|
||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter());
|
||||
rabbitTemplate.setMessageConverter(new DmfMessageConverter());
|
||||
rabbitTemplate.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||
rabbitTemplate.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||
return rabbitTemplate;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
package org.eclipse.hawkbit.autoconfigure.dmf.amqp;
|
||||
|
||||
import org.eclipse.hawkbit.amqp.DmfApiConfiguration;
|
||||
import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler;
|
||||
import org.springframework.amqp.listener.ConditionalRejectingErrorHandler;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@@ -495,16 +495,13 @@ class MgmtDistributionSetTypeResourceTest extends AbstractManagementApiIntegrati
|
||||
|
||||
@Test
|
||||
void getDistributionSetTypesFilteredBySoftDeletedMode() throws Exception {
|
||||
final DistributionSetType activeType = distributionSetTypeManagement.create(
|
||||
DistributionSetTypeManagement.Create.builder()
|
||||
.key("activeKey").name("activeType").build());
|
||||
distributionSetTypeManagement.create(DistributionSetTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
||||
|
||||
// create type + DS using it, then delete type → soft-delete
|
||||
final DistributionSetType deletedType = distributionSetTypeManagement.create(
|
||||
DistributionSetTypeManagement.Create.builder()
|
||||
.key("deletedKey").name("deletedType").build());
|
||||
distributionSetManagement.create(DistributionSetManagement.Create.builder()
|
||||
.type(deletedType).name("ds").version("1.0").build());
|
||||
DistributionSetTypeManagement.Create.builder().key("deletedKey").name("deletedType").build());
|
||||
distributionSetManagement.create(
|
||||
DistributionSetManagement.Create.builder().type(deletedType).name("ds").version("1.0").build());
|
||||
distributionSetTypeManagement.delete(deletedType.getId());
|
||||
|
||||
// default — built-in + activeType, no deletedType
|
||||
|
||||
@@ -1230,8 +1230,7 @@ class MgmtSoftwareModuleResourceTest extends AbstractManagementApiIntegrationTes
|
||||
|
||||
@Test
|
||||
void getSoftwareModulesFilteredBySoftDeletedMode() throws Exception {
|
||||
final SoftwareModule activeSm = softwareModuleManagement.create(
|
||||
SoftwareModuleManagement.Create.builder().type(osType).name("activeSm").version("1.0").build());
|
||||
softwareModuleManagement.create(SoftwareModuleManagement.Create.builder().type(osType).name("activeSm").version("1.0").build());
|
||||
|
||||
SoftwareModule deletedSm = softwareModuleManagement.create(
|
||||
SoftwareModuleManagement.Create.builder().type(osType).name("deletedSm").version("1.0").build());
|
||||
|
||||
@@ -494,8 +494,7 @@ public class MgmtSoftwareModuleTypeResourceTest extends AbstractManagementApiInt
|
||||
// 3 built-in types exist (os, runtime, application)
|
||||
final int builtInTypes = 3;
|
||||
|
||||
final SoftwareModuleType activeType = softwareModuleTypeManagement.create(
|
||||
SoftwareModuleTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
||||
softwareModuleTypeManagement.create(SoftwareModuleTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
||||
|
||||
// create type + SM using it, then delete type → soft-delete
|
||||
final SoftwareModuleType deletedType = softwareModuleTypeManagement.create(
|
||||
|
||||
@@ -155,10 +155,9 @@ public class SpecificationBuilder<T> {
|
||||
String.format("Operator %s is not supported for map fields with value null", op));
|
||||
};
|
||||
} else {
|
||||
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
|
||||
return isNot(op)
|
||||
? compare(comparison, toMapValuePath(pathResolver.getJoinOnInner(attribute, split[1])))
|
||||
: cb.and(equal(mapPath.key(), split[1]), compare(comparison, toMapValuePath(mapPath)));
|
||||
// map entry with key not null (exist) - use left join per key, key/value filtered in where
|
||||
final MapJoin<?, ?, ?> mapJoin = pathResolver.getJoinForWhere(attribute, split[1]);
|
||||
return cb.and(equal(mapJoin.key(), split[1]), compare(comparison, toMapValuePath(mapJoin)));
|
||||
}
|
||||
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
|
||||
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
|
||||
@@ -437,8 +436,8 @@ public class SpecificationBuilder<T> {
|
||||
return getCollectionPathResolver(attribute.getName()).getJoinOn(value);
|
||||
}
|
||||
|
||||
private MapJoin<?, ?, ?> getJoinOnInner(final Attribute<?, ?> attribute, final Object value) {
|
||||
return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value);
|
||||
private MapJoin<?, ?, ?> getJoinForWhere(final Attribute<?, ?> attribute, final Object mapKeyName) {
|
||||
return getCollectionPathResolver(attribute.getName()).getJoinForWhere(mapKeyName);
|
||||
}
|
||||
|
||||
private Map<String, Integer> getState() {
|
||||
@@ -463,7 +462,7 @@ public class SpecificationBuilder<T> {
|
||||
@Setter
|
||||
private int pos;
|
||||
private final Map<Object, MapJoin<?, ?, ?>> joinOnCache = new HashMap<>();
|
||||
private final Map<Object, MapJoin<?, ?, ?>> joinOnInnerCache = new HashMap<>();
|
||||
private final Map<Object, MapJoin<?, ?, ?>> joinForWhereCache = new HashMap<>();
|
||||
|
||||
private CollectionPathResolver(final String attributeName) {
|
||||
this.attributeName = attributeName;
|
||||
@@ -488,12 +487,9 @@ public class SpecificationBuilder<T> {
|
||||
});
|
||||
}
|
||||
|
||||
private MapJoin<?, ?, ?> getJoinOnInner(final Object value) {
|
||||
return joinOnInnerCache.computeIfAbsent(value, k -> {
|
||||
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.INNER);
|
||||
mapPath.on(equal(mapPath.key(), k));
|
||||
return mapPath;
|
||||
});
|
||||
private MapJoin<?, ?, ?> getJoinForWhere(final Object mapKeyName) {
|
||||
return joinForWhereCache.computeIfAbsent(mapKeyName, k ->
|
||||
(MapJoin<?, ?, ?>) root.join(attributeName, JoinType.LEFT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,10 +308,25 @@ class SpecificationBuilderTest {
|
||||
|
||||
assertThat(filter("subMap.x==*tx and subMap.y==rooty")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x==*tx and subMap.y!=rootx")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x==*tx or subMap.x==rooty"))
|
||||
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
|
||||
assertThat(filter("subMap.x==*tx or subMap.x!=rootx"))
|
||||
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
|
||||
assertThat(filter("subMap.x==*tx or subMap.x==rooty")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
|
||||
assertThat(filter("subMap.x==*tx or subMap.x!=rootx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
|
||||
|
||||
assertThat(filter("subMap.x=out=rootx and subMap.y=out=rooty")).hasSize(1).containsExactlyInAnyOrder(root4);
|
||||
assertThat(filter("subMap.x!=rootx and subMap.y!=rooty")).hasSize(1).containsExactlyInAnyOrder(root4);
|
||||
assertThat(filter("subMap.x=out=(rootx, rooty) and subMap.y=out=(rootx, rooty)")).isEmpty();
|
||||
assertThat(filter("subMap.x=out=rootx and subMap.y=out=rooty and subMap.x=out=rooty")).isEmpty();
|
||||
assertThat(filter("subMap.x==rooty and subMap.y=out=rooty")).hasSize(1).containsExactlyInAnyOrder(root4);
|
||||
|
||||
assertThat(filter("subMap.x==rootx and subMap.y==rooty")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x==rootx and subMap.y=in=(rooty, rootz)")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x==rootx and subMap.y==rooty and subMap.x==rooty")).isEmpty();
|
||||
assertThat(filter("subMap.x==rooty and subMap.y==rootx")).hasSize(1).containsExactlyInAnyOrder(root4);
|
||||
|
||||
assertThat(filter("subMap.x=in=(rootx) and subMap.y=in=(rooty)")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x=in=(rootx,rooty) and subMap.y=in=(rootx,rooty)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
|
||||
assertThat(filter("subMap.x=in=(rootx) and subMap.y=in=(rooty) and subMap.x=in=(rooty)")).isEmpty();
|
||||
assertThat(filter("subMap.x=out=rooty and subMap.y=out=rootx")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
assertThat(filter("subMap.x=out=rooty and subMap.y!=rootx")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -61,10 +61,6 @@ public class DistributedLockRepository extends DefaultLockRepository {
|
||||
timeToLive * lockProperties.getRefreshOnRemainPercent() / 100);
|
||||
final int refreshAfterMS = timeToLive - triggerOnRemainMS;
|
||||
refreshAfterMillis = refreshAfterMS <= 0 ? null : refreshAfterMS;
|
||||
|
||||
// to ensure that deprecated acquire and renew will use the lockProperties ttl. none shall call them but anyway - for sure
|
||||
// to be removed when the #setTimeToLive method is removed from DefaultLockRepository
|
||||
setTimeToLive(lockProperties.getTtl());
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
|
||||
@@ -114,6 +114,10 @@ class RsqlToSqlTest {
|
||||
"((attribute.key1==00 or attribute.key1==01) and (attribute.key2==02 or attribute.key2==01) and attribute.key3==03 and updateStatus!=pending)");
|
||||
print(JpaTarget.class, TargetFields.class,
|
||||
"((attribute.key1==00 or attribute.key1==01) and (attribute.key2==02 or attribute.key2==01) and attribute.key3==01 and updateStatus!=pending)");
|
||||
print(JpaTarget.class, TargetFields.class, "attribute.sw_1_version=in=('test1','test2') and attribute.sw_2_version=in=('test3','test4') and attribute.sw_3_version=in=('test5','test6')");
|
||||
print(JpaTarget.class, TargetFields.class, "attribute.sw_1_version=out=('test1','test2') and attribute.sw_2_version=out=('test3','test4') and attribute.sw_3_version=out=('test5','test6')");
|
||||
print(JpaTarget.class, TargetFields.class, "metadata.key1=in=(value1,value2) and metadata.key2=out=(value3,value4) and metadata.key3=out=(value3,value4)");
|
||||
print(JpaTarget.class, TargetFields.class, "metadata.key1=out=(value1,value2) and metadata.key2=out=(value3,value4) and metadata.key3=out=(value3,value4)");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.EventTopic;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
||||
@@ -40,7 +41,6 @@ import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
||||
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
|
||||
/**
|
||||
* Abstract class for sender and receiver service.
|
||||
@@ -60,7 +60,7 @@ public final class VHost extends DmfSender implements MessageListener {
|
||||
// It is necessary to define rabbitTemplate as a Bean and set JacksonJsonMessageConverter explicitly here in order to convert only
|
||||
// OUTCOMING messages to JSON. In case of INCOMING messages, JacksonJsonMessageConverter can not handle messages with NULL
|
||||
// payload (e.g. REQUEST_ATTRIBUTES_UPDATE), so the SimpleMessageConverter is used instead per default.
|
||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter());
|
||||
rabbitTemplate.setMessageConverter(new DmfMessageConverter());
|
||||
|
||||
if (initVHost) {
|
||||
final RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory);
|
||||
|
||||
3
hawkbit-ui/.gitignore
vendored
3
hawkbit-ui/.gitignore
vendored
@@ -7,4 +7,5 @@ package-lock.json
|
||||
tsconfig.json
|
||||
types.d.ts
|
||||
vite.config.ts
|
||||
vite.generated.ts
|
||||
vite.generated.ts
|
||||
*.lock
|
||||
@@ -52,6 +52,8 @@ public final class TargetActionsHistory extends Grid<TargetActionsHistory.Action
|
||||
public TargetActionsHistory(final HawkbitMgmtClient hawkbitClient, TargetView.TargetActionsHistoryLayout.ActionStepsGrid actionStepsGrid) {
|
||||
this.hawkbitClient = hawkbitClient;
|
||||
setWidthFull();
|
||||
setMinHeight("200px");
|
||||
setAllRowsVisible(true);
|
||||
addColumn(new ComponentRenderer<>(ActionStatusEntry::getStatusIcon)).setHeader(STATUS).setAutoWidth(true).setFlexGrow(0);
|
||||
addColumn(ActionStatusEntry::getDistributionSetName).setHeader("Distribution Set").setAutoWidth(true);
|
||||
addColumn(Utils.localDateTimeRenderer(ActionStatusEntry::getLastModifiedAt))
|
||||
@@ -73,25 +75,60 @@ public final class TargetActionsHistory extends Grid<TargetActionsHistory.Action
|
||||
this.actionStepsGrid.setTarget(target);
|
||||
}
|
||||
|
||||
private List<ActionStatusEntry> fetchActions() {
|
||||
return hawkbitClient.getTargetRestApi().getActionHistory(target.getControllerId(), null, 0, 30, null)
|
||||
.getBody()
|
||||
.getContent()
|
||||
.stream()
|
||||
.map(action -> new ActionStatusEntry(action, () -> setItems(fetchActions())))
|
||||
.filter(value -> value.action != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
List<ActionStatusEntry> actionStatusEntries = fetchActions();
|
||||
setItems(actionStatusEntries);
|
||||
actionStatusEntries.stream().findFirst().ifPresentOrElse(e -> {
|
||||
// select first action in the list by default
|
||||
public void loadData() {
|
||||
if (target == null) return;
|
||||
List<ActionStatusEntry> items;
|
||||
try {
|
||||
items = fetchActions();
|
||||
} catch (Exception e) {
|
||||
log.error("loadData: failed to fetch actions for target {}", target.getControllerId(), e);
|
||||
setItems(List.of());
|
||||
actionStepsGrid.setActionId(null);
|
||||
return;
|
||||
}
|
||||
log.info("loadData: {} action entries for target {}", items.size(), target.getControllerId());
|
||||
setItems(items);
|
||||
items.stream().findFirst().ifPresentOrElse(e -> {
|
||||
asSingleSelect().setValue(e);
|
||||
actionStepsGrid.setActionId(e.action.getId());
|
||||
}, () -> actionStepsGrid.setActionId(null));
|
||||
}, () -> {
|
||||
log.warn("loadData: no action entries to display");
|
||||
actionStepsGrid.setActionId(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from parent's onAttach or tab-selection listener.
|
||||
* Only triggers if a target has been set.
|
||||
*/
|
||||
public void loadIfReady() {
|
||||
if (target != null) {
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
|
||||
private List<ActionStatusEntry> fetchActions() {
|
||||
final var response = hawkbitClient.getTargetRestApi().getActionHistory(target.getControllerId(), null, 0, 30, null);
|
||||
log.info("fetchActions: controllerId={}, httpStatus={}, body={}",
|
||||
target.getControllerId(),
|
||||
response.getStatusCode(),
|
||||
response.getBody() != null ? "present (" + response.getBody().getContent().size() + " actions)" : "NULL");
|
||||
if (response.getBody() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return response.getBody()
|
||||
.getContent()
|
||||
.stream()
|
||||
.map(action -> {
|
||||
try {
|
||||
return new ActionStatusEntry(action, () -> setItems(fetchActions()));
|
||||
} catch (Exception e) {
|
||||
log.error("fetchActions: failed to create ActionStatusEntry for action id={}", action.getId(), e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(value -> value != null && value.action != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
protected class ActionStatusEntry {
|
||||
@@ -101,20 +138,35 @@ public final class TargetActionsHistory extends Grid<TargetActionsHistory.Action
|
||||
MgmtDistributionSet distributionSet;
|
||||
|
||||
public ActionStatusEntry(final MgmtAction mgmtAction, final Runnable onUpdate) {
|
||||
this.action = hawkbitClient.getActionRestApi().getAction(mgmtAction.getId()).getBody();
|
||||
log.info("ActionStatusEntry: fetching action id={}", mgmtAction.getId());
|
||||
final var actionResp = hawkbitClient.getActionRestApi().getAction(mgmtAction.getId());
|
||||
log.info("ActionStatusEntry: getAction(id={}) -> status={}, body={}",
|
||||
mgmtAction.getId(),
|
||||
actionResp.getStatusCode(),
|
||||
actionResp.getBody() != null ? "present" : "NULL");
|
||||
this.action = actionResp.getBody();
|
||||
this.onUpdate = onUpdate;
|
||||
if (action == null) {
|
||||
log.error("Unable to fetch the action with id : {}", mgmtAction.getId());
|
||||
return;
|
||||
}
|
||||
log.info("ActionStatusEntry: action id={} loaded, links={}",
|
||||
action.getId(),
|
||||
action.getLinks().stream().map(l -> l.getRel().value()).toList());
|
||||
this.action.getLink("distributionset").ifPresent(link -> {
|
||||
log.info("ActionStatusEntry: found distributionset link, href={}", link.getHref());
|
||||
try {
|
||||
Long dsId = Long.parseLong(link.getHref().substring(link.getHref().lastIndexOf("/") + 1));
|
||||
this.distributionSet = hawkbitClient.getDistributionSetRestApi().getDistributionSet(dsId).getBody();
|
||||
log.info("ActionStatusEntry: distributionSet loaded, name={}",
|
||||
this.distributionSet != null ? this.distributionSet.getName() : "NULL");
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("Error parsing distribution set ID", e);
|
||||
}
|
||||
});
|
||||
if (this.action.getLink("distributionset").isEmpty()) {
|
||||
log.warn("ActionStatusEntry: NO distributionset link found for action id={}", action.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isActive() {
|
||||
|
||||
@@ -12,6 +12,7 @@ package org.eclipse.hawkbit.ui.security;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -50,7 +51,9 @@ class UserDetailsSetter extends OncePerRequestFilter {
|
||||
final Collection<? extends GrantedAuthority> grantedAuthorities = grantedAuthoritiesService.getGrantedAuthorities(authentication);
|
||||
if (authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) {
|
||||
newAuthentication = new OAuth2AuthenticationToken(
|
||||
oAuth2AuthenticationToken.getPrincipal(), grantedAuthorities,
|
||||
// principal is not null, but the method is not @NonNull annotated
|
||||
Objects.requireNonNull(oAuth2AuthenticationToken.getPrincipal()),
|
||||
grantedAuthorities,
|
||||
oAuth2AuthenticationToken.getAuthorizedClientRegistrationId());
|
||||
if (authentication.getPrincipal() instanceof OidcUser user) {
|
||||
// if there is no refresh token and the access token is expired then re-login is required
|
||||
|
||||
@@ -127,6 +127,9 @@ public class DistributionSetView extends TableView<MgmtDistributionSet, Long> {
|
||||
|
||||
private final TextField name = Utils.textField("Name");
|
||||
|
||||
private static final java.util.regex.Pattern RSQL_OP =
|
||||
java.util.regex.Pattern.compile("==|!=|=like=|=unlike=|=gt=|=ge=|=lt=|=le=|=in=|=out=");
|
||||
|
||||
private DistributionSetRawFilter() {
|
||||
name.setPlaceholder("<rsql filter>");
|
||||
}
|
||||
@@ -138,13 +141,25 @@ public class DistributionSetView extends TableView<MgmtDistributionSet, Long> {
|
||||
|
||||
@Override
|
||||
public String filter() {
|
||||
return name.getOptionalValue().orElse(null);
|
||||
return name.getOptionalValue().map(raw -> {
|
||||
if (RSQL_OP.matcher(raw).find()) {
|
||||
return raw;
|
||||
}
|
||||
// plain text: wrap as wildcard search on name and version
|
||||
return "(name==*" + encodeRsqlValue(raw) + "*,version==*" + encodeRsqlValue(raw) + "*)";
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFilter(String filter) {
|
||||
name.setValue(filter);
|
||||
}
|
||||
|
||||
private static String encodeRsqlValue(String value) {
|
||||
return value.replace("\\", "\\\\")
|
||||
.replace("*", "\\*")
|
||||
.replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
private static class DistributionSetFilter implements Filter.Rsql {
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.component.select.Select;
|
||||
import com.vaadin.flow.component.tabs.Tab;
|
||||
import com.vaadin.flow.component.tabs.TabSheet;
|
||||
import com.vaadin.flow.component.textfield.TextArea;
|
||||
import com.vaadin.flow.component.textfield.TextField;
|
||||
@@ -330,9 +331,24 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
||||
return List.of(layout);
|
||||
}
|
||||
|
||||
private static final java.util.regex.Pattern RSQL_OP =
|
||||
java.util.regex.Pattern.compile("==|!=|=like=|=unlike=|=gt=|=ge=|=lt=|=le=|=in=|=out=");
|
||||
|
||||
@Override
|
||||
public String filter() {
|
||||
return textFilter.getOptionalValue().orElse(null);
|
||||
return textFilter.getOptionalValue().map(raw -> {
|
||||
if (RSQL_OP.matcher(raw).find()) {
|
||||
return raw;
|
||||
}
|
||||
// plain text: wrap as wildcard search on controllerId and name
|
||||
return "(controllerid==*" + encodeRsqlValue(raw) + "*,name==*" + encodeRsqlValue(raw) + "*)";
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
private static String encodeRsqlValue(String value) {
|
||||
return value.replace("\\", "\\\\")
|
||||
.replace("*", "\\*")
|
||||
.replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -352,9 +368,11 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
||||
private final TargetTags targetTags;
|
||||
private final TargetMetadata targetMetadata;
|
||||
private final TargetActionsHistoryLayout targetActionsHistoryLayout;
|
||||
private final TabSheet tabSheet;
|
||||
private final Tab actionHistoryTab;
|
||||
|
||||
private TargetDetailedView(final HawkbitMgmtClient hawkbitClient) {
|
||||
final TabSheet tabSheet = new TabSheet();
|
||||
tabSheet = new TabSheet();
|
||||
tabSheet.setWidthFull();
|
||||
targetId = new Span();
|
||||
targetDetails = new TargetDetails(hawkbitClient);
|
||||
@@ -369,8 +387,16 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
||||
tabSheet.add("Assigned / Installed", targetAssignedInstalled);
|
||||
tabSheet.add("Tags", targetTags);
|
||||
tabSheet.add("Metadata", targetMetadata);
|
||||
tabSheet.add("Action History", targetActionsHistoryLayout);
|
||||
actionHistoryTab = tabSheet.add("Action History", targetActionsHistoryLayout);
|
||||
add(tabSheet);
|
||||
|
||||
// Load action history data when the tab becomes visible.
|
||||
// onAttach handles the first time; this listener handles subsequent tab switches.
|
||||
tabSheet.addSelectedChangeListener(e -> {
|
||||
if (e.getSelectedTab() == actionHistoryTab) {
|
||||
targetActionsHistoryLayout.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setItem(final MgmtTarget target) {
|
||||
@@ -685,6 +711,16 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
||||
targetActionsHistory.setItem(target);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
targetActionsHistory.loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
super.onAttach(attachEvent);
|
||||
targetActionsHistory.loadIfReady();
|
||||
}
|
||||
|
||||
public static class ActionStepsGrid extends Grid<ActionStepsGrid.ActionStepEntry> {
|
||||
|
||||
@Serial
|
||||
@@ -715,11 +751,6 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
setItems(fetchActionSteps());
|
||||
}
|
||||
|
||||
public void setActionId(Long id) {
|
||||
actionId = id;
|
||||
setItems(fetchActionSteps());
|
||||
|
||||
746
huaxu/CURL-API.md
Normal file
746
huaxu/CURL-API.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# Hawkbit DDI API — curl 完整操作手册
|
||||
|
||||
基于实际验证的 Hawkbit DDI API v1 交互流程,所有命令均已跑通。
|
||||
|
||||
## 环境信息
|
||||
|
||||
```
|
||||
Hawkbit Server: http://192.168.77.70:8080
|
||||
Tenant: DEFAULT
|
||||
Auth: GatewayToken my-gateway-key (DDI 接口)
|
||||
admin:admin (Management 接口)
|
||||
Controller ID: test-curl-001
|
||||
DistributionSet: id=1 (r3576test:V1.0.0.0)
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
1. [认证配置](#1-认证配置)
|
||||
2. [自动注册与轮询](#2-自动注册与轮询)
|
||||
3. [分配 Distribution Set](#3-分配-distribution-set)
|
||||
4. [拉取部署详情](#4-拉取部署详情)
|
||||
5. [上报设备属性](#5-上报设备属性)
|
||||
6. [下载 artifact](#6-下载-artifact)
|
||||
7. [SHA256 文件校验](#7-sha256-文件校验)
|
||||
8. [上报执行状态](#8-上报执行状态)
|
||||
9. [取消任务](#9-取消任务)
|
||||
10. [确认任务流程](#10-确认任务流程)
|
||||
11. [查询验证](#11-查询验证)
|
||||
12. [完整流程速查](#12-完整流程速查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 认证配置
|
||||
|
||||
### 1.1 开启 GatewayToken 认证(服务端,一次性)
|
||||
|
||||
```bash
|
||||
# 设置 token key
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/system/configs/authentication.gatewaytoken.key" \
|
||||
-d '{"value": "my-gateway-key"}'
|
||||
|
||||
# 启用 gateway token 认证
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 1.2 开启 TargetToken 认证(服务端,一次性)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/system/configs/authentication.targettoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 1.3 查看当前所有认证配置
|
||||
|
||||
```bash
|
||||
curl -s -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/system/configs" \
|
||||
| python3 -m json.tool | grep -E "authentication\.(gateway|target)"
|
||||
```
|
||||
|
||||
### 1.4 认证方式对比
|
||||
|
||||
```
|
||||
DDI 请求头:
|
||||
GatewayToken → Authorization: GatewayToken my-gateway-key
|
||||
TargetToken → Authorization: TargetToken <设备securityToken>
|
||||
|
||||
Management API 请求头:
|
||||
Basic Auth → -u admin:admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 自动注册与轮询
|
||||
|
||||
### 2.1 首次轮询(自动注册)
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}
|
||||
```
|
||||
|
||||
设备 SN 在 Hawkbit 中不存在时,服务端自动调用 `findOrRegisterTargetIfItDoesNotExist()` 创建 target。
|
||||
|
||||
```bash
|
||||
curl -v \
|
||||
-H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Accept: application/json" \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001"
|
||||
```
|
||||
|
||||
**响应(无待处理 action):**
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {"polling": {"sleep": "00:05:00"}},
|
||||
"_links": {
|
||||
"configData": {
|
||||
"href": "http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/configData"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应(有待处理 action):**
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {"polling": {"sleep": "00:05:00"}},
|
||||
"_links": {
|
||||
"deploymentBase": {
|
||||
"href": "http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/deploymentBase/15?c=424529173"
|
||||
},
|
||||
"configData": {
|
||||
"href": "http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/configData"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 响应中的 HAL 链接含义
|
||||
|
||||
| 链接 rel | 含义 | 下一步动作 |
|
||||
|----------|------|----------|
|
||||
| `deploymentBase` | 有待执行的部署任务 | 拉取部署详情 → 下载 → 安装 |
|
||||
| `cancelAction` | 有取消指令 | 确认取消 |
|
||||
| `confirmationBase` | 有需要确认的任务 | 发送确认 |
|
||||
| `configData` | 服务端要求上报属性 | PUT 属性数据 |
|
||||
| `installedBase` | 有历史安装记录 | 可查询 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 分配 Distribution Set
|
||||
|
||||
```
|
||||
POST /rest/v1/distributionsets/{distributionSetId}/assignedTargets
|
||||
```
|
||||
|
||||
> ⚠️ 这是 Management API,用 `-u admin:admin` 认证。
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id": "test-curl-001"}]'
|
||||
```
|
||||
|
||||
**可选参数(完整请求体)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "test-curl-001",
|
||||
"type": "forced",
|
||||
"forcetime": 1682408575278,
|
||||
"weight": 1000,
|
||||
"maintenanceWindow": {
|
||||
"schedule": "00:00-04:00",
|
||||
"duration": "02:00:00",
|
||||
"timezone": "+08:00"
|
||||
},
|
||||
"confirmationRequired": false
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:`200 OK`,返回 `MgmtTargetAssignmentResponseBody`。
|
||||
|
||||
分配成功后,设备下次轮询就会拿到 `deploymentBase` 链接。
|
||||
|
||||
---
|
||||
|
||||
## 4. 拉取部署详情
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Accept: application/json" \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/deploymentBase/15"
|
||||
```
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "15",
|
||||
"deployment": {
|
||||
"download": "forced",
|
||||
"update": "forced",
|
||||
"chunks": [
|
||||
{
|
||||
"part": "os",
|
||||
"version": "V1.0.0.0",
|
||||
"name": "r3576-OS-android",
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "update.zip",
|
||||
"hashes": {
|
||||
"sha1": "223377ea691bd7fc47865cccfd802771ff1b3fca",
|
||||
"md5": "28a17f5fb87d637756c0cc5d555d69b7",
|
||||
"sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
|
||||
},
|
||||
"size": 195411,
|
||||
"_links": {
|
||||
"download-http": {
|
||||
"href": "http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
},
|
||||
"md5sum-http": {
|
||||
"href": "http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | action ID,后续反馈操作的关键参数 |
|
||||
| `deployment.download` | `forced`=强制下载 / `attempt`=尝<><E5B09D><EFBFBD>下载 / `skip`=跳过 |
|
||||
| `deployment.update` | `forced`=强制安装 / `attempt`=尝试安装 / `skip`=跳过 |
|
||||
| `chunks[].part` | 软件模块类型(os / app / …) |
|
||||
| `chunks[].version` | 软件版本号 |
|
||||
| `chunks[].artifacts[].filename` | 要下载的文件名 |
|
||||
| `chunks[].artifacts[].hashes` | SHA1 / MD5 / SHA256 校验值 |
|
||||
| `chunks[].artifacts[].size` | 文件大小(bytes) |
|
||||
| `_links.download-http.href` | **下载地址** |
|
||||
|
||||
### download / update handlingType 组合
|
||||
|
||||
| download | update | 含义 |
|
||||
|----------|--------|------|
|
||||
| `forced` | `forced` | 立即下载并安装 |
|
||||
| `attempt` | `attempt` | 设备自行决定是否下载/安装 |
|
||||
| `forced` | `skip` | 只下载不安装(download only) |
|
||||
| `skip` | `skip` | maintenance window 未到,等待下次轮询 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 上报设备属性
|
||||
|
||||
```
|
||||
PUT /{tenant}/controller/v1/{controllerId}/configData
|
||||
```
|
||||
|
||||
> ⚠️ 必须先轮询一次让 target 自动注册,再上报属性,否则 404。
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" -X PUT \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/configData" \
|
||||
-d '{
|
||||
"mode": "merge",
|
||||
"data": {
|
||||
"osType": "linux",
|
||||
"platform": "x86_64",
|
||||
"kernel": "6.17.0-35-generic",
|
||||
"hostname": "test-curl-001",
|
||||
"hwRevision": "VMware Virtual Platform",
|
||||
"manufacturer": "VMware, Inc.",
|
||||
"swVersion": "V1.0.0.0"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### mode 说明
|
||||
|
||||
| mode | 行为 |
|
||||
|------|------|
|
||||
| `merge` | 合并:只更新传入的 key,保留已有属性(推荐) |
|
||||
| `replace` | 替换:完全覆盖已有属性 |
|
||||
| `remove` | 删除:移除传入的 key |
|
||||
|
||||
**响应**:`200 OK`(空 body)
|
||||
|
||||
### 查询设备属性
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/attributes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 下载 artifact
|
||||
|
||||
```
|
||||
GET /{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/{fileName}
|
||||
```
|
||||
|
||||
### 6.1 完整下载
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-o /tmp/test-update.zip \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
```
|
||||
|
||||
**响应**:`200 OK`,二进制流。
|
||||
|
||||
### 6.2 断点续传(Range 请求)
|
||||
|
||||
```bash
|
||||
# 续传:从 byte 1048576 开始
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Range: bytes=1048576-" \
|
||||
-o /tmp/test-update.zip.part --append \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip"
|
||||
```
|
||||
|
||||
**响应**:`206 Partial Content`,`Content-Range: bytes 1048576-195410/195411`。
|
||||
|
||||
### 6.3 条件请求(If-Match)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "If-Match: 223377ea691bd7fc47865cccfd802771ff1b3fca" \
|
||||
-o /tmp/test-update.zip \
|
||||
"http://..."
|
||||
# SHA1 不匹配 → 412 Precondition Failed
|
||||
```
|
||||
|
||||
### 6.4 下载 MD5SUM
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/softwaremodules/1/artifacts/update.zip.MD5SUM"
|
||||
```
|
||||
|
||||
**响应**:`28a17f5fb87d637756c0cc5d555d69b7 update.zip`
|
||||
|
||||
### 6.5 从 HAL 链接提取下载地址
|
||||
|
||||
artifact 的 `_links` 中有两个下载链接,优先级:
|
||||
|
||||
```
|
||||
download → HTTPS(如果有,首选)
|
||||
download-http → HTTP(回退方案)
|
||||
md5sum-http → MD5 校验文件(HTTP)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SHA256 文件校验
|
||||
|
||||
### 7.1 计算本地文件 SHA256
|
||||
|
||||
```bash
|
||||
sha256sum /tmp/test-update.zip
|
||||
```
|
||||
|
||||
### 7.2 对比部署详情中的哈希值
|
||||
|
||||
```bash
|
||||
# 部署详情中的 SHA256:
|
||||
# "sha256": "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530"
|
||||
|
||||
echo "438b8f9e2af3a3334dc72272d167e0deda8c6e65bb1585202151b3cb866bb530 /tmp/test-update.zip" | sha256sum -c
|
||||
# → /tmp/test-update.zip: OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 上报执行状态
|
||||
|
||||
```
|
||||
POST /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback
|
||||
```
|
||||
|
||||
### 8.1 状态上报 JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1750000000000,
|
||||
"status": {
|
||||
"execution": "download",
|
||||
"result": {
|
||||
"finished": "none",
|
||||
"progress": {"cnt": 2, "of": 5}
|
||||
},
|
||||
"code": 200,
|
||||
"details": ["Starting download"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 四步标准流程
|
||||
|
||||
```bash
|
||||
# 定义变量,方便重复使用
|
||||
D="http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/deploymentBase/15/feedback"
|
||||
A="Authorization: GatewayToken my-gateway-key"
|
||||
J="Content-Type: application/json"
|
||||
|
||||
# ① download — 开始下载
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting download"]}}' \
|
||||
"$D"
|
||||
|
||||
# ② downloaded — 下载完成
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded update.zip"]}}' \
|
||||
"$D"
|
||||
|
||||
# ③ proceeding — 安装中
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing update"]}}' \
|
||||
"$D"
|
||||
|
||||
# ④ closed — 最终结果
|
||||
# 成功:
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' \
|
||||
"$D"
|
||||
|
||||
# 失败:
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"failure"},"details":["Installation failed"],"code":500}}' \
|
||||
"$D"
|
||||
```
|
||||
|
||||
### 8.3 execution 枚举值
|
||||
|
||||
| execution | Hawkbit Status | 说明 |
|
||||
|-----------|---------------|------|
|
||||
| `download` | `DOWNLOAD` | 开始下载 |
|
||||
| `downloaded` | `DOWNLOADED` | 下载完成 |
|
||||
| `proceeding` | `RUNNING` | 安装进行中 |
|
||||
| `closed` | `FINISHED` / `ERROR` | 最终状态(配合 result.finished) |
|
||||
| `canceled` | `CANCELED` | 已取消 |
|
||||
| `rejected` | `WARNING` | 已拒绝 |
|
||||
| `scheduled` | `RUNNING` | 已计划 |
|
||||
| `resumed` | `RUNNING` | 恢复执行 |
|
||||
|
||||
### 8.4 result.finished 枚举值
|
||||
|
||||
| finished | 含义 |
|
||||
|----------|------|
|
||||
| `none` | 尚未完成(中间状态) |
|
||||
| `success` | 执行成功 |
|
||||
| `failure` | 执行失败 |
|
||||
|
||||
### 8.5 带进度的上报
|
||||
|
||||
```bash
|
||||
# 进度: 第2个/总共5个 artifact
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none","progress":{"cnt":2,"of":5}},"details":["Downloading artifact 2/5"]}}' \
|
||||
"$D"
|
||||
```
|
||||
|
||||
### 8.6 取消反馈(cancelAction feedback)
|
||||
|
||||
```bash
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Cancel acknowledged"]}}' \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
|
||||
```
|
||||
|
||||
### 8.7 确认反馈(confirmationBase feedback)
|
||||
|
||||
```bash
|
||||
curl -H "$A" -H "$J" \
|
||||
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
|
||||
```
|
||||
|
||||
### 8.8 常见错误
|
||||
|
||||
| HTTP 状态码 | 原因 | 解决 |
|
||||
|------------|------|------|
|
||||
| `401` | 认证未通过 | 检查 Authorization header 格式 |
|
||||
| `404` | URL 路径错误或 target 不存在 | 确保 `/{tenant}/controller/v1/...` |
|
||||
| `405` | feedback URL 带了 `?c=xxx` 参数 | POST 前剥离查询参数 |
|
||||
| `410 Gone` | action 已不再活跃 | 已经 closed 或 canceled |
|
||||
| `400 Body Not Readable` | JSON 格式错误 | `"final"` 应为 `"finished"`,`"progress":{}` 应为 `null` 或省略 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 取消任务
|
||||
|
||||
### 9.1 服务端发起取消(Management API)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X DELETE \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/actions/15"
|
||||
```
|
||||
|
||||
### 9.2 设备端处理取消
|
||||
|
||||
```bash
|
||||
# ① 轮询拿到 cancelAction
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001"
|
||||
# → _links.cancelAction.href
|
||||
|
||||
# ② 确认取消
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"canceled","result":{"finished":"none"},"details":["Acknowledged"]}}' \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/cancelAction/15/feedback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 确认任务流程
|
||||
|
||||
### 10.1 开启确认流程(服务端)
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/system/configs/user.confirmation.flow.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### 10.2 分配时要求确认
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://192.168.77.70:8080/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id":"test-curl-001","confirmationRequired":true}]'
|
||||
```
|
||||
|
||||
### 10.3 设备端自动确认
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: GatewayToken my-gateway-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"confirmation":"confirmed","code":200,"details":["Auto-confirmed"]}' \
|
||||
"http://192.168.77.70:8080/DEFAULT/controller/v1/test-curl-001/confirmationBase/15/feedback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 查询验证
|
||||
|
||||
### 11.1 查询 target 基本信息
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001"
|
||||
```
|
||||
|
||||
### 11.2 查询 target 所有 action
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/actions"
|
||||
```
|
||||
|
||||
### 11.3 查询指定 action 详情
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/actions/15"
|
||||
```
|
||||
|
||||
### 11.4 查询 action 状态历史
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/actions/15/status"
|
||||
```
|
||||
|
||||
**响应示例(完成全流程后)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{"id":58, "type": "finished", "messages": ["Installation complete"], ...},
|
||||
{"id":57, "type": "running", "messages": ["Installing update"], ...},
|
||||
{"id":56, "type": "downloaded","messages": ["Downloaded update.zip"], ...},
|
||||
{"id":55, "type": "download", "messages": ["Starting download"], ...},
|
||||
{"id":54, "type": "download", "messages": ["Target downloads ..."], ...},
|
||||
{"id":53, "type": "retrieved", "messages": ["Target retrieved ..."], ...},
|
||||
{"id":52, "type": "running", "messages": ["Assignment initiated ..."]}
|
||||
],
|
||||
"total": 7, "size": 7
|
||||
}
|
||||
```
|
||||
|
||||
### 11.5 查询 target 属性
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets/test-curl-001/attributes"
|
||||
```
|
||||
|
||||
### 11.6 查询所有 target
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/targets"
|
||||
```
|
||||
|
||||
### 11.7 查询所有 Distribution Set
|
||||
|
||||
```bash
|
||||
curl -u admin:admin \
|
||||
"http://192.168.77.70:8080/rest/v1/distributionsets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 完整流程速查
|
||||
|
||||
### 12.1 一次完整的 OTA 生命周期(复制粘贴即用)
|
||||
|
||||
```bash
|
||||
# ═══ 变量设置 ═══
|
||||
HOST="http://192.168.77.70:8080"
|
||||
TENANT="DEFAULT"
|
||||
CID="test-curl-001"
|
||||
AUTH="Authorization: GatewayToken my-gateway-key"
|
||||
ADMIN="admin:admin"
|
||||
|
||||
# ═══ Step 1: 轮询(自动注册)═══════════════════════════════════════
|
||||
curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID" | python3 -m json.tool
|
||||
|
||||
# ═══ Step 2: 分配 Distribution Set(Management API)═════════════════
|
||||
curl -u $ADMIN -X POST -H "Content-Type: application/json" \
|
||||
"$HOST/rest/v1/distributionsets/1/assignedTargets" \
|
||||
-d '[{"id": "'$CID'"}]'
|
||||
|
||||
# ═══ Step 3: 轮询(拿到 deploymentBase)═════════════════════════════
|
||||
RESP=$(curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID")
|
||||
echo "$RESP" | python3 -m json.tool
|
||||
|
||||
# 提取 actionId(假设第一次分配,取 deploymentBase URL 中的数字)
|
||||
DEPLOY_URL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['_links']['deploymentBase']['href'])")
|
||||
AID=$(echo "$DEPLOY_URL" | grep -oP 'deploymentBase/\K\d+')
|
||||
echo "Action ID: $AID"
|
||||
|
||||
# ═══ Step 4: 拉取部署详情 ══════════════════════════════════════════
|
||||
DEPLOY=$(curl -s -H "$AUTH" -H "Accept: application/json" \
|
||||
"$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID")
|
||||
echo "$DEPLOY" | python3 -m json.tool
|
||||
|
||||
# 提取下载地址和 SHA256
|
||||
DL_URL=$(echo "$DEPLOY" | python3 -c "
|
||||
import sys,json
|
||||
art = json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]
|
||||
print(art['_links']['download-http']['href'])
|
||||
")
|
||||
SHA256=$(echo "$DEPLOY" | python3 -c "
|
||||
import sys,json
|
||||
print(json.load(sys.stdin)['deployment']['chunks'][0]['artifacts'][0]['hashes']['sha256'])
|
||||
")
|
||||
echo "Download: $DL_URL"
|
||||
echo "SHA256: $SHA256"
|
||||
|
||||
# ═══ Step 5: 上报属性 ══════════════════════════════════════════════
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" -X PUT \
|
||||
"$HOST/$TENANT/controller/v1/$CID/configData" \
|
||||
-d '{"mode":"merge","data":{"osType":"linux","hostname":"'$CID'","swVersion":"V1.0.0.0"}}'
|
||||
|
||||
# ═══ Step 6: 下载 ══════════════════════════════════════════════════
|
||||
curl -H "$AUTH" -o /tmp/update.zip "$DL_URL"
|
||||
|
||||
# ═══ Step 7: 校验 ══════════════════════════════════════════════════
|
||||
echo "$SHA256 /tmp/update.zip" | sha256sum -c
|
||||
|
||||
# ═══ Step 8: 上报四步状态 ══════════════════════════════════════════
|
||||
FB="$HOST/$TENANT/controller/v1/$CID/deploymentBase/$AID/feedback"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"download","result":{"finished":"none"},"details":["Starting"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"downloaded","result":{"finished":"none"},"details":["Downloaded"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"proceeding","result":{"finished":"none"},"details":["Installing"]}}' "$FB"
|
||||
|
||||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"status":{"execution":"closed","result":{"finished":"success"},"details":["Installation complete"],"code":200}}' "$FB"
|
||||
|
||||
# ═══ Step 9: 验证 ══════════════════════════════════════════════════
|
||||
curl -s -u $ADMIN "$HOST/rest/v1/targets/$CID/actions/$AID/status" | python3 -m json.tool
|
||||
|
||||
echo "Done! Action $AID is now finished."
|
||||
```
|
||||
|
||||
### 12.2 Hawkbit 状态流转图
|
||||
|
||||
```
|
||||
管理员分配 DS
|
||||
│
|
||||
▼
|
||||
RUNNING WAITING_FOR_CONFIRMATION
|
||||
│ │
|
||||
▼ │ confirmed
|
||||
RETRIEVED ◄─── deploymentBase ▼
|
||||
│ (轮询返回) RUNNING
|
||||
▼ │
|
||||
DOWNLOAD ...
|
||||
│
|
||||
▼
|
||||
DOWNLOADED
|
||||
│
|
||||
▼
|
||||
PROCEEDING ──→ status: RUNNING
|
||||
│
|
||||
┌───┴───┐
|
||||
▼ ▼
|
||||
FINISHED ERROR ◄─── closed + success/failure
|
||||
(成功) (失败)
|
||||
|
||||
任意阶段 ──cancel──→ CANCELING ──→ CANCELED
|
||||
```
|
||||
|
||||
### 12.3 API 端点速查表
|
||||
|
||||
| 操作 | 方法 | 路径 | 认证 |
|
||||
|------|------|------|------|
|
||||
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | DDI |
|
||||
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | DDI |
|
||||
| 上报反馈 | POST | `.../deploymentBase/{actionId}/feedback` | DDI |
|
||||
| 取消反馈 | POST | `.../cancelAction/{actionId}/feedback` | DDI |
|
||||
| 确认反馈 | POST | `.../confirmationBase/{actionId}/feedback` | DDI |
|
||||
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | DDI |
|
||||
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | DDI |
|
||||
| 下载MD5 | GET | `.../softwaremodules/{id}/artifacts/{file}.MD5SUM` | DDI |
|
||||
| 分配 DS | POST | `/rest/v1/distributionsets/{id}/assignedTargets` | Basic |
|
||||
| 查询 target | GET | `/rest/v1/targets/{controllerId}` | Basic |
|
||||
| 查询 action | GET | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
|
||||
| 查询状态 | GET | `/rest/v1/targets/{controllerId}/actions/{id}/status` | Basic |
|
||||
| 查询属性 | GET | `/rest/v1/targets/{controllerId}/attributes` | Basic |
|
||||
| 取消 action | DELETE | `/rest/v1/targets/{controllerId}/actions/{id}` | Basic |
|
||||
| 配置管理 | GET/PUT/DELETE | `/rest/v1/system/configs/{key}` | Basic |
|
||||
214
huaxu/README.md
Normal file
214
huaxu/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Hawkbit DDI Client
|
||||
|
||||
轻量级纯 Python OTA 更新代理,零依赖(仅 Python 3 标准库),可在 **Android adb shell** 和 **Linux 主机**上运行。
|
||||
|
||||
## 功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **轮询** | `GET /{tenant}/controller/v1/{controllerId}` 查询部署任务,自动适配服务端建议的轮询间隔 |
|
||||
| **断点下载** | HTTP `Range` 请求实现断点续传,临时 `.tmp` 文件保留至下载完成 |
|
||||
| **SHA256 校验** | 下载完成后自动与部署元数据中的哈希值比对 |
|
||||
| **状态上报** | 完整状态链:`download` → `downloaded` → `proceeding` → `closed(success/failure)` |
|
||||
| **设备 SN 检测** | 自动检测序列号:Android `ro.serialno` → DMI `product_serial` → `/etc/machine-id` → hostname |
|
||||
| **设备属性上报** | 首次连接自动上报设备属性(osType, platform, hwRevision, kernel…),供 Hawkbit target filter 匹配 |
|
||||
| **认证** | 支持 `GatewayToken` 和 `TargetToken`,也支持从文件读取:`--gateway-token @/etc/hawkbit/gateway.key` |
|
||||
| **取消/确认** | 处理 `cancelAction`(取消任务)和 `confirmationBase`(headless 自动确认) |
|
||||
|
||||
### 设备属性上报
|
||||
|
||||
脚本在**首次轮询**时自动调用 `PUT …/configData` 上报以下属性:
|
||||
|
||||
| 属性 | 来源 | 示例 |
|
||||
|------|------|------|
|
||||
| `osType` | `/system/build.prop` 或 uname | `android` / `linux` |
|
||||
| `platform` | `platform.machine()` | `aarch64` / `x86_64` |
|
||||
| `kernel` | `os.uname().release` | `6.17.0-35-generic` |
|
||||
| `hwRevision` | DMI product_name + product_version | `VMware Virtual Platform` |
|
||||
| `manufacturer` | DMI sys_vendor 或 Android ro.product.manufacturer | `VMware, Inc.` |
|
||||
| `hostname` | `socket.gethostname()` | `device-001` |
|
||||
| `swVersion` | `/etc/hawkbit/current_version`(可选) | `V1.0.0.0` |
|
||||
|
||||
**扩展属性**:创建 `/etc/hawkbit/device_attrs.json` 添加自定义属性:
|
||||
|
||||
```json
|
||||
{"dept": "production", "region": "east", "owner": "team-a"}
|
||||
```
|
||||
|
||||
这些属性在 Hawkbit 里就是 target 的 attributes,管理员创建 target filter 时可以直接用:
|
||||
|
||||
```
|
||||
属性 osType=android AND hwRevision=VMware → 自动分配 V2.0 更新
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 网关令牌,每 30 秒轮询
|
||||
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
|
||||
|
||||
# 网关令牌从文件读取(生产推荐)
|
||||
echo "s3cret" > /etc/hawkbit/gateway.key
|
||||
chmod 600 /etc/hawkbit/gateway.key
|
||||
python3 ddi-client.py -u https://hawkbit.example.com -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key -i 30
|
||||
|
||||
# 目标令牌,单次轮询
|
||||
python3 ddi-client.py -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
|
||||
|
||||
# 自定义设备 ID + 自定义属性文件,调试模式
|
||||
python3 ddi-client.py -u https://hawkbit:8443 -t prod \
|
||||
--gateway-token @/etc/hawkbit/gateway.key \
|
||||
--controller-id edge-042 -d /data/ota -v
|
||||
```
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```
|
||||
-u, --base-url Hawkbit 服务端地址(必填)
|
||||
-t, --tenant 租户名称,如 DEFAULT(必填)
|
||||
--gateway-token Gateway 安全令牌
|
||||
--target-token Target 安全令牌
|
||||
--controller-id 控制器 ID,默认自动检测设备序列号
|
||||
-d, --download-dir 下载目录,默认 /tmp/hawkbit
|
||||
-i, --polling-interval 轮询间隔(秒),默认 60,可被服务端覆盖
|
||||
--no-verify 跳过 SHA256 校验
|
||||
--no-resume 禁用断点续传
|
||||
--no-ssl-verify 禁用 TLS 证书验证(不安全)
|
||||
--once 仅轮询一次后退出
|
||||
-v, --verbose 调试级日志
|
||||
-h, --help 帮助信息
|
||||
```
|
||||
|
||||
## DDI API 流程
|
||||
|
||||
```
|
||||
┌─────────┐ ① GET /{tenant}/controller/v1/{controllerId} ┌──────────┐
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ 设备 │ ←──── HAL links (deploymentBase / cancel / …) │ Hawkbit │
|
||||
│ │ │ Server │
|
||||
│ │ ② GET …/deploymentBase/{actionId} │ │
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ │ ←──── chunks[].artifacts[] (filename, SHA256, │ │
|
||||
│ │ download URL) │ │
|
||||
│ │ │ │
|
||||
│ │ ③ GET …/softwaremodules/{id}/artifacts/{file} │ │
|
||||
│ │ ────────────────────────────────────────────────────→ │ │
|
||||
│ │ ←──── 二进制流(支持 Range 断点续传) │ │
|
||||
│ │ │ │
|
||||
│ │ ④ POST …/deploymentBase/{actionId}/feedback │ │
|
||||
│ │ ───── {execution, result, details} ────────────────→ │ │
|
||||
│ │ ←──── 200 OK │ │
|
||||
└─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 状态上报 JSON 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1750000000000,
|
||||
"status": {
|
||||
"execution": "download",
|
||||
"result": {
|
||||
"finished": "none"
|
||||
},
|
||||
"details": ["Starting download"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| execution 值 | 含义 | result.finished |
|
||||
|-------------|------|-----------------|
|
||||
| `download` | 开始下载 | `none` |
|
||||
| `downloaded` | 下载完成 | `none` |
|
||||
| `proceeding` | 安装中 | `none` |
|
||||
| `closed` | 最终状态 | `success` / `failure` |
|
||||
| `canceled` | 已取消 | `none` |
|
||||
| `rejected` | 已拒绝 | `none` |
|
||||
|
||||
## 认证配置
|
||||
|
||||
### Target Token(推荐,每设备独立令牌)
|
||||
|
||||
在 Hawkbit Management API 中为目标设置 security token:
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/targets/<controllerId>" \
|
||||
-d '{"securityToken": "my-device-token"}'
|
||||
```
|
||||
|
||||
启用租户级 TargetToken 认证:
|
||||
|
||||
```bash
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.targettoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
### Gateway Token(所有设备共享同一令牌)
|
||||
|
||||
```bash
|
||||
# 设置 token key
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.key" \
|
||||
-d '{"value": "my-gateway-key"}'
|
||||
|
||||
# 启用
|
||||
curl -u admin:admin -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://<hawkbit>:9090/rest/v1/system/configs/authentication.gatewaytoken.enabled" \
|
||||
-d '{"value": true}'
|
||||
```
|
||||
|
||||
## 设备 SN 检测优先级
|
||||
|
||||
| 优先级 | 来源 | 适用平台 |
|
||||
|--------|------|---------|
|
||||
| 1 | `getprop ro.serialno` | Android |
|
||||
| 2 | `/sys/class/dmi/id/product_serial` | Linux (x86) |
|
||||
| 3 | `/sys/devices/virtual/dmi/id/product_serial` | Linux (ARM / 嵌入式) |
|
||||
| 4 | `/etc/machine-id` | systemd Linux |
|
||||
| 5 | `socket.gethostname()` | 通用回退 |
|
||||
|
||||
## 安装钩子
|
||||
|
||||
下载完成后,脚本调用 `_install(files, handling)` 方法执行实际安装。**默认实现仅做文件存在性检查,生产环境必须覆盖此方法**。
|
||||
|
||||
### 方式一:子类继承
|
||||
|
||||
```python
|
||||
from ddi_client import HawkbitDDIClient
|
||||
|
||||
class MyClient(HawkbitDDIClient):
|
||||
def _install(self, files, handling):
|
||||
import subprocess
|
||||
for f in files:
|
||||
subprocess.run(["unzip", "-o", f, "-d", "/data/ota"], check=True)
|
||||
return True
|
||||
```
|
||||
|
||||
### 方式二:Monkey-Patch
|
||||
|
||||
```python
|
||||
client = HawkbitDDIClient(...)
|
||||
client._install = lambda files, h: do_my_install(files)
|
||||
```
|
||||
|
||||
## 兼容性
|
||||
|
||||
- Python ≥ 3.7
|
||||
- Android (adb shell, 需安装 Python)
|
||||
- Linux (x86_64 / aarch64 / armv7l)
|
||||
- macOS
|
||||
- Hawkbit DDI API v1
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
ddi-client.py # 脚本本体(单文件,可直接部署)
|
||||
```
|
||||
|
||||
无其他依赖文件。
|
||||
902
huaxu/RUNBOOK.md
Normal file
902
huaxu/RUNBOOK.md
Normal file
@@ -0,0 +1,902 @@
|
||||
# Hawkbit DDI Client — 运行流程文档
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体架构](#1-整体架构)
|
||||
2. [首次注册流程](#2-首次注册流程)
|
||||
3. [守护进程轮询流程](#3-守护进程轮询流程)
|
||||
4. [单次轮询流程](#4-单次轮询流程)
|
||||
5. [OTA 部署流程](#5-ota-部署流程)
|
||||
6. [认证流程](#6-认证流程)
|
||||
7. [取消任务流程](#7-取消任务流程)
|
||||
8. [确认任务流程](#8-确认任务流程)
|
||||
9. [断点续传流程](#9-断点续传流程)
|
||||
10. [错误处理流程](#10-错误处理流程)
|
||||
11. [安装钩子定制](#11-安装钩子定制)
|
||||
12. [部署与运维](#12-部署与运维)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ddi-client.py │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 属性检测 │ │ 认证模块 │ │ 轮询模块 │ │ 安装钩子 │ │
|
||||
│ │ (SN/OS/ │ │ Gateway/ │ │ Poll/ │ │ _install()│ │
|
||||
│ │ HW/Kernel│ │ Target │ │ Deploy/ │ │ 可覆盖 │ │
|
||||
│ │ ...) │ │ Token │ │ Download │ │ │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP 层 │ │
|
||||
│ │ (urllib + SSL context + Range 支持) │ │
|
||||
│ └──────────────────────┬───────────────────────────┘ │
|
||||
└─────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Hawkbit Server │
|
||||
│ DDI API v1 (9090) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
属性文件 ──┐
|
||||
├──→ 属性检测 ──→ PUT /configData ──→ Hawkbit 记录 target attributes
|
||||
SN 检测 ──┘
|
||||
|
||||
轮询 ──→ GET /controller/v1/{SN}
|
||||
←── deploymentBase / cancelAction / confirmationBase / configData / 无
|
||||
|
||||
部署 ──→ GET /deploymentBase/{id} ←── chunks[].artifacts[]
|
||||
下载 ──→ GET /softwaremodules/{id}/... ←── 二进制流 (Range 支持)
|
||||
反馈 ──→ POST /feedback ←── 200 OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 首次注册流程
|
||||
|
||||
**触发条件**:设备 SN 在 Hawkbit 中不存在(或脚本首次连接后重启)
|
||||
|
||||
```
|
||||
启动参数:
|
||||
ddi-client.py -u http://192.168.77.70:8080 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 1: 读取 Gateway Token
|
||||
═══════════════════════════════════════════════════════════════
|
||||
输入: @/etc/hawkbit/gateway.key
|
||||
动作: open("/etc/hawkbit/gateway.key") → read() → strip()
|
||||
输出: Authorization: GatewayToken <文件内容>
|
||||
|
||||
备选: --gateway-token <明文key> 直接传入
|
||||
--target-token <明文token> 直接传入
|
||||
--target-token @/etc/hawkbit/target.token 文件读取
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 2: 检测设备 SN
|
||||
═══════════════════════════════════════════════════════════════
|
||||
优先级:
|
||||
① getprop ro.serialno → Android 序列号
|
||||
② /sys/class/dmi/id/product_serial → Linux DMI (x86)
|
||||
③ /sys/devices/virtual/dmi/id/... → Linux DMI (ARM/嵌入式)
|
||||
④ /etc/machine-id → systemd 机器 ID
|
||||
⑤ socket.gethostname() → 兜底
|
||||
|
||||
跳过条件: --controller-id 显式指定
|
||||
输出: controllerId = "89aa36c5b6744dc2910479f7ecd6d2c3"
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 3: 检测设备属性
|
||||
═══════════════════════════════════════════════════════════════
|
||||
自动检测:
|
||||
osType → /system/build.prop 存在 ? "android" : "linux"
|
||||
platform → platform.machine() # x86_64 / aarch64
|
||||
kernel → os.uname().release # 6.17.0-35-generic
|
||||
hostname → socket.gethostname()
|
||||
hwRevision → DMI product_name + product_version
|
||||
manufacturer→ DMI sys_vendor / Android ro.product.manufacturer
|
||||
|
||||
Android 额外属性:
|
||||
androidVersion → ro.build.version.release
|
||||
productModel → ro.product.model
|
||||
buildNumber → ro.build.version.incremental
|
||||
|
||||
用户属性文件 (/etc/hawkbit/device_attrs.json):
|
||||
{"dept": "production", "region": "east", "owner": "team-a"}
|
||||
|
||||
版本文件 (/etc/hawkbit/current_version) :
|
||||
V1.0.0.0 → swVersion
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 4: 上报属性
|
||||
═══════════════════════════════════════════════════════════════
|
||||
PUT http://host/DEFAULT/controller/v1/{SN}/configData
|
||||
Body:
|
||||
{
|
||||
"mode": "merge",
|
||||
"data": {
|
||||
"osType": "linux",
|
||||
"platform": "x86_64",
|
||||
"kernel": "6.17.0-35-generic",
|
||||
"hostname": "device-001",
|
||||
"hwRevision": "VMware Virtual Platform",
|
||||
"manufacturer": "VMware, Inc.",
|
||||
"swVersion": "V1.0.0.0",
|
||||
"dept": "production",
|
||||
"region": "east"
|
||||
}
|
||||
}
|
||||
|
||||
mode 说明:
|
||||
"merge" → 只更新传入的 key,保留已有属性 (推荐)
|
||||
"replace" → 完全替换
|
||||
"remove" → 删除传入的 key
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
Step 5: 首次轮询(自动注册)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
GET http://host/DEFAULT/controller/v1/{SN}
|
||||
Hawkbit 行为:
|
||||
findOrRegisterTargetIfItDoesNotExist(SN)
|
||||
→ 不存在 → INSERT INTO target (controller_id, ip_address, ...)
|
||||
→ 存在 → UPDATE last_target_query
|
||||
|
||||
响应:
|
||||
无 action:
|
||||
{"config": {"polling": {"sleep": "00:05:00"}}, "_links": {}}
|
||||
→ sleep 5分钟
|
||||
|
||||
有 deployment:
|
||||
"_links": {
|
||||
"deploymentBase": {"href": "http://.../deploymentBase/1?c=..."},
|
||||
"configData": {"href": "http://.../configData"}
|
||||
}
|
||||
→ 进入 OTA 部署流程
|
||||
|
||||
有 cancel:
|
||||
"_links": {"cancelAction": {"href": "http://.../cancelAction/..."}}
|
||||
→ 进入取消流程
|
||||
|
||||
有 confirmation:
|
||||
"_links": {"confirmationBase": {"href": "http://.../confirmationBase"}}
|
||||
→ 进入确认流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 守护进程轮询流程
|
||||
|
||||
```
|
||||
启动命令:
|
||||
ddi-client.py -u http://192.168.77.70:8080 -t DEFAULT \
|
||||
--gateway-token my-key -i 60
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 守护模式 (daemon=True) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 启动/打印 │ ╔═══════════════════╗ │
|
||||
│ │ banner │ ║ Server: host ║ │
|
||||
│ └────┬─────┘ ║ Tenant: DEFAULT ║ │
|
||||
│ │ ║ Device: <SN> ║ │
|
||||
│ ▼ ║ Auth: Gateway ║ │
|
||||
│ ┌──────────┐ ╚═══════════════════╝ │
|
||||
│ │ 上报属性 │ PUT /configData │
|
||||
│ │(首次) │ (仅一次, 成功即跳过) │
|
||||
│ └────┬─────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 轮询 │ GET /controller/v1/{SN} │
|
||||
│ │ Poll │ │
|
||||
│ └────┬─────┘ │
|
||||
│ │ │
|
||||
│ ┌───┴───┬───────────┬───────────┐ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ deploy cancel confirm 无任务 │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ │ │
|
||||
│ OTA流程 取消流程 确认流程 │ │
|
||||
│ │ │ │ │ │
|
||||
│ └───┴───┴─────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ sleep(N 秒) │ N = max(server建议, │
|
||||
│ │ │ 默认60s) │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ └──────→ 回到轮询 ◄── 循环 │
|
||||
│ │
|
||||
│ 退出: Ctrl+C (KeyboardInterrupt) │
|
||||
│ max_cycles 达到上限 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
轮询间隔策略:
|
||||
- 默认: --polling-interval 60 (或 -i 指定)
|
||||
- 服务端可覆盖: response.config.polling.sleep → "00:05:00" → 5分钟
|
||||
- 有活跃 action 时: 服务端可能缩短间隔 (getPollingTimeForAction)
|
||||
- 轮询失败时: 退避到 min(polling_interval, 30s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 单次轮询流程
|
||||
|
||||
```
|
||||
启动命令:
|
||||
ddi-client.py -u http://host:9090 -t DEFAULT \
|
||||
--gateway-token my-key --once
|
||||
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 单次模式 (daemon=False, --once) │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① 上报属性 (首次) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ② 轮询 GET /controller/v1/{SN} │
|
||||
│ │ │
|
||||
│ ┌───┴───┬───────────┬───────────┐ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ deploy cancel confirm 无任务 │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ │ │
|
||||
│ OTA流程 取消流程 确认流程 │ │
|
||||
│ │ │ │ │ │
|
||||
│ └───┴───┴─────────┴───────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ③ sys.exit(0) │
|
||||
│ │
|
||||
│ 用途: │
|
||||
│ - systemd timer / cron 定时触发 │
|
||||
│ - 测试/调试 │
|
||||
│ - 脚本化: && 链式调用后续操作 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
|
||||
cron 示例:
|
||||
*/5 * * * * /usr/bin/python3 /opt/hawkbit/ddi-client.py \
|
||||
-u https://hawkbit:9090 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key --once
|
||||
|
||||
systemd timer 示例:
|
||||
[Unit]
|
||||
Description=Hawkbit DDI poll
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \
|
||||
-u https://hawkbit:9090 -t DEFAULT \
|
||||
--gateway-token @/etc/hawkbit/gateway.key --once
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=300s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. OTA 部署流程
|
||||
|
||||
```
|
||||
前置条件: 轮询返回 _links.deploymentBase
|
||||
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ OTA 部署完整生命周期 │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step A: 拉取部署详情 │ │
|
||||
│ │ │ │
|
||||
│ │ GET /deploymentBase/{actionId}?c=... │ │
|
||||
│ │ ← { │ │
|
||||
│ │ "id": "2", │ │
|
||||
│ │ "deployment": { │ │
|
||||
│ │ "download": "forced", │ │
|
||||
│ │ "update": "forced", │ │
|
||||
│ │ "chunks": [{ │ │
|
||||
│ │ "part": "os", │ │
|
||||
│ │ "version": "V1.0.0.0", │ │
|
||||
│ │ "name": "r3576-OS-android", │ │
|
||||
│ │ "artifacts": [{ │ │
|
||||
│ │ "filename": "update.zip", │ │
|
||||
│ │ "size": 195411, │ │
|
||||
│ │ "hashes": { │ │
|
||||
│ │ "sha256": "438b8f9e...", │ │
|
||||
│ │ "md5": "28a17f5f...", │ │
|
||||
│ │ "sha1": "223377ea..." │ │
|
||||
│ │ }, │ │
|
||||
│ │ "_links": { │ │
|
||||
│ │ "download-http": { │ │
|
||||
│ │ "href": "http://.../update.zip" │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ │ }] │ │
|
||||
│ │ }] │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step B: 上报 DOWNLOAD (开始下载) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "download", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Starting download"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step C: 下载 artifacts (逐个) │ │
|
||||
│ │ │ │
|
||||
│ │ 对每个 chunk.artifacts[]: │ │
|
||||
│ │ │ │
|
||||
│ │ ① 提取 download URL (优先级) │ │
|
||||
│ │ download (HTTPS) > download-http (HTTP) │ │
|
||||
│ │ 失败 → fallback: 遍历所有 HAL link 提取 smId │ │
|
||||
│ │ │ │
|
||||
│ │ ② 检查本地缓存 /tmp/hawkbit/{filename} │ │
|
||||
│ │ 存在 + SHA256 匹配 → 跳过下载 │ │
|
||||
│ │ 存在 + SHA256 不匹配 → 重新下载 │ │
|
||||
│ │ │ │
|
||||
│ │ ③ 检查 .tmp 断点文件 │ │
|
||||
│ │ 存在 → 加 Range: bytes={size}- 头续传 │ │
|
||||
│ │ │ │
|
||||
│ │ ④ GET /softwaremodules/{id}/artifacts/{filename} │ │
|
||||
│ │ 分块读取 64KB, 写入 .tmp │ │
|
||||
│ │ 打印下载进度百分比 │ │
|
||||
│ │ │ │
|
||||
│ │ ⑤ SHA256 校验 .tmp 文件 │ │
|
||||
│ │ 成功 → rename .tmp → 最终文件 │ │
|
||||
│ │ 失败 → 返回 None │ │
|
||||
│ │ │ │
|
||||
│ │ ⑥ 上报 PROCEEDING (每 artifact) │ │
|
||||
│ │ execution=proceeding, progress={cnt:1, of:1} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step D: 上报 DOWNLOADED (全部下载完成) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "downloaded", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Downloaded 1 artifact(s)"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step E: 上报 PROCEEDING (开始安装) │ │
|
||||
│ │ │ │
|
||||
│ │ POST /deploymentBase/{id}/feedback │ │
|
||||
│ │ {"status": { │ │
|
||||
│ │ "execution": "proceeding", │ │
|
||||
│ │ "result": {"finished": "none"}, │ │
|
||||
│ │ "details": ["Installing update …"] │ │
|
||||
│ │ }} │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Step F: 执行安装 _install(files, handling) │ │
|
||||
│ │ │ │
|
||||
│ │ 默认行为: 检测到 *.zip 文件 → 返回 True │ │
|
||||
│ │ 生产: 覆盖此方法执行实际刷写 │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────┴────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ 成功 失败 │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Step G: 上报 CLOSED (最终) │ │
|
||||
│ │ │ │
|
||||
│ │ 成功: │ │
|
||||
│ │ execution=closed │ │
|
||||
│ │ result.finished=success │ │
|
||||
│ │ code=200 │ │
|
||||
│ │ │ │
|
||||
│ │ 失败: │ │
|
||||
│ │ execution=closed │ │
|
||||
│ │ result.finished=failure │ │
|
||||
│ │ code=500 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ → 返回主循环 sleep → 下次轮询 │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Hawkbit Action Status 映射:
|
||||
execution=download → Status.DOWNLOAD
|
||||
execution=downloaded → Status.DOWNLOADED
|
||||
execution=proceeding → Status.RUNNING
|
||||
execution=closed → result.success → Status.FINISHED
|
||||
→ result.failure → Status.ERROR
|
||||
execution=canceled → Status.CANCELED
|
||||
execution=rejected → Status.WARNING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 认证流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 认证方式选择 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① GatewayToken (推荐,所有设备共享) │
|
||||
│ ────────────────────────────────────── │
|
||||
│ 服务端配置: │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.gatewaytoken.key \│
|
||||
│ -d '{"value": "my-shared-key"}' │
|
||||
│ │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.gatewaytoken.enabled \│
|
||||
│ -d '{"value": true}' │
|
||||
│ │
|
||||
│ 客户端: │
|
||||
│ 方式 A: 命令行参数 │
|
||||
│ --gateway-token my-shared-key │
|
||||
│ │
|
||||
│ 方式 B: 从文件读取 │
|
||||
│ echo "my-shared-key" > /etc/hawkbit/gateway.key │
|
||||
│ chmod 600 /etc/hawkbit/gateway.key │
|
||||
│ --gateway-token @/etc/hawkbit/gateway.key │
|
||||
│ │
|
||||
│ HTTP: Authorization: GatewayToken my-shared-key │
|
||||
│ │
|
||||
│ │
|
||||
│ ② TargetToken (每设备独立令牌) │
|
||||
│ ──────────────────────────────── │
|
||||
│ 服务端配置: │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../system/configs/authentication.targettoken.enabled \│
|
||||
│ -d '{"value": true}' │
|
||||
│ │
|
||||
│ # 每个 target 的 securityToken 自动生成或手动设置 │
|
||||
│ curl -u admin:admin -X PUT \ │
|
||||
│ .../targets/{controllerId} \ │
|
||||
│ -d '{"securityToken": "device-unique-token"}' │
|
||||
│ │
|
||||
│ 客户端: │
|
||||
│ --target-token device-unique-token │
|
||||
│ 或 --target-token @/etc/hawkbit/target.token │
|
||||
│ │
|
||||
│ HTTP: Authorization: TargetToken device-unique-token │
|
||||
│ │
|
||||
│ │
|
||||
│ ③ SecurityHeader (反向代理模式, 脚本不支持) │
|
||||
│ ────────────────────────────────────────── │
|
||||
│ 适用于 nginx/Envoy 前置认证场景 │
|
||||
│ │
|
||||
│ │
|
||||
│ 优先级 (客户端): --gateway-token > --target-token │
|
||||
│ 无认证: 服务器返回 401 │
|
||||
│ 验证失败: 服务器返回 401 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 取消任务流程
|
||||
|
||||
```
|
||||
前置条件: 轮询返回 _links.cancelAction
|
||||
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ 取消任务处理 │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① GET /cancelAction/{actionId} │
|
||||
│ ← {"id": "11", │
|
||||
│ "cancelAction": {"stopId": "11"}} │
|
||||
│ │
|
||||
│ ② POST /cancelAction/{actionId}/feedback │
|
||||
│ Body: │
|
||||
│ {"status": { │
|
||||
│ "execution": "canceled", │
|
||||
│ "result": {"finished": "none"}, │
|
||||
│ "details": ["Cancel acknowledged"] │
|
||||
│ }} │
|
||||
│ │
|
||||
│ → 回到主循环 sleep → 下次轮询 │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 确认任务流程
|
||||
|
||||
```
|
||||
前置条件: action 状态为 WAITING_FOR_CONFIRMATION
|
||||
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ 确认任务处理 (headless) │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Headless 设备自动确认,不等待人工: │
|
||||
│ │
|
||||
│ POST /confirmationBase/{actionId}/feedback │
|
||||
│ Body: │
|
||||
│ {"confirmation": "confirmed", │
|
||||
│ "code": 200, │
|
||||
│ "details": ["Auto-confirmed by DDI client"]} │
|
||||
│ │
|
||||
│ → Action 状态从 WAITING_FOR_CONFIRMATION │
|
||||
│ 转为 RUNNING │
|
||||
│ → 下次轮询拿到 deploymentBase 链接 │
|
||||
│ → 进入正常 OTA 流程 │
|
||||
│ │
|
||||
│ 如果 autoConfirm.active=true: │
|
||||
│ 下次轮询时服务端直接返回 deploymentBase │
|
||||
│ 跳过确认步骤 │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 断点续传流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 断点续传 (Range 请求) │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 触发条件: --no-resume 未设置 (默认启用) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 下载 {filename} 之前 │ │
|
||||
│ │ │ │
|
||||
│ │ ① 检查 /tmp/hawkbit/{filename} │ │
|
||||
│ │ 存在 + SHA256 匹配 │ │
|
||||
│ │ → 跳过下载,直接返回本地路径 │ │
|
||||
│ │ │ │
|
||||
│ │ ② 检查 /tmp/hawkbit/{filename}.tmp │ │
|
||||
│ │ 存在 → os.path.getsize(tmp_path) = 1048576 │ │
|
||||
│ │ 添加头: Range: bytes=1048576- │ │
|
||||
│ │ 打开模式: "ab" (追加) │ │
|
||||
│ │ │ │
|
||||
│ │ ③ 文件不存在 │ │
|
||||
│ │ 无 Range 头 │ │
|
||||
│ │ 打开模式: "wb" (新建) │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ GET /softwaremodules/{id}/artifacts/{filename} │ │
|
||||
│ │ Range: bytes=1048576- │ │
|
||||
│ │ ← 206 Partial Content │ │
|
||||
│ │ Content-Range: bytes 1048576-195410/195411 │ │
|
||||
│ │ (服务端从 offset 开始发送) │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 分块写入 + 进度报告 │ │
|
||||
│ │ │ │
|
||||
│ │ while chunk = resp.read(64KB): │ │
|
||||
│ │ fh.write(chunk) │ │
|
||||
│ │ downloaded += len(chunk) │ │
|
||||
│ │ pct = downloaded / total * 100 │ │
|
||||
│ │ │ │
|
||||
│ │ 中断 (Exception / 网络断): │ │
|
||||
│ │ .tmp 文件保留 │ │
|
||||
│ │ 下次启动时自动续传 │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ SHA256 校验通过 │ │
|
||||
│ │ → os.rename(tmp_path, local_path) 原子重命名 │ │
|
||||
│ │ │ │
|
||||
│ │ SHA256 校验失败 │ │
|
||||
│ │ → 日志记录 mismatch │ │
|
||||
│ │ → 返回 None │ │
|
||||
│ │ → 调用方删除 .tmp 或手动恢复 │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 禁用续传: │
|
||||
│ --no-resume → 每次都从头下载 │
|
||||
│ → 每次 .tmp 从 0 开始 │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 错误处理流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 错误处理矩阵 │
|
||||
├───────────────┬───────────────────────┬───────────────────────┤
|
||||
│ 场景 │ 行为 │ 恢复 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 轮询 HTTP 错误 │ 日志记录,退避 sleep │ 下次轮询自动重试 │
|
||||
│ (network/500) │ min(interval, 30s) │ │
|
||||
│ │ 守护模式继续 │ │
|
||||
│ │ --once: exit(1) │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 获取部署详情 │ send_feedback(closed, │ 下次轮询后 action │
|
||||
│ 失败 │ failure, "Cannot │ 可能还在 pending │
|
||||
│ │ fetch deployment") │ 状态,重新处理 │
|
||||
│ │ return False │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 下载失败 │ send_feedback(closed, │ .tmp 保留 │
|
||||
│ (HTTP error) │ failure, "Download │ 下次轮询自动续传 │
|
||||
│ │ failed for xxx") │ │
|
||||
│ │ return False │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ SHA256 不匹配 │ 日志 error │ .tmp 保留,可手动删除 │
|
||||
│ │ "SHA256 MISMATCH" │ 下次重新下载 │
|
||||
│ │ return None │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 下载中断 │ 日志 error │ .tmp 保留 │
|
||||
│ (Exception) │ "Download interrupted" │ 下次自动续传 │
|
||||
│ │ return None │ │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 安装失败 │ send_feedback(closed, │ 下次轮询后 action │
|
||||
│ (_install 返 │ failure, code=500) │ 状态 → ERROR │
|
||||
│ 回 False) │ return False │ 管理端可见 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ SSL 证书错误 │ 捕获后当作普通 HTTP 错误 │ --no-ssl-verify │
|
||||
│ │ │ 跳过证书验证(不安全) │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ 属性上报失败 │ 日志 error │ _attrs_reported=False │
|
||||
│ │ 不影响轮询继续 │ 下次轮询自动重试 │
|
||||
├───────────────┼───────────────────────┼───────────────────────┤
|
||||
│ Ctrl+C 中断 │ 日志 "Stopped" │ return True │
|
||||
│ │ sys.exit(0) │ │
|
||||
└───────────────┴───────────────────────┴───────────────────────┘
|
||||
|
||||
日志级别:
|
||||
DEBUG - HTTP 请求/响应详情, 下载进度, 属性内容 (--verbose / -v)
|
||||
INFO - 状态变化, action 处理, 睡眠倒计时 (默认)
|
||||
WARNING- 非致命错误 (poll 失败, 缓存失效, 无认证) (默认)
|
||||
ERROR - 致命错误 (下载失败, SHA256 不匹配, HTTP 错误) (默认)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 安装钩子定制
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ _install() 钩子定制 │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 默认行为: │
|
||||
│ 检测下载文件列表中是否有 *.zip 文件 → 返回 True │
|
||||
│ (适合测试,不做实际操作) │
|
||||
│ │
|
||||
│ 输入: │
|
||||
│ files: List[str] → ["/tmp/hawkbit/update.zip"] │
|
||||
│ handling: dict → {"download":"forced", │
|
||||
│ "update":"forced", │
|
||||
│ "maintenanceWindow":null} │
|
||||
│ │
|
||||
│ 返回: │
|
||||
│ True → send_feedback(closed, success) │
|
||||
│ False → send_feedback(closed, failure) │
|
||||
│ │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 方式 A: 子类继承 │
|
||||
│ ───────────────── │
|
||||
│ │
|
||||
│ # my_client.py │
|
||||
│ from ddi_client import HawkbitDDIClient │
|
||||
│ │
|
||||
│ class AndroidOTAClient(HawkbitDDIClient): │
|
||||
│ def _install(self, files, handling): │
|
||||
│ import subprocess │
|
||||
│ for fp in files: │
|
||||
│ if fp.endswith('.zip'): │
|
||||
│ # 写 recovery 命令 │
|
||||
│ subprocess.run([ │
|
||||
│ 'update_engine_client', │
|
||||
│ '--update', │
|
||||
│ f'file://{fp}' │
|
||||
│ ], check=True) │
|
||||
│ # 写版本号 │
|
||||
│ with open('/etc/hawkbit/current_version', │
|
||||
│ 'w') as f: │
|
||||
│ f.write('V2.0.0.0') │
|
||||
│ return True │
|
||||
│ │
|
||||
│ if __name__ == '__main__': │
|
||||
│ # 用自定义子类替换 CLI 入口 │
|
||||
│ client = AndroidOTAClient(...) │
|
||||
│ client.run() │
|
||||
│ │
|
||||
│ │
|
||||
│ 方式 B: Monkey-Patch (快速原型) │
|
||||
│ ───────────────────────────── │
|
||||
│ │
|
||||
│ client = HawkbitDDIClient(...) │
|
||||
│ original_install = client._install │
|
||||
│ def my_install(files, handling): │
|
||||
│ result = subprocess.run(['unzip', '-o', files[0], │
|
||||
│ '-d', '/data/ota']) │
|
||||
│ with open('/etc/hawkbit/current_version','w') as f: │
|
||||
│ f.write('V2.0.0.0') │
|
||||
│ return result.returncode == 0 │
|
||||
│ client._install = my_install │
|
||||
│ client.run() │
|
||||
│ │
|
||||
│ │
|
||||
│ 方式 C: 简单模式 (不需要 Python 继承) │
|
||||
│ ─────────────────────────────────── │
|
||||
│ │
|
||||
│ 创建安装脚本: /etc/hawkbit/install.sh │
|
||||
│ │
|
||||
│ #!/bin/sh │
|
||||
│ unzip -o "$1" -d /data/ota │
|
||||
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
|
||||
│ exit $? │
|
||||
│ │
|
||||
│ 修改 _install: │
|
||||
│ def _install(self, files, handling): │
|
||||
│ for fp in files: │
|
||||
│ if fp.endswith('.zip'): │
|
||||
│ import subprocess │
|
||||
│ result = subprocess.run( │
|
||||
│ ['/etc/hawkbit/install.sh', fp]) │
|
||||
│ return result.returncode == 0 │
|
||||
│ return True │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 部署与运维
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 部署方案 │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Android 设备部署: │
|
||||
│ ──────────────── │
|
||||
│ │
|
||||
│ # 1. 推送脚本 │
|
||||
│ adb push ddi-client.py /data/local/tmp/ │
|
||||
│ │
|
||||
│ # 2. 创建 token 文件 │
|
||||
│ adb shell "echo 'my-gateway-key' > /data/local/tmp/gateway.key" │
|
||||
│ adb shell "chmod 600 /data/local/tmp/gateway.key" │
|
||||
│ │
|
||||
│ # 3. 创建属性文件 (可选) │
|
||||
│ adb push device_attrs.json /data/local/tmp/ │
|
||||
│ adb shell "mkdir -p /etc/hawkbit" │
|
||||
│ adb shell "cp /data/local/tmp/device_attrs.json /etc/hawkbit/" │
|
||||
│ │
|
||||
│ # 4. 运行 │
|
||||
│ adb shell "python3 /data/local/tmp/ddi-client.py \ │
|
||||
│ -u https://hawkbit:9090 -t DEFAULT \ │
|
||||
│ --gateway-token @/data/local/tmp/gateway.key \ │
|
||||
│ -d /data/local/tmp/hawkbit" │
|
||||
│ │
|
||||
│ │
|
||||
│ Linux 设备部署 (systemd): │
|
||||
│ ───────────────────────── │
|
||||
│ │
|
||||
│ # /opt/hawkbit/ddi-client.py │
|
||||
│ # /etc/hawkbit/gateway.key │
|
||||
│ # /etc/hawkbit/device_attrs.json │
|
||||
│ │
|
||||
│ # /etc/systemd/system/hawkbit-ddi.service │
|
||||
│ [Unit] │
|
||||
│ Description=Hawkbit DDI Client │
|
||||
│ After=network-online.target │
|
||||
│ Wants=network-online.target │
|
||||
│ │
|
||||
│ [Service] │
|
||||
│ Type=simple │
|
||||
│ ExecStart=/usr/bin/python3 /opt/hawkbit/ddi-client.py \ │
|
||||
│ -u https://hawkbit:9090 -t DEFAULT \ │
|
||||
│ --gateway-token @/etc/hawkbit/gateway.key \ │
|
||||
│ -d /var/cache/hawkbit -i 30 │
|
||||
│ Restart=always │
|
||||
│ RestartSec=30 │
|
||||
│ User=root │
|
||||
│ │
|
||||
│ [Install] │
|
||||
│ WantedBy=multi-user.target │
|
||||
│ │
|
||||
│ systemctl enable --now hawkbit-ddi │
|
||||
│ │
|
||||
│ │
|
||||
│ 版本管理: │
|
||||
│ ──────── │
|
||||
│ │
|
||||
│ 安装成功后写入版本号: │
|
||||
│ echo "V2.0.0.0" > /etc/hawkbit/current_version │
|
||||
│ │
|
||||
│ 下次轮询时属性中自动包含 swVersion=V2.0.0.0 │
|
||||
│ 管理员可在 Hawkbit 中按版本筛选设备 │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: HTTP 请求/响应速查
|
||||
|
||||
| 操作 | 方法 | 路径 | Content-Type |
|
||||
|------|------|------|-------------|
|
||||
| 轮询 | GET | `/{tenant}/controller/v1/{controllerId}` | application/hal+json |
|
||||
| 部署详情 | GET | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}` | application/hal+json |
|
||||
| 上报反馈 | POST | `/{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}/feedback` | application/json |
|
||||
| 下载文件 | GET | `/{tenant}/controller/v1/{controllerId}/softwaremodules/{id}/artifacts/{file}` | application/octet-stream |
|
||||
| 上报属性 | PUT | `/{tenant}/controller/v1/{controllerId}/configData` | application/json |
|
||||
| 取消反馈 | POST | `/{tenant}/controller/v1/{controllerId}/cancelAction/{id}/feedback` | application/json |
|
||||
| 确认反馈 | POST | `/{tenant}/controller/v1/{controllerId}/confirmationBase/{id}/feedback` | application/json |
|
||||
|
||||
## 附录 B: 命令行参数完整列表
|
||||
|
||||
```
|
||||
-u, --base-url Hawkbit 服务端地址 (必填)
|
||||
-t, --tenant 租户名称 (必填)
|
||||
--gateway-token Gateway Token 或 @文件路径
|
||||
--target-token Target Token 或 @文件路径
|
||||
--controller-id 控制器ID (默认自动检测SN)
|
||||
-d, --download-dir 下载目录 (/tmp/hawkbit)
|
||||
-i, --polling-interval 轮询间隔秒数 (60)
|
||||
--no-verify 跳过 SHA256 校验
|
||||
--no-resume 禁用断点续传
|
||||
--no-ssl-verify 跳过 TLS 证书验证
|
||||
--once 单次轮询后退出
|
||||
-v, --verbose 调试日志
|
||||
-h, --help 帮助
|
||||
```
|
||||
|
||||
## 附录 C: 设备属性文件格式
|
||||
|
||||
```json
|
||||
# /etc/hawkbit/device_attrs.json
|
||||
{
|
||||
"dept": "production",
|
||||
"region": "east-1",
|
||||
"owner": "team-a",
|
||||
"rack": "A12",
|
||||
"environment": "staging"
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 D: 状态流转图 (Hawkbit 视角)
|
||||
|
||||
```
|
||||
assignment
|
||||
│
|
||||
▼
|
||||
READY ──→ RETRIEVED ──→ DOWNLOAD ──→ DOWNLOADED ──→ RUNNING ──→ FINISHED
|
||||
│ │ │ (成功)
|
||||
│ │ │
|
||||
│ │ └──→ ERROR
|
||||
│ │ (失败)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
CANCELING ──→ CANCELED
|
||||
│
|
||||
CANCEL_REJECTED
|
||||
|
||||
WAITING_FOR_CONFIRMATION ──confirmed──→ RUNNING → ...
|
||||
│
|
||||
└──denied──→ DENIED
|
||||
```
|
||||
845
huaxu/ddi-client.py
Executable file
845
huaxu/ddi-client.py
Executable file
@@ -0,0 +1,845 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hawkbit DDI Client — lightweight pure-Python OTA update agent.
|
||||
=================================================================
|
||||
No dependencies beyond Python 3 standard library.
|
||||
Runs on Android (adb shell) and Linux hosts.
|
||||
|
||||
DDI API version: v1
|
||||
Ref: https://github.com/eclipse-hawkbit/hawkbit
|
||||
|
||||
Capabilities:
|
||||
• Poll Hawkbit for deployment actions
|
||||
• Resumable (Range-request) artifact download
|
||||
• SHA256 file integrity verification
|
||||
• Status progression: DOWNLOAD → DOWNLOADED → PROCEEDING → FINISHED / FAILURE
|
||||
• Auto-detects device serial number as controllerId (Android ro.serialno, DMI, machine-id)
|
||||
• Reports device attributes (osType, platform, hwRevision, swVersion, kernel…) to Hawkbit
|
||||
• Authentication: GatewayToken or TargetToken (also from file: --gateway-token @/path/to/key)
|
||||
|
||||
Quick start:
|
||||
# Gateway token
|
||||
ddi-client.py --base-url https://hawkbit:8443 --tenant DEFAULT --gateway-token mykey
|
||||
|
||||
# Target token (per-device)
|
||||
ddi-client.py --base-url http://10.0.0.1:8080 --tenant DEFAULT --target-token abc123
|
||||
|
||||
# One-shot poll
|
||||
ddi-client.py --base-url http://localhost:8080 --tenant DEFAULT --gateway-token k --once
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# ── logging ─────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("hawkbit-ddi")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# HawkbitDDIClient
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
class HawkbitDDIClient:
|
||||
"""Lightweight DDI (Direct Device Integration) client for Eclipse Hawkbit."""
|
||||
|
||||
# DDI execution statuses (mapped to Hawkbit action states)
|
||||
EXEC_DOWNLOAD = "download"
|
||||
EXEC_DOWNLOADED = "downloaded"
|
||||
EXEC_PROCEEDING = "proceeding"
|
||||
EXEC_CLOSED = "closed"
|
||||
EXEC_CANCELED = "canceled"
|
||||
EXEC_REJECTED = "rejected"
|
||||
EXEC_SCHEDULED = "scheduled"
|
||||
EXEC_RESUMED = "resumed"
|
||||
|
||||
RESULT_SUCCESS = "success"
|
||||
RESULT_FAILURE = "failure"
|
||||
RESULT_NONE = "none"
|
||||
|
||||
# ── constructor ──────────────────────────────────────────────────────
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
tenant: str,
|
||||
controller_id: Optional[str] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
download_dir: str = "/tmp/hawkbit",
|
||||
verify_sha256: bool = True,
|
||||
polling_interval: int = 60,
|
||||
ssl_verify: bool = True,
|
||||
resume_download: bool = True,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.tenant = tenant
|
||||
self.controller_id = controller_id or self._detect_device_sn()
|
||||
self.auth_header = auth_header
|
||||
self.download_dir = download_dir
|
||||
self.verify_sha256 = verify_sha256
|
||||
self.polling_interval = polling_interval
|
||||
self.ssl_verify = ssl_verify
|
||||
self.resume_download = resume_download
|
||||
self._attrs_reported = False
|
||||
|
||||
os.makedirs(self.download_dir, exist_ok=True)
|
||||
|
||||
# ── device serial detection ───────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _detect_device_sn() -> str:
|
||||
"""Best-effort device serial number detection.
|
||||
|
||||
Priority: Android ro.serialno → DMI product_serial → /etc/machine-id → hostname
|
||||
"""
|
||||
# 1. Android property
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["getprop", "ro.serialno"],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
sn = out.stdout.strip()
|
||||
if sn:
|
||||
logger.info("Detected Android serial: %s", sn)
|
||||
return sn
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# 2. Linux DMI product serial
|
||||
for p in ("/sys/class/dmi/id/product_serial",
|
||||
"/sys/devices/virtual/dmi/id/product_serial"):
|
||||
try:
|
||||
with open(p) as fh:
|
||||
sn = fh.read().strip()
|
||||
if sn and sn not in ("0", "Not Specified", "None", ""):
|
||||
logger.info("Detected DMI serial: %s", sn)
|
||||
return sn
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# 3. /etc/machine-id (systemd)
|
||||
try:
|
||||
with open("/etc/machine-id") as fh:
|
||||
mid = fh.read().strip()
|
||||
if mid:
|
||||
logger.info("Using machine-id: %s", mid)
|
||||
return mid
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# 4. Fallback — hostname
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
logger.warning("No serial found; falling back to hostname: %s", hostname)
|
||||
return hostname
|
||||
|
||||
# ── device attributes detection ─────────────────────────────────────
|
||||
@staticmethod
|
||||
def _detect_device_attrs() -> Dict[str, str]:
|
||||
"""Collect device metadata for Hawkbit target attributes.
|
||||
|
||||
These attributes are used by Hawkbit target filters to auto-assign updates.
|
||||
Returns a dict of key-value pairs.
|
||||
"""
|
||||
attrs: Dict[str, str] = {}
|
||||
|
||||
# ── OS type ─────────────────────────────────────────────────
|
||||
attrs["hostname"] = HawkbitDDIClient._safe_hostname()
|
||||
|
||||
if os.path.exists("/system/build.prop"):
|
||||
attrs["osType"] = "android"
|
||||
else:
|
||||
attrs["osType"] = "linux"
|
||||
|
||||
# ── platform / architecture ─────────────────────────────────
|
||||
try:
|
||||
import platform
|
||||
attrs["platform"] = platform.machine()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── kernel version ──────────────────────────────────────────
|
||||
try:
|
||||
attrs["kernel"] = os.uname().release
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Android: detailed attributes ────────────────────────────
|
||||
if attrs.get("osType") == "android":
|
||||
for prop, attr_key in (
|
||||
("ro.build.version.release", "androidVersion"),
|
||||
("ro.product.model", "productModel"),
|
||||
("ro.product.manufacturer", "manufacturer"),
|
||||
("ro.build.version.incremental", "buildNumber"),
|
||||
("ro.build.fingerprint", "buildFingerprint"),
|
||||
):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["getprop", prop], capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
val = out.stdout.strip()
|
||||
if val:
|
||||
attrs[attr_key] = val
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# ── Linux: DMI hardware info ────────────────────────────────
|
||||
if attrs.get("osType") == "linux":
|
||||
for path, attr_key in (
|
||||
("/sys/class/dmi/id/product_name", "productName"),
|
||||
("/sys/class/dmi/id/product_version", "productVersion"),
|
||||
("/sys/class/dmi/id/product_sku", "productSku"),
|
||||
("/sys/class/dmi/id/sys_vendor", "manufacturer"),
|
||||
):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
val = fh.read().strip()
|
||||
if val and val not in ("0", "Not Specified", "None", "", "System Product Name"):
|
||||
attrs[attr_key] = val
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# hwRevision: combine product name + version
|
||||
if "productName" in attrs:
|
||||
rev = attrs["productName"]
|
||||
if "productVersion" in attrs:
|
||||
rev += " " + attrs["productVersion"]
|
||||
attrs["hwRevision"] = rev
|
||||
|
||||
# ── current software version (if installed) ─────────────────
|
||||
version_file = "/etc/hawkbit/current_version"
|
||||
try:
|
||||
with open(version_file) as fh:
|
||||
v = fh.read().strip()
|
||||
if v:
|
||||
attrs["swVersion"] = v
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
# ── merge user-supplied attrs from file ─────────────────────
|
||||
attrs_file = "/etc/hawkbit/device_attrs.json"
|
||||
try:
|
||||
with open(attrs_file) as fh:
|
||||
extra = json.load(fh)
|
||||
if isinstance(extra, dict):
|
||||
attrs.update({k: str(v) for k, v in extra.items()})
|
||||
except (FileNotFoundError, PermissionError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return attrs
|
||||
|
||||
@staticmethod
|
||||
def _safe_hostname() -> str:
|
||||
try:
|
||||
import socket
|
||||
return socket.gethostname()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
# ── report device attributes to Hawkbit ──────────────────────────────
|
||||
def report_attributes(self) -> bool:
|
||||
"""PUT /{tenant}/controller/v1/{controllerId}/configData
|
||||
|
||||
Reports device metadata so Hawkbit can use target filters to assign updates.
|
||||
Uses ``mode: merge`` — only updates the keys sent, preserves existing attributes.
|
||||
"""
|
||||
attrs = self._detect_device_attrs()
|
||||
url = self._ddi_url(f"{self.controller_id}/configData")
|
||||
payload = {"mode": "merge", "data": attrs}
|
||||
logger.info("Reporting %d device attributes → %s", len(attrs), url)
|
||||
logger.debug("Attributes: %s", json.dumps(attrs))
|
||||
resp = self._http(url, method="PUT", data=payload)
|
||||
return resp is not None
|
||||
def _ddi_url(self, path: str) -> str:
|
||||
"""Build a DDI v1 URL: <base>/<tenant>/controller/v1/<path>"""
|
||||
return f"{self.base_url}/{self.tenant}/controller/v1/{path}"
|
||||
|
||||
# ── low-level HTTP ────────────────────────────────────────────────────
|
||||
def _http(
|
||||
self,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
data: Any = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
stream: bool = False,
|
||||
):
|
||||
"""Make an HTTP request. Returns decoded JSON, or raw file handle when
|
||||
``stream=True``. Returns ``None`` on HTTP errors."""
|
||||
req_headers: Dict[str, str] = {"Accept": "application/hal+json, application/json"}
|
||||
if self.auth_header:
|
||||
req_headers["Authorization"] = self.auth_header
|
||||
if data is not None:
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
body = None if data is None else json.dumps(data).encode("utf-8")
|
||||
|
||||
ctx = None
|
||||
if not self.ssl_verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
logger.info("URL::::: → %s", url)
|
||||
req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
except urllib.error.HTTPError as exc:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
logger.error("HTTP %d %s %s → %s", exc.code, method, url, err_body)
|
||||
return None
|
||||
|
||||
if stream:
|
||||
return resp
|
||||
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
# ── helper: extract best download link from artifact HAL links ─────────
|
||||
@staticmethod
|
||||
def _pick_download_url(artifact: dict) -> Optional[str]:
|
||||
"""Pick the preferred download URL from the artifact's _links.
|
||||
|
||||
Priority: download (HTTPS) → download-http (HTTP) → construct from filename
|
||||
"""
|
||||
links = artifact.get("_links", {})
|
||||
if not isinstance(links, dict):
|
||||
return None
|
||||
|
||||
# HAL links are keyed by rel; values can be a single object or a list.
|
||||
for rel in ("download", "download-http"):
|
||||
entry = links.get(rel)
|
||||
if isinstance(entry, list):
|
||||
for item in entry:
|
||||
if isinstance(item, dict) and "href" in item:
|
||||
return item["href"]
|
||||
elif isinstance(entry, dict) and "href" in entry:
|
||||
return entry["href"]
|
||||
return None
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Public API
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── poll ──────────────────────────────────────────────────────────────
|
||||
def poll(self) -> Optional[dict]:
|
||||
"""GET /{tenant}/controller/v1/{controllerId}
|
||||
|
||||
Returns parsed controller base or None on failure.
|
||||
"""
|
||||
url = self._ddi_url(self.controller_id)
|
||||
logger.debug("Polling GET %s", url)
|
||||
body = self._http(url)
|
||||
if body is None:
|
||||
return None
|
||||
return self._parse_poll(body)
|
||||
|
||||
def _parse_poll(self, body: dict) -> dict:
|
||||
result: Dict[str, Any] = {
|
||||
"config": body.get("config", {}),
|
||||
"links": {},
|
||||
}
|
||||
|
||||
links = body.get("_links", {})
|
||||
if isinstance(links, dict):
|
||||
for rel in ("deploymentBase", "cancelAction",
|
||||
"confirmationBase", "installedBase", "configData"):
|
||||
entry = links.get(rel)
|
||||
href = None
|
||||
if isinstance(entry, dict):
|
||||
href = entry.get("href")
|
||||
elif isinstance(entry, list) and entry:
|
||||
href = entry[0].get("href") if isinstance(entry[0], dict) else None
|
||||
if href:
|
||||
result["links"][rel] = href
|
||||
|
||||
# Adopt server-suggested polling interval
|
||||
sleep_str = body.get("config", {}).get("polling", {}).get("sleep", "")
|
||||
if sleep_str:
|
||||
try:
|
||||
h, m, s = (int(x) for x in sleep_str.split(":"))
|
||||
seconds = h * 3600 + m * 60 + s
|
||||
if seconds > 0:
|
||||
self.polling_interval = seconds
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
# ── get deployment base ───────────────────────────────────────────────
|
||||
def get_deployment(self, deployment_url: str) -> Optional[dict]:
|
||||
"""GET /{tenant}/controller/v1/{controllerId}/deploymentBase/{actionId}
|
||||
|
||||
Returns structured deployment info or None.
|
||||
"""
|
||||
logger.debug("Fetching deployment: %s", deployment_url)
|
||||
body = self._http(deployment_url)
|
||||
if body is None:
|
||||
return None
|
||||
return self._parse_deployment(body)
|
||||
|
||||
def _parse_deployment(self, body: dict) -> dict:
|
||||
info = body.get("deployment", {})
|
||||
|
||||
chunks = []
|
||||
for ch in info.get("chunks", []):
|
||||
artifacts = []
|
||||
for art in ch.get("artifacts", []):
|
||||
artifacts.append({
|
||||
"filename": art["filename"],
|
||||
"size": art.get("size", 0),
|
||||
"hashes": art.get("hashes", {}),
|
||||
"_links": art.get("_links", {}),
|
||||
})
|
||||
chunks.append({
|
||||
"part": ch.get("part", ""),
|
||||
"version": ch.get("version", ""),
|
||||
"name": ch.get("name", ""),
|
||||
"artifacts": artifacts,
|
||||
})
|
||||
|
||||
return {
|
||||
"action_id": body.get("id"),
|
||||
"chunks": chunks,
|
||||
"handling": {
|
||||
"download": info.get("download", "attempt"),
|
||||
"update": info.get("update", "attempt"),
|
||||
"maintenanceWindow": info.get("maintenanceWindow"),
|
||||
},
|
||||
}
|
||||
|
||||
# ── download artifact ─────────────────────────────────────────────────
|
||||
def download_artifact(self, artifact: dict, action_id: str = "") -> Optional[str]:
|
||||
"""Download one artifact with Range-request resume and SHA256 verification.
|
||||
|
||||
Returns the local file path on success, or None on failure.
|
||||
"""
|
||||
filename = artifact["filename"]
|
||||
expected_sha256 = artifact.get("hashes", {}).get("sha256")
|
||||
expected_size = artifact.get("size", 0)
|
||||
|
||||
# Resolve the download URL
|
||||
download_url = self._pick_download_url(artifact)
|
||||
if not download_url:
|
||||
# Fallback — construct from DDI path
|
||||
# Try to extract smId from any artifact HAL link
|
||||
logger.warning("No download link in artifact — using fallback URL")
|
||||
import re
|
||||
sm_id = "0"
|
||||
links = artifact.get("_links", {})
|
||||
for entry in links.values():
|
||||
href = None
|
||||
if isinstance(entry, dict) and "href" in entry:
|
||||
href = entry["href"]
|
||||
elif isinstance(entry, list):
|
||||
for item in entry:
|
||||
if isinstance(item, dict) and "href" in item:
|
||||
href = item["href"]
|
||||
break
|
||||
if href:
|
||||
m = re.search(r"/softwaremodules/(\d+)/", href)
|
||||
if m:
|
||||
sm_id = m.group(1)
|
||||
break
|
||||
download_url = self._ddi_url(
|
||||
f"{self.controller_id}/softwaremodules/{sm_id}/artifacts/{filename}"
|
||||
)
|
||||
|
||||
local_path = os.path.join(self.download_dir, filename)
|
||||
tmp_path = local_path + ".tmp"
|
||||
|
||||
# Already downloaded and verified?
|
||||
if os.path.exists(local_path):
|
||||
if self._sha256_check(local_path, expected_sha256):
|
||||
logger.info("Already cached & verified: %s", local_path)
|
||||
return local_path
|
||||
logger.warning("Cached file fails SHA256 — re-downloading")
|
||||
|
||||
# ── resume ────────────────────────────────────────────────────
|
||||
downloaded = 0
|
||||
if self.resume_download and os.path.exists(tmp_path):
|
||||
downloaded = os.path.getsize(tmp_path)
|
||||
logger.info("Resuming from byte %d", downloaded)
|
||||
|
||||
req_headers: Dict[str, str] = {}
|
||||
if downloaded > 0:
|
||||
req_headers["Range"] = f"bytes={downloaded}-"
|
||||
|
||||
logger.info("Downloading %s (%s bytes) → %s",
|
||||
filename, expected_size or "?", tmp_path)
|
||||
|
||||
resp = self._http(download_url, headers=req_headers, stream=True)
|
||||
if resp is None:
|
||||
logger.error("Download request failed")
|
||||
return None
|
||||
|
||||
# Determine total size (from Content-Range header)
|
||||
total = expected_size
|
||||
cr = resp.headers.get("Content-Range", "")
|
||||
if cr:
|
||||
try:
|
||||
total = int(cr.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
mode = "ab" if downloaded > 0 else "wb"
|
||||
try:
|
||||
with open(tmp_path, mode) as fh:
|
||||
while True:
|
||||
chunk = resp.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
fh.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
pct = downloaded / total * 100
|
||||
logger.debug(" %6.1f %% (%d / %d)", pct, downloaded, total)
|
||||
except Exception as exc:
|
||||
logger.error("Download interrupted: %s (tmp kept for resume)", exc)
|
||||
return None
|
||||
|
||||
logger.info("Download finished — %d bytes", downloaded)
|
||||
|
||||
# SHA256
|
||||
if self.verify_sha256 and expected_sha256:
|
||||
if not self._sha256_check(tmp_path, expected_sha256):
|
||||
logger.error("SHA256 MISMATCH for %s", filename)
|
||||
return None
|
||||
|
||||
# Atomic rename
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
os.rename(tmp_path, local_path)
|
||||
return local_path
|
||||
|
||||
@staticmethod
|
||||
def _sha256_check(filepath: str, expected: Optional[str]) -> bool:
|
||||
if not expected:
|
||||
return True # nothing to compare against
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
with open(filepath, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
except OSError:
|
||||
return False
|
||||
actual = h.hexdigest()
|
||||
ok = actual.lower() == expected.lower()
|
||||
if ok:
|
||||
logger.info("SHA256 verified %s", actual)
|
||||
else:
|
||||
logger.error("SHA256 mismatch!\n expected: %s\n actual: %s", expected, actual)
|
||||
return ok
|
||||
|
||||
# ── send feedback ─────────────────────────────────────────────────────
|
||||
def send_feedback(
|
||||
self,
|
||||
deployment_url: str,
|
||||
execution: str,
|
||||
result: str = RESULT_NONE,
|
||||
progress: Optional[Tuple[int, int]] = None,
|
||||
details: Optional[List[str]] = None,
|
||||
code: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""POST …/feedback — report execution status to the server.
|
||||
|
||||
Args:
|
||||
deployment_url: full URL to the deployment base / cancel action / confirmation base.
|
||||
execution: one of the EXEC_* constants.
|
||||
result: ``success``, ``failure``, or ``none``.
|
||||
progress: ``(cnt, of)`` e.g. ``(2, 5)``.
|
||||
details: human-readable messages (shows in Hawkbit UI).
|
||||
code: optional numeric status code.
|
||||
"""
|
||||
# strip query params — they belong to GET, not POST /feedback
|
||||
base = deployment_url.split("?")[0]
|
||||
url = base + "/feedback"
|
||||
|
||||
result_obj: Dict[str, Any] = {"finished": result}
|
||||
if progress:
|
||||
result_obj["progress"] = {"cnt": progress[0], "of": progress[1]}
|
||||
|
||||
status: Dict[str, Any] = {
|
||||
"execution": execution,
|
||||
"result": result_obj,
|
||||
"details": details or [],
|
||||
}
|
||||
if code is not None:
|
||||
status["code"] = code
|
||||
|
||||
payload = {
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
logger.info("Feedback %-12s / %-7s → %s", execution, result, url)
|
||||
return self._http(url, method="POST", data=payload)
|
||||
|
||||
# ── process deployment ────────────────────────────────────────────────
|
||||
def process_deployment(self, deployment_url: str) -> bool:
|
||||
"""Run the full deployment lifecycle: fetch metadata → download → verify → report."""
|
||||
|
||||
dep = self.get_deployment(deployment_url)
|
||||
if not dep:
|
||||
logger.error("Cannot fetch deployment details")
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=["Failed to fetch deployment base"])
|
||||
return False
|
||||
|
||||
action_id = dep["action_id"]
|
||||
handling = dep["handling"]
|
||||
logger.info("══ Action %s ══ download=%s update=%s",
|
||||
action_id, handling["download"], handling["update"])
|
||||
|
||||
# 1. DOWNLOAD phase — report start
|
||||
self.send_feedback(deployment_url, self.EXEC_DOWNLOAD, self.RESULT_NONE,
|
||||
details=["Starting download"])
|
||||
|
||||
# 2. Fetch every artifact
|
||||
downloaded: List[str] = []
|
||||
for chunk in dep["chunks"]:
|
||||
logger.info("Chunk: %s v%s (%d artifacts)",
|
||||
chunk["name"], chunk["version"], len(chunk["artifacts"]))
|
||||
for idx, art in enumerate(chunk["artifacts"], 1):
|
||||
# Per-artifact progress
|
||||
self.send_feedback(
|
||||
deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
|
||||
progress=(idx, len(chunk["artifacts"])),
|
||||
details=[f"Downloading {art['filename']}"],
|
||||
)
|
||||
local = self.download_artifact(art, action_id)
|
||||
if local is None:
|
||||
self.send_feedback(
|
||||
deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=[f"Download failed for {art['filename']}"],
|
||||
)
|
||||
return False
|
||||
downloaded.append(local)
|
||||
|
||||
# 3. DOWNLOADED — all artifacts on disk, hashes ok
|
||||
self.send_feedback(deployment_url, self.EXEC_DOWNLOADED, self.RESULT_NONE,
|
||||
details=[f"Downloaded {len(downloaded)} artifact(s)"])
|
||||
|
||||
# 4. PROCEEDING — install
|
||||
self.send_feedback(deployment_url, self.EXEC_PROCEEDING, self.RESULT_NONE,
|
||||
details=["Installing update …"])
|
||||
|
||||
success = self._install(downloaded, handling)
|
||||
|
||||
# 5. CLOSED — success or failure
|
||||
if success:
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_SUCCESS,
|
||||
details=["Installation complete"], code=200)
|
||||
else:
|
||||
self.send_feedback(deployment_url, self.EXEC_CLOSED, self.RESULT_FAILURE,
|
||||
details=["Installation failed"], code=500)
|
||||
return success
|
||||
|
||||
# ── install hook — override this for your device ──────────────────────
|
||||
def _install(self, files: List[str], handling: dict) -> bool:
|
||||
"""Apply the downloaded update. **Override in subclass or monkey-patch.**"""
|
||||
logger.info("Install hook — files: %s", files)
|
||||
# Default: look for update.zip and report success.
|
||||
for fp in files:
|
||||
if os.path.basename(fp).lower().endswith(".zip"):
|
||||
# Example: subprocess.run(["unzip", "-o", fp, "-d", "/data/ota"], check=True)
|
||||
logger.info("Would install: %s", fp)
|
||||
return True
|
||||
return bool(files)
|
||||
|
||||
# ── cancel action ─────────────────────────────────────────────────────
|
||||
def process_cancel(self, cancel_url: str) -> None:
|
||||
"""Acknowledge a cancel action."""
|
||||
self.send_feedback(cancel_url + "/feedback", self.EXEC_CANCELED,
|
||||
self.RESULT_NONE, details=["Cancel acknowledged"])
|
||||
|
||||
# ── confirmation (auto-confirm for headless devices) ──────────────────
|
||||
def process_confirmation(self, confirmation_url: str) -> None:
|
||||
"""Auto-confirm a WAITING_FOR_CONFIRMATION action."""
|
||||
logger.info("Auto-confirming: %s", confirmation_url)
|
||||
body = {
|
||||
"confirmation": "confirmed",
|
||||
"code": 200,
|
||||
"details": ["Auto-confirmed by DDI client"],
|
||||
}
|
||||
self._http(confirmation_url + "/feedback", method="POST", data=body)
|
||||
|
||||
# ── main loop ─────────────────────────────────────────────────────────
|
||||
def run(self, *, daemon: bool = True, max_cycles: Optional[int] = None) -> bool:
|
||||
"""Run the polling loop.
|
||||
|
||||
Args:
|
||||
daemon: If False, poll once and return.
|
||||
max_cycles: Maximum number of poll cycles (None = forever).
|
||||
"""
|
||||
logger.info("╔══════════════════════════════════════════════════════════╗")
|
||||
logger.info("║ Hawkbit DDI Client v1.0 ║")
|
||||
logger.info("╠══════════════════════════════════════════════════════════╣")
|
||||
logger.info("║ Server: %s", self.base_url)
|
||||
logger.info("║ Tenant: %s", self.tenant)
|
||||
logger.info("║ Device: %s", self.controller_id)
|
||||
logger.info("║ Auth: %s",
|
||||
self.auth_header.split(" ", 1)[0] if self.auth_header else "none")
|
||||
logger.info("╚══════════════════════════════════════════════════════════╝")
|
||||
|
||||
cycle = 0
|
||||
while max_cycles is None or cycle < max_cycles:
|
||||
cycle += 1
|
||||
logger.info("── Poll cycle %d ──", cycle)
|
||||
|
||||
poll_result = self.poll()
|
||||
if poll_result is None:
|
||||
logger.warning("Poll failed; retrying in %ds", min(self.polling_interval, 30))
|
||||
if not daemon:
|
||||
return False
|
||||
time.sleep(min(self.polling_interval, 30))
|
||||
continue
|
||||
|
||||
# ── report device attributes after first successful poll ──
|
||||
# Target is auto-created by GET /controller/v1/{SN} (findOrRegister…);
|
||||
# configData requires an existing target → poll first, then report.
|
||||
if not self._attrs_reported:
|
||||
self.report_attributes()
|
||||
self._attrs_reported = True
|
||||
|
||||
links = poll_result.get("links", {})
|
||||
|
||||
if "deploymentBase" in links:
|
||||
self.process_deployment(links["deploymentBase"])
|
||||
|
||||
elif "cancelAction" in links:
|
||||
self.process_cancel(links["cancelAction"])
|
||||
|
||||
elif "confirmationBase" in links:
|
||||
self.process_confirmation(links["confirmationBase"])
|
||||
|
||||
if "deploymentBase" not in links and "cancelAction" not in links and "confirmationBase" not in links:
|
||||
logger.debug("No pending action.")
|
||||
|
||||
if not daemon:
|
||||
return True
|
||||
|
||||
logger.info("Sleeping %ds …", self.polling_interval)
|
||||
time.sleep(self.polling_interval)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI entry point
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
def main(argv: Optional[List[str]] = None) -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Hawkbit DDI Client — lightweight OTA agent (pure Python)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Gateway token, poll every 30s
|
||||
%(prog)s -u https://hawkbit.example.com -t DEFAULT --gateway-token s3cret -i 30
|
||||
|
||||
# Target token, one-shot
|
||||
%(prog)s -u http://10.0.0.1:8080 -t DEFAULT --target-token tok --once
|
||||
|
||||
# Custom controller-id, insecure SSL, custom download dir
|
||||
%(prog)s -u https://hawkbit:8443 -t prod --gateway-token k \\
|
||||
--controller-id edge-042 -d /data/ota --no-ssl-verify -v
|
||||
""",
|
||||
)
|
||||
|
||||
# ── required ──────────────────────────────────────────────────────
|
||||
ap.add_argument("-u", "--base-url", required=True,
|
||||
help="Hawkbit base URL, e.g. https://hawkbit.example.com")
|
||||
ap.add_argument("-t", "--tenant", required=True,
|
||||
help="Tenant name, e.g. DEFAULT")
|
||||
|
||||
# ── authentication (mutually-exclusive group would be nice but accepts either) ─
|
||||
ap.add_argument("--gateway-token", metavar="TOKEN",
|
||||
help="Gateway security token → Authorization: GatewayToken TOKEN")
|
||||
ap.add_argument("--target-token", metavar="TOKEN",
|
||||
help="Target security token → Authorization: TargetToken TOKEN")
|
||||
|
||||
# ── optional ──────────────────────────────────────────────────────
|
||||
ap.add_argument("--controller-id",
|
||||
help="Controller ID (default: auto-detect from device)")
|
||||
ap.add_argument("-d", "--download-dir", default="/tmp/hawkbit",
|
||||
help="Directory for downloaded artifacts [default: /tmp/hawkbit]")
|
||||
ap.add_argument("-i", "--polling-interval", type=int, default=60,
|
||||
help="Default polling interval in seconds (overridden by server) [default: 60]")
|
||||
ap.add_argument("--no-verify", action="store_true",
|
||||
help="Skip SHA256 verification")
|
||||
ap.add_argument("--no-resume", action="store_true",
|
||||
help="Disable resumable download")
|
||||
ap.add_argument("--no-ssl-verify", action="store_true",
|
||||
help="Disable TLS certificate verification (INSECURE)")
|
||||
ap.add_argument("--once", action="store_true",
|
||||
help="Poll exactly once then exit")
|
||||
ap.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Debug-level logging")
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# ── logging ───────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
# ── auth header ───────────────────────────────────────────────────
|
||||
auth = None
|
||||
if args.gateway_token:
|
||||
token = args.gateway_token
|
||||
if token.startswith("@"):
|
||||
with open(token[1:]) as f:
|
||||
token = f.read().strip()
|
||||
logger.info("Read gateway token from file: %s", args.gateway_token)
|
||||
auth = f"GatewayToken {token}"
|
||||
elif args.target_token:
|
||||
token = args.target_token
|
||||
if token.startswith("@"):
|
||||
with open(token[1:]) as f:
|
||||
token = f.read().strip()
|
||||
logger.info("Read target token from file: %s", args.target_token)
|
||||
auth = f"TargetToken {token}"
|
||||
|
||||
if not auth:
|
||||
logger.warning("No authentication token provided — server may reject requests")
|
||||
|
||||
# ── run ───────────────────────────────────────────────────────────
|
||||
client = HawkbitDDIClient(
|
||||
base_url=args.base_url,
|
||||
tenant=args.tenant,
|
||||
controller_id=args.controller_id,
|
||||
auth_header=auth,
|
||||
download_dir=args.download_dir,
|
||||
verify_sha256=not args.no_verify,
|
||||
polling_interval=args.polling_interval,
|
||||
ssl_verify=not args.no_ssl_verify,
|
||||
resume_download=not args.no_resume,
|
||||
)
|
||||
|
||||
try:
|
||||
ok = client.run(daemon=not args.once)
|
||||
sys.exit(0 if ok else 1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Stopped (Ctrl+C)")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
pom.xml
19
pom.xml
@@ -17,7 +17,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.6</version>
|
||||
<version>4.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
@@ -42,12 +42,12 @@
|
||||
|
||||
<properties>
|
||||
<!-- Overrides START - to be reviewed regularly -->
|
||||
<!-- Spring boot version overrides (should be reviewed with every Spring boot upgrade) - START -->
|
||||
<!-- Spring boot version overrides - END -->
|
||||
<!-- Override vulnerable commons-fileupload used by feign-form-spring (via spring-cloud-starter-openfeign) -->
|
||||
<commons-fileupload.version>1.6.0</commons-fileupload.version>
|
||||
<!-- Override commons-lang3 version since 3.17 is vulnerable -->
|
||||
<commons-lang.version>3.20.0</commons-lang.version>
|
||||
<!-- Override Netty 4.2.12 due to CVE -->
|
||||
<netty.version>4.2.13.Final</netty.version>
|
||||
<!-- Overrides END -->
|
||||
|
||||
<revision>0-SNAPSHOT</revision>
|
||||
@@ -58,20 +58,15 @@
|
||||
<java.client.version>17</java.client.version>
|
||||
|
||||
<!-- must be the same as the parent version -->
|
||||
<spring-boot.version>4.0.6</spring-boot.version>
|
||||
<spring-cloud.version>2025.1.1</spring-cloud.version>
|
||||
<spring-boot.version>4.1.0</spring-boot.version>
|
||||
<spring-cloud.version>2025.1.2</spring-cloud.version>
|
||||
<spring-ai.version>1.1.7</spring-ai.version>
|
||||
<springdoc-openapi.version>3.0.3</springdoc-openapi.version>
|
||||
|
||||
<!-- openfeign versions -->
|
||||
<spring-cloud-openfeign.version>5.0.1</spring-cloud-openfeign.version>
|
||||
<spring-cloud-openfeign.version>5.0.2</spring-cloud-openfeign.version>
|
||||
<openfeign-hc5.version>13.12</openfeign-hc5.version>
|
||||
|
||||
<!-- Spring boot version overrides (should be reviewed with every Spring boot upgrade) - START -->
|
||||
<!-- Newer versions needed than defined in the Spring boot -->
|
||||
|
||||
<!-- Spring boot version overrides - END -->
|
||||
|
||||
<!-- Eclipselink - START -->
|
||||
<!--
|
||||
Ethlo plugin uses 4.x dependencies - since it only does static weaving - it should be fine - but should eventually upgrade its dependencies.
|
||||
@@ -88,7 +83,7 @@
|
||||
<commons-collections4.version>4.5.0</commons-collections4.version>
|
||||
<io-protostuff.version>1.8.0</io-protostuff.version>
|
||||
<!-- the version should be `synchronized` with version used by springdoc-openapi -->
|
||||
<swagger-annotations-jakarta.version>2.2.50</swagger-annotations-jakarta.version>
|
||||
<swagger-annotations-jakarta.version>2.2.51</swagger-annotations-jakarta.version>
|
||||
|
||||
<!-- test -->
|
||||
<rabbitmq-http-client.version>5.5.0</rabbitmq-http-client.version>
|
||||
|
||||
Reference in New Issue
Block a user