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
|
# set Java
|
||||||
ARG JAVA_VERSION=21.0.11_10
|
ARG JAVA_VERSION=21
|
||||||
# extracts spring layers from the app jar (to optimize boot)
|
# extracts spring layers from the app jar (to optimize boot)
|
||||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS build
|
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
|
# set Java
|
||||||
ARG JAVA_VERSION=21.0.11_10
|
ARG JAVA_VERSION=21
|
||||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS build
|
FROM eclipse-temurin:${JAVA_VERSION}-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG HAWKBIT_APP=hawkbit-repository-jpa-init
|
ARG HAWKBIT_APP=hawkbit-repository-jpa-init
|
||||||
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# set Java
|
# set Java
|
||||||
ARG JAVA_VERSION=21.0.11_10
|
ARG JAVA_VERSION=21
|
||||||
# extracts spring layers from the app jar (to optimize boot)
|
# 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_APP=hawkbit-update-server
|
||||||
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
ARG HAWKBIT_VERSION=0-SNAPSHOT
|
||||||
@@ -18,7 +18,7 @@ RUN set -x &&\
|
|||||||
java -Djarmode=tools -jar /${APP}-${VERSION}.jar extract --layers --launcher --destination . &&\
|
java -Djarmode=tools -jar /${APP}-${VERSION}.jar extract --layers --launcher --destination . &&\
|
||||||
rm /${APP}-${VERSION}.jar
|
rm /${APP}-${VERSION}.jar
|
||||||
|
|
||||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine
|
FROM eclipse-temurin:${JAVA_VERSION}-alpine
|
||||||
ARG APP_DIR=/app
|
ARG APP_DIR=/app
|
||||||
WORKDIR ${APP_DIR}
|
WORKDIR ${APP_DIR}
|
||||||
RUN addgroup -S hawkbit_group && adduser -D hawkbit -G hawkbit_group
|
RUN addgroup -S hawkbit_group && adduser -D hawkbit -G hawkbit_group
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
# Postgres service
|
# Postgres service
|
||||||
# ---------------------
|
# ---------------------
|
||||||
postgres:
|
postgres:
|
||||||
image: "postgres:16.5"
|
image: "postgres:16"
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
deploy:
|
deploy:
|
||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
# RabbitMQ service
|
# RabbitMQ service
|
||||||
# ---------------------
|
# ---------------------
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: "rabbitmq:4-management-alpine"
|
image: "rabbitmq:management-alpine"
|
||||||
hostname: "rabbitmq"
|
hostname: "rabbitmq"
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_VHOST: "/"
|
RABBITMQ_DEFAULT_VHOST: "/"
|
||||||
|
|||||||
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
|
||||||
# ---------------------
|
# ---------------------
|
||||||
hawkbit-ddi:
|
hawkbit-ddi:
|
||||||
image: "hawkbit/hawkbit-ddi-server:latest"
|
image: "ghcr.io/eclipse-hawkbit/hawkbit-ddi-server:latest"
|
||||||
environment:
|
environment:
|
||||||
- 'PROFILES=postgresql'
|
- 'PROFILES=postgresql'
|
||||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
# HawkBit DMF
|
# HawkBit DMF
|
||||||
# ---------------------
|
# ---------------------
|
||||||
hawkbit-dmf:
|
hawkbit-dmf:
|
||||||
image: "hawkbit/hawkbit-dmf-server:latest"
|
image: "ghcr.io/eclipse-hawkbit/hawkbit-dmf-server:latest"
|
||||||
environment:
|
environment:
|
||||||
- 'PROFILES=postgresql'
|
- 'PROFILES=postgresql'
|
||||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
||||||
@@ -62,7 +62,7 @@ services:
|
|||||||
# HawkBit MGMT
|
# HawkBit MGMT
|
||||||
# ---------------------
|
# ---------------------
|
||||||
hawkbit-mgmt:
|
hawkbit-mgmt:
|
||||||
image: "hawkbit/hawkbit-mgmt-server:latest"
|
image: "ghcr.io/eclipse-hawkbit/hawkbit-mgmt-server:latest"
|
||||||
environment:
|
environment:
|
||||||
- 'PROFILES=postgresql'
|
- 'PROFILES=postgresql'
|
||||||
- 'SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/hawkbit'
|
- '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:
|
services:
|
||||||
|
postgres:
|
||||||
# ---------------------
|
image: postgres:16
|
||||||
# HawkBit UI
|
|
||||||
# ---------------------
|
|
||||||
hawkbit-ui:
|
|
||||||
image: "hawkbit/hawkbit-ui:latest"
|
|
||||||
environment:
|
environment:
|
||||||
- 'SPRING_APPLICATION_JSON={"hawkbit.server.mgmtUrl": "http://hawkbit-mgmt:8080"}'
|
POSTGRES_DB: hawkbit
|
||||||
restart: always
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 8088:8088
|
- "5432:5432"
|
||||||
labels:
|
|
||||||
NAME: "hawkbit-ui"
|
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:
|
volumes:
|
||||||
artifactrepo:
|
pgdata:
|
||||||
driver: local
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
# HawkBit UI service
|
# HawkBit UI service
|
||||||
# ---------------------
|
# ---------------------
|
||||||
hawkbit-ui:
|
hawkbit-ui:
|
||||||
image: "hawkbit/hawkbit-ui:latest"
|
image: "ghcr.io/eclipse-hawkbit/hawkbit-ui:lates"
|
||||||
environment:
|
environment:
|
||||||
- 'SPRING_APPLICATION_JSON={"hawkbit.server.mgmtUrl": "http://hawkbit:8080"}'
|
- 'SPRING_APPLICATION_JSON={"hawkbit.server.mgmtUrl": "http://hawkbit:8080"}'
|
||||||
restart: always
|
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.sql.SQLException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrlResolver;
|
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrlResolver;
|
||||||
|
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||||
import org.eclipse.hawkbit.repository.ConfirmationManagement;
|
import org.eclipse.hawkbit.repository.ConfirmationManagement;
|
||||||
import org.eclipse.hawkbit.repository.ControllerManagement;
|
import org.eclipse.hawkbit.repository.ControllerManagement;
|
||||||
import org.eclipse.hawkbit.repository.DeploymentManagement;
|
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.DistributionSet;
|
||||||
import org.eclipse.hawkbit.repository.model.SoftwareModule;
|
import org.eclipse.hawkbit.repository.model.SoftwareModule;
|
||||||
import org.eclipse.hawkbit.repository.model.Target;
|
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.connection.ConnectionFactory;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
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.RabbitListenerContainerFactory;
|
||||||
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
||||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer;
|
import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
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.RetryPolicy;
|
||||||
import org.springframework.core.retry.RetryTemplate;
|
import org.springframework.core.retry.RetryTemplate;
|
||||||
import org.springframework.util.ErrorHandler;
|
import org.springframework.util.ErrorHandler;
|
||||||
|
import org.springframework.util.ObjectUtils;
|
||||||
import tools.jackson.databind.json.JsonMapper;
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,13 +94,25 @@ public class DmfApiConfiguration {
|
|||||||
return new ConditionalRejectingErrorHandler(new RequeueExceptionStrategy(fatalExceptionStrategies, fatalExceptionTypes));
|
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}.
|
* @return {@link RabbitTemplate} with automatic retry, published confirms and {@link JacksonJsonMessageConverter}.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public RabbitTemplate rabbitTemplate(final JsonMapper jsonMapper) {
|
public RabbitTemplate rabbitTemplate(@Qualifier("dmfMessageConverter") final MessageConverter dmfMessageConverter) {
|
||||||
final RabbitTemplate rabbitTemplate = new RabbitTemplate(rabbitConnectionFactory);
|
final RabbitTemplate rabbitTemplate = new RabbitTemplate(rabbitConnectionFactory);
|
||||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter(jsonMapper));
|
rabbitTemplate.setMessageConverter(dmfMessageConverter);
|
||||||
|
|
||||||
// the same policy the previously used default ExponentialBackOffPolicy applied
|
// the same policy the previously used default ExponentialBackOffPolicy applied
|
||||||
rabbitTemplate.setRetryTemplate(new RetryTemplate(RetryPolicy.builder()
|
rabbitTemplate.setRetryTemplate(new RetryTemplate(RetryPolicy.builder()
|
||||||
@@ -168,7 +185,8 @@ public class DmfApiConfiguration {
|
|||||||
final SystemManagement systemManagement,
|
final SystemManagement systemManagement,
|
||||||
final TargetManagement<? extends Target> targetManagement,
|
final TargetManagement<? extends Target> targetManagement,
|
||||||
final DistributionSetManagement<? extends DistributionSet> distributionSetManagement,
|
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,
|
return new AmqpMessageDispatcherService(rabbitTemplate, amqpSenderService, artifactUrlHandler,
|
||||||
systemManagement, targetManagement, softwareModuleManagement, distributionSetManagement,
|
systemManagement, targetManagement, softwareModuleManagement, distributionSetManagement,
|
||||||
deploymentManagement);
|
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.EntityNotFoundException;
|
||||||
import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException;
|
import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException;
|
||||||
import org.eclipse.hawkbit.repository.exception.TenantNotExistException;
|
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.amqp.support.converter.MessageConversionException;
|
||||||
import org.springframework.messaging.MessageHandlingException;
|
import org.springframework.messaging.MessageHandlingException;
|
||||||
import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException;
|
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.model.StoredArtifactInfo;
|
||||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrl;
|
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrl;
|
||||||
import org.eclipse.hawkbit.artifact.urlresolver.ArtifactUrlResolver;
|
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.EventTopic;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
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.core.MessageProperties;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
||||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.TestPropertySource;
|
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());
|
Create.builder().controllerId(CONTROLLER_ID).securityToken(TEST_TOKEN).address(AMQP_URI.toString()).build());
|
||||||
|
|
||||||
this.rabbitTemplate = Mockito.mock(RabbitTemplate.class);
|
this.rabbitTemplate = Mockito.mock(RabbitTemplate.class);
|
||||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new JacksonJsonMessageConverter());
|
when(rabbitTemplate.getMessageConverter()).thenReturn(new DmfMessageConverter());
|
||||||
|
|
||||||
senderService = Mockito.mock(DefaultAmqpMessageSenderService.class);
|
senderService = Mockito.mock(DefaultAmqpMessageSenderService.class);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import java.net.URI;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
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.EventTopic;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
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.Message;
|
||||||
import org.springframework.amqp.core.MessageProperties;
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
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.MessageConversionException;
|
||||||
import org.springframework.amqp.support.converter.MessageConverter;
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class AmqpMessageHandlerServiceTest {
|
|||||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||||
void before() {
|
void before() {
|
||||||
TenantConfigHelper.setTenantConfigurationManagement(tenantConfigurationManagement);
|
TenantConfigHelper.setTenantConfigurationManagement(tenantConfigurationManagement);
|
||||||
messageConverter = new JacksonJsonMessageConverter();
|
messageConverter = new DmfMessageConverter();
|
||||||
lenient().when(rabbitTemplate.getMessageConverter()).thenReturn(messageConverter);
|
lenient().when(rabbitTemplate.getMessageConverter()).thenReturn(messageConverter);
|
||||||
|
|
||||||
amqpMessageHandlerService = new AmqpMessageHandlerService(
|
amqpMessageHandlerService = new AmqpMessageHandlerService(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import java.util.List;
|
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.DmfActionStatus;
|
||||||
import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus;
|
import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus;
|
||||||
import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent;
|
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.Message;
|
||||||
import org.springframework.amqp.core.MessageProperties;
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
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.MessageConversionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +54,7 @@ class BaseAmqpServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void convertMessageTest() {
|
void convertMessageTest() {
|
||||||
final DmfActionUpdateStatus actionUpdateStatus = createActionStatus();
|
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 Message message = rabbitTemplate.getMessageConverter().toMessage(actionUpdateStatus, createJsonProperties());
|
||||||
final DmfActionUpdateStatus convertedActionUpdateStatus = baseAmqpService.convertMessage(message, DmfActionUpdateStatus.class);
|
final DmfActionUpdateStatus convertedActionUpdateStatus = baseAmqpService.convertMessage(message, DmfActionUpdateStatus.class);
|
||||||
@@ -91,7 +91,7 @@ class BaseAmqpServiceTest {
|
|||||||
@ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 0) })
|
@ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 0) })
|
||||||
void updateActionStatusWithInvalidJsonContent() {
|
void updateActionStatusWithInvalidJsonContent() {
|
||||||
final Message message = createMessage("Invalid Json".getBytes());
|
final Message message = createMessage("Invalid Json".getBytes());
|
||||||
when(rabbitTemplate.getMessageConverter()).thenReturn(new JacksonJsonMessageConverter());
|
when(rabbitTemplate.getMessageConverter()).thenReturn(new DmfMessageConverter());
|
||||||
|
|
||||||
assertThatExceptionOfType(MessageConversionException.class)
|
assertThatExceptionOfType(MessageConversionException.class)
|
||||||
.as("Expected MessageConversionException for invalid JSON")
|
.as("Expected MessageConversionException for invalid JSON")
|
||||||
@@ -111,5 +111,4 @@ class BaseAmqpServiceTest {
|
|||||||
private DmfActionUpdateStatus createActionStatus() {
|
private DmfActionUpdateStatus createActionStatus() {
|
||||||
return new DmfActionUpdateStatus(1L, DmfActionStatus.RUNNING, null, 2L, List.of("Message 1", "Message 2"), 2);
|
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 java.util.List;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
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;
|
import org.springframework.amqp.support.converter.MessageConversionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,5 +30,22 @@
|
|||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
<artifactId>jackson-annotations</artifactId>
|
<artifactId>jackson-annotations</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
</project>
|
</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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.awaitility.Awaitility;
|
import org.awaitility.Awaitility;
|
||||||
import org.awaitility.core.ConditionFactory;
|
import org.awaitility.core.ConditionFactory;
|
||||||
|
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||||
import org.eclipse.hawkbit.repository.jpa.JpaRepositoryConfiguration;
|
import org.eclipse.hawkbit.repository.jpa.JpaRepositoryConfiguration;
|
||||||
import org.eclipse.hawkbit.repository.test.TestConfiguration;
|
import org.eclipse.hawkbit.repository.test.TestConfiguration;
|
||||||
import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest;
|
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.RabbitAdmin;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.amqp.rabbit.junit.RabbitAvailable;
|
import org.springframework.amqp.rabbit.junit.RabbitAvailable;
|
||||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
import org.springframework.test.annotation.DirtiesContext.ClassMode;
|
import org.springframework.test.annotation.DirtiesContext.ClassMode;
|
||||||
@@ -88,7 +88,7 @@ public abstract class AbstractAmqpIntegrationTest extends AbstractIntegrationTes
|
|||||||
|
|
||||||
private RabbitTemplate createDmfClient() {
|
private RabbitTemplate createDmfClient() {
|
||||||
final RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
final RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||||
template.setMessageConverter(new JacksonJsonMessageConverter());
|
template.setMessageConverter(new DmfMessageConverter());
|
||||||
template.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
template.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||||
template.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
template.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||||
template.setExchange(getExchange());
|
template.setExchange(getExchange());
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.eclipse.hawkbit.dmf.DmfMessageConverter;
|
||||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
@@ -32,7 +32,7 @@ public class AmqpTestConfiguration {
|
|||||||
@Primary
|
@Primary
|
||||||
public RabbitTemplate rabbitTemplateForTest(final ConnectionFactory connectionFactory) {
|
public RabbitTemplate rabbitTemplateForTest(final ConnectionFactory connectionFactory) {
|
||||||
final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
|
final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
|
||||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter());
|
rabbitTemplate.setMessageConverter(new DmfMessageConverter());
|
||||||
rabbitTemplate.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
rabbitTemplate.setReplyTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||||
rabbitTemplate.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
rabbitTemplate.setReceiveTimeout(TimeUnit.SECONDS.toMillis(3));
|
||||||
return rabbitTemplate;
|
return rabbitTemplate;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
package org.eclipse.hawkbit.autoconfigure.dmf.amqp;
|
package org.eclipse.hawkbit.autoconfigure.dmf.amqp;
|
||||||
|
|
||||||
import org.eclipse.hawkbit.amqp.DmfApiConfiguration;
|
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.ConditionalOnClass;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|||||||
@@ -495,16 +495,13 @@ class MgmtDistributionSetTypeResourceTest extends AbstractManagementApiIntegrati
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getDistributionSetTypesFilteredBySoftDeletedMode() throws Exception {
|
void getDistributionSetTypesFilteredBySoftDeletedMode() throws Exception {
|
||||||
final DistributionSetType activeType = distributionSetTypeManagement.create(
|
distributionSetTypeManagement.create(DistributionSetTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
||||||
DistributionSetTypeManagement.Create.builder()
|
|
||||||
.key("activeKey").name("activeType").build());
|
|
||||||
|
|
||||||
// create type + DS using it, then delete type → soft-delete
|
// create type + DS using it, then delete type → soft-delete
|
||||||
final DistributionSetType deletedType = distributionSetTypeManagement.create(
|
final DistributionSetType deletedType = distributionSetTypeManagement.create(
|
||||||
DistributionSetTypeManagement.Create.builder()
|
DistributionSetTypeManagement.Create.builder().key("deletedKey").name("deletedType").build());
|
||||||
.key("deletedKey").name("deletedType").build());
|
distributionSetManagement.create(
|
||||||
distributionSetManagement.create(DistributionSetManagement.Create.builder()
|
DistributionSetManagement.Create.builder().type(deletedType).name("ds").version("1.0").build());
|
||||||
.type(deletedType).name("ds").version("1.0").build());
|
|
||||||
distributionSetTypeManagement.delete(deletedType.getId());
|
distributionSetTypeManagement.delete(deletedType.getId());
|
||||||
|
|
||||||
// default — built-in + activeType, no deletedType
|
// default — built-in + activeType, no deletedType
|
||||||
|
|||||||
@@ -1230,8 +1230,7 @@ class MgmtSoftwareModuleResourceTest extends AbstractManagementApiIntegrationTes
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSoftwareModulesFilteredBySoftDeletedMode() throws Exception {
|
void getSoftwareModulesFilteredBySoftDeletedMode() throws Exception {
|
||||||
final SoftwareModule activeSm = softwareModuleManagement.create(
|
softwareModuleManagement.create(SoftwareModuleManagement.Create.builder().type(osType).name("activeSm").version("1.0").build());
|
||||||
SoftwareModuleManagement.Create.builder().type(osType).name("activeSm").version("1.0").build());
|
|
||||||
|
|
||||||
SoftwareModule deletedSm = softwareModuleManagement.create(
|
SoftwareModule deletedSm = softwareModuleManagement.create(
|
||||||
SoftwareModuleManagement.Create.builder().type(osType).name("deletedSm").version("1.0").build());
|
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)
|
// 3 built-in types exist (os, runtime, application)
|
||||||
final int builtInTypes = 3;
|
final int builtInTypes = 3;
|
||||||
|
|
||||||
final SoftwareModuleType activeType = softwareModuleTypeManagement.create(
|
softwareModuleTypeManagement.create(SoftwareModuleTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
||||||
SoftwareModuleTypeManagement.Create.builder().key("activeKey").name("activeType").build());
|
|
||||||
|
|
||||||
// create type + SM using it, then delete type → soft-delete
|
// create type + SM using it, then delete type → soft-delete
|
||||||
final SoftwareModuleType deletedType = softwareModuleTypeManagement.create(
|
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));
|
String.format("Operator %s is not supported for map fields with value null", op));
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
|
// map entry with key not null (exist) - use left join per key, key/value filtered in where
|
||||||
return isNot(op)
|
final MapJoin<?, ?, ?> mapJoin = pathResolver.getJoinForWhere(attribute, split[1]);
|
||||||
? compare(comparison, toMapValuePath(pathResolver.getJoinOnInner(attribute, split[1])))
|
return cb.and(equal(mapJoin.key(), split[1]), compare(comparison, toMapValuePath(mapJoin)));
|
||||||
: cb.and(equal(mapPath.key(), split[1]), compare(comparison, toMapValuePath(mapPath)));
|
|
||||||
}
|
}
|
||||||
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
|
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
|
||||||
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
|
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
|
||||||
@@ -437,8 +436,8 @@ public class SpecificationBuilder<T> {
|
|||||||
return getCollectionPathResolver(attribute.getName()).getJoinOn(value);
|
return getCollectionPathResolver(attribute.getName()).getJoinOn(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MapJoin<?, ?, ?> getJoinOnInner(final Attribute<?, ?> attribute, final Object value) {
|
private MapJoin<?, ?, ?> getJoinForWhere(final Attribute<?, ?> attribute, final Object mapKeyName) {
|
||||||
return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value);
|
return getCollectionPathResolver(attribute.getName()).getJoinForWhere(mapKeyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Integer> getState() {
|
private Map<String, Integer> getState() {
|
||||||
@@ -463,7 +462,7 @@ public class SpecificationBuilder<T> {
|
|||||||
@Setter
|
@Setter
|
||||||
private int pos;
|
private int pos;
|
||||||
private final Map<Object, MapJoin<?, ?, ?>> joinOnCache = new HashMap<>();
|
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) {
|
private CollectionPathResolver(final String attributeName) {
|
||||||
this.attributeName = attributeName;
|
this.attributeName = attributeName;
|
||||||
@@ -488,12 +487,9 @@ public class SpecificationBuilder<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private MapJoin<?, ?, ?> getJoinOnInner(final Object value) {
|
private MapJoin<?, ?, ?> getJoinForWhere(final Object mapKeyName) {
|
||||||
return joinOnInnerCache.computeIfAbsent(value, k -> {
|
return joinForWhereCache.computeIfAbsent(mapKeyName, k ->
|
||||||
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.INNER);
|
(MapJoin<?, ?, ?>) root.join(attributeName, JoinType.LEFT));
|
||||||
mapPath.on(equal(mapPath.key(), k));
|
|
||||||
return mapPath;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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==rooty")).hasSize(1).containsExactlyInAnyOrder(root1);
|
||||||
assertThat(filter("subMap.x==*tx and subMap.y!=rootx")).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"))
|
assertThat(filter("subMap.x==*tx or subMap.x==rooty")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
|
||||||
.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!=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
|
@Test
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ public class DistributedLockRepository extends DefaultLockRepository {
|
|||||||
timeToLive * lockProperties.getRefreshOnRemainPercent() / 100);
|
timeToLive * lockProperties.getRefreshOnRemainPercent() / 100);
|
||||||
final int refreshAfterMS = timeToLive - triggerOnRemainMS;
|
final int refreshAfterMS = timeToLive - triggerOnRemainMS;
|
||||||
refreshAfterMillis = refreshAfterMS <= 0 ? null : refreshAfterMS;
|
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)
|
@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)");
|
"((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,
|
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)");
|
"((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
|
@Test
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.EventTopic;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey;
|
||||||
import org.eclipse.hawkbit.dmf.amqp.api.MessageType;
|
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.core.RabbitTemplate;
|
||||||
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
||||||
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
import org.springframework.amqp.support.converter.DefaultJacksonJavaTypeMapper;
|
||||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract class for sender and receiver service.
|
* 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
|
// 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
|
// 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.
|
// payload (e.g. REQUEST_ATTRIBUTES_UPDATE), so the SimpleMessageConverter is used instead per default.
|
||||||
rabbitTemplate.setMessageConverter(new JacksonJsonMessageConverter());
|
rabbitTemplate.setMessageConverter(new DmfMessageConverter());
|
||||||
|
|
||||||
if (initVHost) {
|
if (initVHost) {
|
||||||
final RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory);
|
final RabbitAdmin rabbitAdmin = new RabbitAdmin(connectionFactory);
|
||||||
|
|||||||
1
hawkbit-ui/.gitignore
vendored
1
hawkbit-ui/.gitignore
vendored
@@ -8,3 +8,4 @@ tsconfig.json
|
|||||||
types.d.ts
|
types.d.ts
|
||||||
vite.config.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) {
|
public TargetActionsHistory(final HawkbitMgmtClient hawkbitClient, TargetView.TargetActionsHistoryLayout.ActionStepsGrid actionStepsGrid) {
|
||||||
this.hawkbitClient = hawkbitClient;
|
this.hawkbitClient = hawkbitClient;
|
||||||
setWidthFull();
|
setWidthFull();
|
||||||
|
setMinHeight("200px");
|
||||||
|
setAllRowsVisible(true);
|
||||||
addColumn(new ComponentRenderer<>(ActionStatusEntry::getStatusIcon)).setHeader(STATUS).setAutoWidth(true).setFlexGrow(0);
|
addColumn(new ComponentRenderer<>(ActionStatusEntry::getStatusIcon)).setHeader(STATUS).setAutoWidth(true).setFlexGrow(0);
|
||||||
addColumn(ActionStatusEntry::getDistributionSetName).setHeader("Distribution Set").setAutoWidth(true);
|
addColumn(ActionStatusEntry::getDistributionSetName).setHeader("Distribution Set").setAutoWidth(true);
|
||||||
addColumn(Utils.localDateTimeRenderer(ActionStatusEntry::getLastModifiedAt))
|
addColumn(Utils.localDateTimeRenderer(ActionStatusEntry::getLastModifiedAt))
|
||||||
@@ -73,25 +75,60 @@ public final class TargetActionsHistory extends Grid<TargetActionsHistory.Action
|
|||||||
this.actionStepsGrid.setTarget(target);
|
this.actionStepsGrid.setTarget(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ActionStatusEntry> fetchActions() {
|
public void loadData() {
|
||||||
return hawkbitClient.getTargetRestApi().getActionHistory(target.getControllerId(), null, 0, 30, null)
|
if (target == null) return;
|
||||||
.getBody()
|
List<ActionStatusEntry> items;
|
||||||
.getContent()
|
try {
|
||||||
.stream()
|
items = fetchActions();
|
||||||
.map(action -> new ActionStatusEntry(action, () -> setItems(fetchActions())))
|
} catch (Exception e) {
|
||||||
.filter(value -> value.action != null)
|
log.error("loadData: failed to fetch actions for target {}", target.getControllerId(), e);
|
||||||
.toList();
|
setItems(List.of());
|
||||||
|
actionStepsGrid.setActionId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
log.info("loadData: {} action entries for target {}", items.size(), target.getControllerId());
|
||||||
@Override
|
setItems(items);
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
items.stream().findFirst().ifPresentOrElse(e -> {
|
||||||
List<ActionStatusEntry> actionStatusEntries = fetchActions();
|
|
||||||
setItems(actionStatusEntries);
|
|
||||||
actionStatusEntries.stream().findFirst().ifPresentOrElse(e -> {
|
|
||||||
// select first action in the list by default
|
|
||||||
asSingleSelect().setValue(e);
|
asSingleSelect().setValue(e);
|
||||||
actionStepsGrid.setActionId(e.action.getId());
|
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 {
|
protected class ActionStatusEntry {
|
||||||
@@ -101,20 +138,35 @@ public final class TargetActionsHistory extends Grid<TargetActionsHistory.Action
|
|||||||
MgmtDistributionSet distributionSet;
|
MgmtDistributionSet distributionSet;
|
||||||
|
|
||||||
public ActionStatusEntry(final MgmtAction mgmtAction, final Runnable onUpdate) {
|
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;
|
this.onUpdate = onUpdate;
|
||||||
if (action == null) {
|
if (action == null) {
|
||||||
log.error("Unable to fetch the action with id : {}", mgmtAction.getId());
|
log.error("Unable to fetch the action with id : {}", mgmtAction.getId());
|
||||||
return;
|
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 -> {
|
this.action.getLink("distributionset").ifPresent(link -> {
|
||||||
|
log.info("ActionStatusEntry: found distributionset link, href={}", link.getHref());
|
||||||
try {
|
try {
|
||||||
Long dsId = Long.parseLong(link.getHref().substring(link.getHref().lastIndexOf("/") + 1));
|
Long dsId = Long.parseLong(link.getHref().substring(link.getHref().lastIndexOf("/") + 1));
|
||||||
this.distributionSet = hawkbitClient.getDistributionSetRestApi().getDistributionSet(dsId).getBody();
|
this.distributionSet = hawkbitClient.getDistributionSetRestApi().getDistributionSet(dsId).getBody();
|
||||||
|
log.info("ActionStatusEntry: distributionSet loaded, name={}",
|
||||||
|
this.distributionSet != null ? this.distributionSet.getName() : "NULL");
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
log.error("Error parsing distribution set ID", 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() {
|
private boolean isActive() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ package org.eclipse.hawkbit.ui.security;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
@@ -50,7 +51,9 @@ class UserDetailsSetter extends OncePerRequestFilter {
|
|||||||
final Collection<? extends GrantedAuthority> grantedAuthorities = grantedAuthoritiesService.getGrantedAuthorities(authentication);
|
final Collection<? extends GrantedAuthority> grantedAuthorities = grantedAuthoritiesService.getGrantedAuthorities(authentication);
|
||||||
if (authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) {
|
if (authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) {
|
||||||
newAuthentication = new 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());
|
oAuth2AuthenticationToken.getAuthorizedClientRegistrationId());
|
||||||
if (authentication.getPrincipal() instanceof OidcUser user) {
|
if (authentication.getPrincipal() instanceof OidcUser user) {
|
||||||
// if there is no refresh token and the access token is expired then re-login is required
|
// 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 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() {
|
private DistributionSetRawFilter() {
|
||||||
name.setPlaceholder("<rsql filter>");
|
name.setPlaceholder("<rsql filter>");
|
||||||
}
|
}
|
||||||
@@ -138,13 +141,25 @@ public class DistributionSetView extends TableView<MgmtDistributionSet, Long> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String filter() {
|
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
|
@Override
|
||||||
public void setFilter(String filter) {
|
public void setFilter(String filter) {
|
||||||
name.setValue(filter);
|
name.setValue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String encodeRsqlValue(String value) {
|
||||||
|
return value.replace("\\", "\\\\")
|
||||||
|
.replace("*", "\\*")
|
||||||
|
.replace("\"", "\\\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class DistributionSetFilter implements Filter.Rsql {
|
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.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import com.vaadin.flow.component.select.Select;
|
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.tabs.TabSheet;
|
||||||
import com.vaadin.flow.component.textfield.TextArea;
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
@@ -330,9 +331,24 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
|||||||
return List.of(layout);
|
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
|
@Override
|
||||||
public String filter() {
|
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
|
@Override
|
||||||
@@ -352,9 +368,11 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
|||||||
private final TargetTags targetTags;
|
private final TargetTags targetTags;
|
||||||
private final TargetMetadata targetMetadata;
|
private final TargetMetadata targetMetadata;
|
||||||
private final TargetActionsHistoryLayout targetActionsHistoryLayout;
|
private final TargetActionsHistoryLayout targetActionsHistoryLayout;
|
||||||
|
private final TabSheet tabSheet;
|
||||||
|
private final Tab actionHistoryTab;
|
||||||
|
|
||||||
private TargetDetailedView(final HawkbitMgmtClient hawkbitClient) {
|
private TargetDetailedView(final HawkbitMgmtClient hawkbitClient) {
|
||||||
final TabSheet tabSheet = new TabSheet();
|
tabSheet = new TabSheet();
|
||||||
tabSheet.setWidthFull();
|
tabSheet.setWidthFull();
|
||||||
targetId = new Span();
|
targetId = new Span();
|
||||||
targetDetails = new TargetDetails(hawkbitClient);
|
targetDetails = new TargetDetails(hawkbitClient);
|
||||||
@@ -369,8 +387,16 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
|||||||
tabSheet.add("Assigned / Installed", targetAssignedInstalled);
|
tabSheet.add("Assigned / Installed", targetAssignedInstalled);
|
||||||
tabSheet.add("Tags", targetTags);
|
tabSheet.add("Tags", targetTags);
|
||||||
tabSheet.add("Metadata", targetMetadata);
|
tabSheet.add("Metadata", targetMetadata);
|
||||||
tabSheet.add("Action History", targetActionsHistoryLayout);
|
actionHistoryTab = tabSheet.add("Action History", targetActionsHistoryLayout);
|
||||||
add(tabSheet);
|
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) {
|
private void setItem(final MgmtTarget target) {
|
||||||
@@ -685,6 +711,16 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
|||||||
targetActionsHistory.setItem(target);
|
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> {
|
public static class ActionStepsGrid extends Grid<ActionStepsGrid.ActionStepEntry> {
|
||||||
|
|
||||||
@Serial
|
@Serial
|
||||||
@@ -715,11 +751,6 @@ public final class TargetView extends TableView<TargetView.TargetWithDs, String>
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
|
||||||
setItems(fetchActionSteps());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setActionId(Long id) {
|
public void setActionId(Long id) {
|
||||||
actionId = id;
|
actionId = id;
|
||||||
setItems(fetchActionSteps());
|
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>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>4.0.6</version>
|
<version>4.1.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>org.eclipse.hawkbit</groupId>
|
<groupId>org.eclipse.hawkbit</groupId>
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<!-- Overrides START - to be reviewed regularly -->
|
<!-- 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) -->
|
<!-- Override vulnerable commons-fileupload used by feign-form-spring (via spring-cloud-starter-openfeign) -->
|
||||||
<commons-fileupload.version>1.6.0</commons-fileupload.version>
|
<commons-fileupload.version>1.6.0</commons-fileupload.version>
|
||||||
<!-- Override commons-lang3 version since 3.17 is vulnerable -->
|
<!-- Override commons-lang3 version since 3.17 is vulnerable -->
|
||||||
<commons-lang.version>3.20.0</commons-lang.version>
|
<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 -->
|
<!-- Overrides END -->
|
||||||
|
|
||||||
<revision>0-SNAPSHOT</revision>
|
<revision>0-SNAPSHOT</revision>
|
||||||
@@ -58,20 +58,15 @@
|
|||||||
<java.client.version>17</java.client.version>
|
<java.client.version>17</java.client.version>
|
||||||
|
|
||||||
<!-- must be the same as the parent version -->
|
<!-- must be the same as the parent version -->
|
||||||
<spring-boot.version>4.0.6</spring-boot.version>
|
<spring-boot.version>4.1.0</spring-boot.version>
|
||||||
<spring-cloud.version>2025.1.1</spring-cloud.version>
|
<spring-cloud.version>2025.1.2</spring-cloud.version>
|
||||||
<spring-ai.version>1.1.7</spring-ai.version>
|
<spring-ai.version>1.1.7</spring-ai.version>
|
||||||
<springdoc-openapi.version>3.0.3</springdoc-openapi.version>
|
<springdoc-openapi.version>3.0.3</springdoc-openapi.version>
|
||||||
|
|
||||||
<!-- openfeign versions -->
|
<!-- 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>
|
<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 -->
|
<!-- Eclipselink - START -->
|
||||||
<!--
|
<!--
|
||||||
Ethlo plugin uses 4.x dependencies - since it only does static weaving - it should be fine - but should eventually upgrade its dependencies.
|
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>
|
<commons-collections4.version>4.5.0</commons-collections4.version>
|
||||||
<io-protostuff.version>1.8.0</io-protostuff.version>
|
<io-protostuff.version>1.8.0</io-protostuff.version>
|
||||||
<!-- the version should be `synchronized` with version used by springdoc-openapi -->
|
<!-- 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 -->
|
<!-- test -->
|
||||||
<rabbitmq-http-client.version>5.5.0</rabbitmq-http-client.version>
|
<rabbitmq-http-client.version>5.5.0</rabbitmq-http-client.version>
|
||||||
|
|||||||
Reference in New Issue
Block a user