Introduce inital draft of hawkBit SDK (#1638)
Intends to provide a Java SDK facilitating: * development of back-end integrations using mgmt api (including UI-s) * development of java based high-end devices (which could run Spring apps) to communicate with hawkBit via DDI API * implementation of demo/test cases using device & management SDK Status: initial draft - Feign client did & management API - done - Hal/HATEAOS Support - works (including in non-web apps) - device communication works when no software updates (e.g. pulling software base) - demo for single and multiple devices simulation (including management API uses) - TODO - fix software update flows - TODO - provide more integration points for developers to interact with device SDK Signed-off-by: Marinov Avgustin <Avgustin.Marinov@bosch.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,6 +61,7 @@ maven.properties
|
||||
|
||||
# Test Files
|
||||
*.tmp
|
||||
spring-shell.log
|
||||
|
||||
# Documentation
|
||||
.gitmodules
|
||||
|
||||
@@ -46,4 +46,4 @@ public final class MgmtTenantManagementMapper {
|
||||
.withSelfRel().expand());
|
||||
return restConfValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
hawkbit-sdk/hawkbit-sdk-commons/pom.xml
Normal file
48
hawkbit-sdk/hawkbit-sdk-commons/pom.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
|
||||
Copyright (c) 2023 Bosch.IO 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
|
||||
|
||||
-->
|
||||
<project
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-sdk</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hawkbit-sdk-commons</artifactId>
|
||||
<name>hawkBit :: SDK :: Commons</name>
|
||||
<description>SDK commons</description>
|
||||
|
||||
<properties>
|
||||
<spring-cloud-starter-openfeign.version>4.0.4</spring-cloud-starter-openfeign.version>
|
||||
<openfeign-hc5.version>13.0</openfeign-hc5.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
<version>${spring-cloud-starter-openfeign.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-hc5</artifactId>
|
||||
<version>${openfeign-hc5.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-hateoas</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class Controller {
|
||||
|
||||
// id of the tenant
|
||||
@NonNull
|
||||
private String controllerId;
|
||||
// (target) security token
|
||||
@Nullable
|
||||
private String securityToken;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2023 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.sdk;
|
||||
|
||||
import feign.Client;
|
||||
import feign.Contract;
|
||||
import feign.Feign;
|
||||
import feign.codec.Decoder;
|
||||
import feign.codec.Encoder;
|
||||
import feign.codec.ErrorDecoder;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
@Builder
|
||||
public class HawkbitClient {
|
||||
|
||||
private static final String AUTHORIZATION = "Authorization";
|
||||
private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default();
|
||||
|
||||
private final HawkbitServer hawkBitServerProperties;
|
||||
|
||||
private final Client client;
|
||||
private final Encoder encoder;
|
||||
private final Decoder decoder;
|
||||
private final Contract contract;
|
||||
|
||||
public HawkbitClient(
|
||||
final HawkbitServer hawkBitServerProperties,
|
||||
final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
|
||||
this.hawkBitServerProperties = hawkBitServerProperties;
|
||||
this.client = client;
|
||||
this.encoder = encoder;
|
||||
this.decoder = decoder;
|
||||
this.contract = contract;
|
||||
}
|
||||
|
||||
public <T> T mgmtService(final Class<T> serviceType, final Tenant tenantProperties) {
|
||||
return service(serviceType, tenantProperties, null);
|
||||
}
|
||||
public <T> T ddiService(final Class<T> serviceType, final Tenant tenantProperties, final Controller controller) {
|
||||
return service(serviceType, tenantProperties, controller);
|
||||
}
|
||||
|
||||
private <T> T service(final Class<T> serviceType, final Tenant tenantProperties, final Controller controller) {
|
||||
return Feign.builder().client(client)
|
||||
.encoder(encoder)
|
||||
.decoder(decoder)
|
||||
.errorDecoder((methodKey, response) -> {
|
||||
final Exception e = DEFAULT_ERROR_DECODER.decode(methodKey, response);
|
||||
log.trace("REST API call failed!", e);
|
||||
return e;
|
||||
})
|
||||
.contract(contract)
|
||||
.requestInterceptor(controller == null ?
|
||||
template -> {
|
||||
template.header(AUTHORIZATION,
|
||||
"Basic " +
|
||||
Base64.getEncoder()
|
||||
.encodeToString(
|
||||
(Objects.requireNonNull(tenantProperties.getUsername(),
|
||||
"User is null!") +
|
||||
":" +
|
||||
Objects.requireNonNull(tenantProperties.getPassword(),
|
||||
"Password is not available!"))
|
||||
.getBytes(StandardCharsets.ISO_8859_1)));
|
||||
} :
|
||||
template -> {
|
||||
if (ObjectUtils.isEmpty(tenantProperties.getGatewayToken())) {
|
||||
if (!ObjectUtils.isEmpty(controller.getSecurityToken())) {
|
||||
template.header(AUTHORIZATION, "TargetToken " + controller.getSecurityToken());
|
||||
} // else do not sent authentication
|
||||
} else {
|
||||
template.header(AUTHORIZATION, "GatewayToken " + tenantProperties.getGatewayToken());
|
||||
}
|
||||
})
|
||||
.target(serviceType,
|
||||
controller == null ?
|
||||
hawkBitServerProperties.getMgmtUrl() :
|
||||
hawkBitServerProperties.getDdiUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk;
|
||||
|
||||
import feign.Contract;
|
||||
import feign.MethodMetadata;
|
||||
import feign.RequestInterceptor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.cloud.openfeign.FeignClientsConfiguration;
|
||||
import org.springframework.cloud.openfeign.hateoas.WebConvertersCustomizer;
|
||||
import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer;
|
||||
import org.springframework.cloud.openfeign.support.SpringMvcContract;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.hateoas.config.EnableHypermediaSupport;
|
||||
import org.springframework.hateoas.config.WebConverters;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({ HawkbitServer.class, Tenant.class})
|
||||
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
|
||||
@Import(FeignClientsConfiguration.class)
|
||||
public class HawkbitSDKConfigurtion {
|
||||
|
||||
/**
|
||||
* An feign request interceptor to set the defined {@code Accept} and {@code Content-Type} headers for each request
|
||||
* to {@code application/json}.
|
||||
*
|
||||
* TODO - is this needed?
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RequestInterceptor jsonHeaderInterceptorOverride() {
|
||||
return template -> template
|
||||
.header("Accept", MediaType.APPLICATION_JSON_VALUE)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE);
|
||||
}
|
||||
|
||||
// takes place only when spring app is started in non-web-app mode
|
||||
// in that case org.springframework.cloud.openfeign.hateoas.FeignHalAutoConfiguration
|
||||
// is explicitly disabled and HAL/HATEOAS support doesn't work
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnNotWebApplication
|
||||
@ConditionalOnClass({ WebConverters.class})
|
||||
public HttpMessageConverterCustomizer webConvertersCustomizerOverrider(WebConverters webConverters) {
|
||||
return new WebConvertersCustomizer(webConverters);
|
||||
}
|
||||
// another option would be something like (need to import io.github.openfeign:feign-jackson
|
||||
// @Bean @Primary @ConditionalOnNotWebApplication
|
||||
// public Decoder feignDecoderOverride() {
|
||||
// return new ResponseEntityDecoder(new JacksonDecoder(new ObjectMapper().registerModule(new Jackson2HalModule())));
|
||||
// }
|
||||
|
||||
/**
|
||||
* Own implementation of the {@link SpringMvcContract} which catches the {@link IllegalStateException} which occurs
|
||||
* due multiple produces and consumes values in the request-mapping
|
||||
* annotation.https://github.com/spring-cloud/spring-cloud-netflix/issues/808
|
||||
*
|
||||
* TODO - is this needed?
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public Contract feignContractOverride() {
|
||||
return new SpringMvcContract() {
|
||||
|
||||
@Override
|
||||
protected void processAnnotationOnMethod(final MethodMetadata data, final Annotation methodAnnotation, final Method method) {
|
||||
try {
|
||||
super.processAnnotationOnMethod(data, methodAnnotation, method);
|
||||
} catch (final IllegalStateException e) {
|
||||
// ignore illegalstateexception here because it's thrown because of
|
||||
// multiple consumers and produces, see
|
||||
// https://github.com/spring-cloud/spring-cloud-netflix/issues/808
|
||||
log.trace(e.getMessage(), e);
|
||||
|
||||
// This line from super is mandatory to avoid that access to the
|
||||
// expander causes a nullpointer.
|
||||
data.indexToExpander(new LinkedHashMap<>());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
@ConfigurationProperties(prefix="hawkbit.server")
|
||||
@Data
|
||||
public class HawkbitServer {
|
||||
|
||||
@NonNull
|
||||
private String mgmtUrl = "http://localhost:8080";
|
||||
@NonNull
|
||||
private String ddiUrl = "http://localhost:8081";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@ConfigurationProperties("hawkbit.tenant")
|
||||
@Data
|
||||
public class Tenant {
|
||||
|
||||
// id of the tenant
|
||||
@NonNull
|
||||
private String tenantId = "DEFAULT";
|
||||
|
||||
// basic auth user, to access management api
|
||||
@Nullable
|
||||
private String username = "admin";
|
||||
@ToString.Exclude
|
||||
@Nullable
|
||||
private String password = "admin";
|
||||
|
||||
// gateway token
|
||||
@Nullable
|
||||
private String gatewayToken;
|
||||
|
||||
private boolean downloadAuthenticationEnabled;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#
|
||||
# Copyright (c) 2023 Bosch.IO 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
|
||||
#
|
||||
|
||||
spring.cloud.openfeign.httpclient.hc5.enabled=true
|
||||
|
||||
48
hawkbit-sdk/hawkbit-sdk-demo/pom.xml
Normal file
48
hawkbit-sdk/hawkbit-sdk-demo/pom.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
|
||||
Copyright (c) 2023 Bosch.IO 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
|
||||
|
||||
-->
|
||||
<project
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-sdk</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hawkbit-sdk-test</artifactId>
|
||||
<name>hawkBit :: SDK :: Test / Example</name>
|
||||
<description>Test / Example of how SDK could be used to for devices and for Mgmt API access</description>
|
||||
|
||||
<properties>
|
||||
<spring-shell.version>3.1.5</spring-shell.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-sdk-device</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-mgmt-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.shell</groupId>
|
||||
<artifactId>spring-shell-starter</artifactId>
|
||||
<version>${spring-shell.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.demo;
|
||||
|
||||
import feign.FeignException;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
|
||||
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody;
|
||||
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi;
|
||||
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitClient;
|
||||
import org.eclipse.hawkbit.sdk.Tenant;
|
||||
import org.eclipse.hawkbit.sdk.device.DdiController;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Abstract class representing DDI device connecting directly to hawkVit.
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Slf4j
|
||||
public class SetupHelper {
|
||||
|
||||
private static final String AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED = "authentication.gatewaytoken.enabled";
|
||||
|
||||
/**
|
||||
* Gateway token value.
|
||||
*/
|
||||
private static final String AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY = "authentication.gatewaytoken.key";
|
||||
private static final String AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED = "authentication.targettoken.enabled";
|
||||
|
||||
// if gateway toke is configured then the gateway auth is enabled key is set
|
||||
// so all devices use gateway token authentication
|
||||
// otherwise target token authentication is enabled. Then all devices shall be registerd
|
||||
// and the target token shall be set to the one from the DDI controller instance
|
||||
public static void setupTargetAuthentication(final HawkbitClient hawkbitClient, final Tenant tenant) {
|
||||
final MgmtTenantManagementRestApi mgmtTenantManagementRestApi =
|
||||
hawkbitClient.mgmtService(MgmtTenantManagementRestApi.class, tenant);
|
||||
if (ObjectUtils.isEmpty(tenant.getGatewayToken())) {
|
||||
if (!((Boolean)mgmtTenantManagementRestApi
|
||||
.getTenantConfigurationValue(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED)
|
||||
.getBody().getValue())) {
|
||||
mgmtTenantManagementRestApi.updateTenantConfiguration(
|
||||
Map.of(AUTHENTICATION_MODE_TARGET_SECURITY_TOKEN_ENABLED, true)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!((Boolean)mgmtTenantManagementRestApi
|
||||
.getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED)
|
||||
.getBody().getValue())) {
|
||||
mgmtTenantManagementRestApi.updateTenantConfiguration(
|
||||
Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_ENABLED, true)
|
||||
);
|
||||
}
|
||||
if (!tenant.getGatewayToken().equals(
|
||||
mgmtTenantManagementRestApi
|
||||
.getTenantConfigurationValue(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY)
|
||||
.getBody().getValue())) {
|
||||
mgmtTenantManagementRestApi.updateTenantConfiguration(
|
||||
Map.of(AUTHENTICATION_MODE_GATEWAY_SECURITY_TOKEN_KEY, tenant.getGatewayToken())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// returns target token
|
||||
public static String setupTargetToken(
|
||||
final String controllerId, String securityTargetToken,
|
||||
final HawkbitClient hawkbitClient, final Tenant tenant) {
|
||||
if (ObjectUtils.isEmpty(tenant.getGatewayToken())) {
|
||||
final MgmtTargetRestApi mgmtTargetRestApi = hawkbitClient.mgmtService(MgmtTargetRestApi.class, tenant);
|
||||
try {
|
||||
// test if target exist, if not - throws 404
|
||||
final MgmtTarget target = mgmtTargetRestApi.getTarget(controllerId).getBody();
|
||||
if (ObjectUtils.isEmpty(securityTargetToken)) {
|
||||
if (ObjectUtils.isEmpty(target.getSecurityToken())) {
|
||||
// generate random to set to tha existing target without configured security token
|
||||
securityTargetToken = randomToken();
|
||||
mgmtTargetRestApi.updateTarget(controllerId,
|
||||
new MgmtTargetRequestBody().setSecurityToken(securityTargetToken));
|
||||
}
|
||||
} else if (!securityTargetToken.equals(target.getSecurityToken())){
|
||||
// update target's with the security token (since it doesn't match)
|
||||
mgmtTargetRestApi.updateTarget(controllerId,
|
||||
new MgmtTargetRequestBody().setSecurityToken(securityTargetToken));
|
||||
}
|
||||
} catch (final FeignException.NotFound e) {
|
||||
if (ObjectUtils.isEmpty(securityTargetToken)) {
|
||||
securityTargetToken = randomToken();
|
||||
}
|
||||
// create target with the security token
|
||||
mgmtTargetRestApi.createTargets(List.of(
|
||||
new MgmtTargetRequestBody()
|
||||
.setControllerId(controllerId)
|
||||
.setSecurityToken(securityTargetToken)));
|
||||
}
|
||||
}
|
||||
|
||||
return securityTargetToken;
|
||||
}
|
||||
|
||||
private static final Random RND = new Random();
|
||||
public static String randomToken() {
|
||||
final byte[] rnd = new byte[24];
|
||||
RND.nextBytes(rnd);
|
||||
return Base64.getEncoder().encodeToString(rnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.demo.device;
|
||||
|
||||
import feign.Client;
|
||||
import feign.Contract;
|
||||
import feign.codec.Decoder;
|
||||
import feign.codec.Encoder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.sdk.Controller;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitServer;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitClient;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion;
|
||||
import org.eclipse.hawkbit.sdk.Tenant;
|
||||
import org.eclipse.hawkbit.sdk.demo.SetupHelper;
|
||||
import org.eclipse.hawkbit.sdk.device.DdiController;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.shell.standard.ShellComponent;
|
||||
import org.springframework.shell.standard.ShellMethod;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
/**
|
||||
* Abstract class representing DDI device connecting directly to hawkVit.
|
||||
*/
|
||||
@Slf4j
|
||||
@SpringBootApplication
|
||||
@Import({ HawkbitSDKConfigurtion.class})
|
||||
public class DeviceApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DeviceApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HawkbitClient hawkbitClient(
|
||||
final HawkbitServer hawkBitServer,
|
||||
final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
|
||||
return new HawkbitClient(hawkBitServer, client, encoder, decoder, contract);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DdiController device(
|
||||
@Value("${hawkbit.device:controller-default}") final String controllerId,
|
||||
@Value("${hawkbit.device.securityToken:}") final String securityToken,
|
||||
final Tenant defaultTenant, final HawkbitClient hawkbitClient) {
|
||||
return new DdiController(
|
||||
defaultTenant,
|
||||
Controller.builder()
|
||||
.controllerId(controllerId)
|
||||
.securityToken(ObjectUtils.isEmpty(securityToken) ?
|
||||
(ObjectUtils.isEmpty(defaultTenant.getGatewayToken()) ? SetupHelper.randomToken() : securityToken) :
|
||||
securityToken)
|
||||
.build(),
|
||||
hawkbitClient).setOverridePollMillis(10_000);
|
||||
}
|
||||
|
||||
@ShellComponent
|
||||
public static class Shell {
|
||||
|
||||
private final Tenant tenant;
|
||||
private final DdiController device;
|
||||
private final HawkbitClient hawkbitClient;
|
||||
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
Shell(final Tenant tenant, final DdiController device, final HawkbitClient hawkbitClient) {
|
||||
this.tenant = tenant;
|
||||
this.device = device;
|
||||
this.hawkbitClient = hawkbitClient;
|
||||
}
|
||||
|
||||
@ShellMethod(key = "setup")
|
||||
public void setup() {
|
||||
SetupHelper.setupTargetAuthentication(hawkbitClient, tenant);
|
||||
SetupHelper.setupTargetToken(
|
||||
device.getControllerId(), device.getTargetSecurityToken(), hawkbitClient, tenant);
|
||||
}
|
||||
|
||||
@ShellMethod(key = "start")
|
||||
public void start() {
|
||||
device.start(scheduler);
|
||||
}
|
||||
|
||||
@ShellMethod(key = "stop")
|
||||
public void stop() {
|
||||
device.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.demo.multidevice;
|
||||
|
||||
import feign.Client;
|
||||
import feign.Contract;
|
||||
import feign.codec.Decoder;
|
||||
import feign.codec.Encoder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.sdk.Controller;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitServer;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitClient;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitSDKConfigurtion;
|
||||
import org.eclipse.hawkbit.sdk.Tenant;
|
||||
import org.eclipse.hawkbit.sdk.demo.SetupHelper;
|
||||
import org.eclipse.hawkbit.sdk.device.DdiController;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.shell.standard.ShellComponent;
|
||||
import org.springframework.shell.standard.ShellMethod;
|
||||
import org.springframework.shell.standard.ShellOption;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
/**
|
||||
* Abstract class representing DDI device connecting directly to hawkVit.
|
||||
*/
|
||||
@Slf4j
|
||||
@SpringBootApplication
|
||||
@Import({ HawkbitSDKConfigurtion.class})
|
||||
public class MultiDeviceApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MultiDeviceApp.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HawkbitClient hawkbitClient(
|
||||
final HawkbitServer hawkBitServer,
|
||||
final Client client, final Encoder encoder, final Decoder decoder, final Contract contract) {
|
||||
return new HawkbitClient(hawkBitServer, client, encoder, decoder, contract);
|
||||
}
|
||||
|
||||
@ShellComponent
|
||||
public static class Shell {
|
||||
|
||||
private final Tenant tenant;
|
||||
private final HawkbitClient hawkbitClient;
|
||||
private final Map<String, DdiController> devices = new ConcurrentHashMap<>();
|
||||
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
private boolean setup;
|
||||
|
||||
Shell(final Tenant tenant, final HawkbitClient hawkbitClient) {
|
||||
this.tenant = tenant;
|
||||
this.hawkbitClient = hawkbitClient;
|
||||
}
|
||||
|
||||
@ShellMethod(key = "setup")
|
||||
public void setup() {
|
||||
SetupHelper.setupTargetAuthentication(hawkbitClient, tenant);
|
||||
setup = true;
|
||||
}
|
||||
|
||||
@ShellMethod(key = "start-one")
|
||||
public void startOne(@ShellOption("--id") final String controllerId) {
|
||||
DdiController device = devices.get(controllerId);
|
||||
final String securityTargetToken;
|
||||
if (setup) {
|
||||
securityTargetToken = SetupHelper.setupTargetToken(
|
||||
controllerId, null, hawkbitClient, tenant);
|
||||
} else {
|
||||
securityTargetToken = null;
|
||||
}
|
||||
if (device == null) {
|
||||
device = new DdiController(tenant,
|
||||
Controller.builder()
|
||||
.controllerId(controllerId)
|
||||
.securityToken(securityTargetToken)
|
||||
.build(),
|
||||
hawkbitClient).setOverridePollMillis(10_000);
|
||||
final DdiController oldDevice = devices.putIfAbsent(controllerId, device);
|
||||
if (oldDevice != null) {
|
||||
device = oldDevice; // reuse existing
|
||||
}
|
||||
}
|
||||
|
||||
device.start(scheduler);
|
||||
}
|
||||
|
||||
@ShellMethod(key = "stop-one")
|
||||
public void stopOne(@ShellOption("--id") final String controllerId) {
|
||||
final DdiController device = devices.get(controllerId);
|
||||
if (device == null) {
|
||||
System.out.println("ERROR: controller with id " + controllerId + " not found!");
|
||||
} else {
|
||||
device.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@ShellMethod(key = "start")
|
||||
public void start(
|
||||
@ShellOption(value = "--prefix", defaultValue = "") final String prefix,
|
||||
@ShellOption(value = "--offset", defaultValue = "0") final int offset,
|
||||
@ShellOption(value = "--count") final int count) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
startOne(String.format(prefix + "%03d", offset + i));
|
||||
}
|
||||
}
|
||||
|
||||
@ShellMethod(key = "stop")
|
||||
public void stop(
|
||||
@ShellOption(value = "--prefix", defaultValue = "") final String prefix,
|
||||
@ShellOption(value = "--offset", defaultValue = "0") final int offset,
|
||||
@ShellOption(value = "--count") final int count) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
stopOne(String.format(prefix + "%03d", offset + i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#
|
||||
# Copyright (c) 2023 Bosch.IO 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
|
||||
#
|
||||
|
||||
spring.main.web-application-type=none
|
||||
spring.cloud.openfeign.httpclient.hc5.enabled=true
|
||||
|
||||
logging.level.org.eclipse.hawkbit=DEBUG
|
||||
|
||||
39
hawkbit-sdk/hawkbit-sdk-device/pom.xml
Normal file
39
hawkbit-sdk/hawkbit-sdk-device/pom.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
|
||||
Copyright (c) 2023 Bosch.IO 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
|
||||
|
||||
-->
|
||||
<project
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-sdk</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hawkbit-sdk-device</artifactId>
|
||||
<name>hawkBit :: SDK :: Device SDK</name>
|
||||
<description>Device SDK that could be used for development of devices on JVM based languages</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-sdk-commons</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-ddi-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.device;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalTime;
|
||||
import java.time.temporal.ChronoField;
|
||||
import java.util.AbstractMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
|
||||
import org.apache.hc.core5.ssl.SSLContextBuilder;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiArtifact;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiChunk;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiConfigData;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiDeployment;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiUpdateMode;
|
||||
import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi;
|
||||
import org.eclipse.hawkbit.sdk.Controller;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitClient;
|
||||
import org.eclipse.hawkbit.sdk.Tenant;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Abstract class representing DDI device connecting directly to hawkVit.
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
public class DdiController {
|
||||
|
||||
private static final String LOG_PREFIX = "[{}:{}] ";
|
||||
|
||||
// TODO - make them configurable
|
||||
private static final long IMMEDIATE_MS = 10;
|
||||
private static final long DEFAULT_POLL_MS = 5_000;
|
||||
|
||||
private static final String DEPLOYMENT_BASE_LINK = "deploymentBase";
|
||||
private static final String CONFIRMATION_BASE_LINK = "confirmationBase";
|
||||
|
||||
private final String tenantId;
|
||||
private final String controllerId;
|
||||
private final DdiRootControllerRestApi ddiApi;
|
||||
|
||||
// configuration
|
||||
private final boolean downloadAuthenticationEnabled;
|
||||
private final String gatewayToken;
|
||||
private final String targetSecurityToken;
|
||||
@Setter
|
||||
@Accessors(chain = true)
|
||||
private long overridePollMillis = -1; // -1 means disabled
|
||||
|
||||
// state
|
||||
private volatile ScheduledExecutorService executorService;
|
||||
private volatile Long currentActionId;
|
||||
private volatile UpdateStatus updateStatus;
|
||||
|
||||
/**
|
||||
* Creates a new device instance.
|
||||
*
|
||||
* @param tenant the tenant of the device belongs to
|
||||
* @param controller the the controller
|
||||
* @param hawkbitClient a factory for creaint to {@link DdiRootControllerRestApi} (and moreused)
|
||||
* for communication to hawkBit
|
||||
*/
|
||||
public DdiController(final Tenant tenant, final Controller controller, final HawkbitClient hawkbitClient) {
|
||||
this.tenantId = tenant.getTenantId();
|
||||
gatewayToken = tenant.getGatewayToken();
|
||||
downloadAuthenticationEnabled = tenant.isDownloadAuthenticationEnabled();
|
||||
this.controllerId = controller.getControllerId();
|
||||
this.targetSecurityToken = controller.getSecurityToken();
|
||||
ddiApi = hawkbitClient.ddiService(DdiRootControllerRestApi.class, tenant, controller);
|
||||
}
|
||||
|
||||
// expects single threaded {@link java.util.concurrent.ScheduledExecutorService}
|
||||
public void start(final ScheduledExecutorService executorService) {
|
||||
Objects.requireNonNull(executorService, "Require non null executor!");
|
||||
|
||||
this.executorService = executorService;
|
||||
executorService.submit(this::poll);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
executorService = null;
|
||||
currentActionId = null;
|
||||
}
|
||||
|
||||
private void poll() {
|
||||
Optional.ofNullable(executorService).ifPresent(executor -> {
|
||||
getControllerBase().ifPresentOrElse(
|
||||
controllerBase -> {
|
||||
final Optional<Link> confirmationBaseLink = getRequiredLink(controllerBase, CONFIRMATION_BASE_LINK);
|
||||
if (confirmationBaseLink.isPresent()) {
|
||||
final long actionId = getActionId(confirmationBaseLink.get());
|
||||
log.info(LOG_PREFIX + "Confirmation is required for action {}!", getTenantId(),
|
||||
getControllerId(), actionId);
|
||||
// TODO - confirmation handler
|
||||
sendConfirmationFeedback(actionId);
|
||||
executor.schedule(this::poll, IMMEDIATE_MS, TimeUnit.MILLISECONDS);
|
||||
} else {
|
||||
getRequiredLink(controllerBase, DEPLOYMENT_BASE_LINK).flatMap(this::getActionWithDeployment).ifPresentOrElse(actionWithDeployment -> {
|
||||
final long actionId = actionWithDeployment.getKey();
|
||||
if (currentActionId == null) {
|
||||
log.info(LOG_PREFIX + "Process action {}", getTenantId(), getControllerId(),
|
||||
actionId);
|
||||
final DdiDeployment deployment = actionWithDeployment.getValue().getDeployment();
|
||||
final DdiDeployment.HandlingType updateType = deployment.getUpdate();
|
||||
final List<DdiChunk> modules = deployment.getChunks();
|
||||
|
||||
currentActionId = actionId;
|
||||
executor.submit(new UpdateProcessor(actionId, updateType, modules));
|
||||
} else if (currentActionId != actionId) {
|
||||
// TODO - cancel and start new one?
|
||||
log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(),
|
||||
getControllerId(), getCurrentActionId());
|
||||
} // else same action - already processing
|
||||
}, () -> {
|
||||
if (currentActionId != null) {
|
||||
// TODO - cancel current?
|
||||
log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(),
|
||||
getControllerId(), getCurrentActionId());
|
||||
}
|
||||
});
|
||||
executor.schedule(this::poll, getPollMillis(controllerBase), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
},
|
||||
() -> {
|
||||
// error has occurred or no controller base hasn't been acquired
|
||||
executor.schedule(this::poll, DEFAULT_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<DdiControllerBase> getControllerBase() {
|
||||
log.trace(LOG_PREFIX + "Polling ...", getTenantId(), getControllerId());
|
||||
final ResponseEntity<DdiControllerBase> poll;
|
||||
try {
|
||||
poll = getDdiApi().getControllerBase(getTenantId(), getControllerId());
|
||||
} catch (final RuntimeException ex) {
|
||||
log.error(LOG_PREFIX + "Failed base poll", getTenantId(), getControllerId(), ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (poll.getStatusCode() != HttpStatus.OK) {
|
||||
log.error(LOG_PREFIX + "Failed base poll {}", getTenantId(), getControllerId(), poll.getStatusCode());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.ofNullable(poll.getBody());
|
||||
}
|
||||
|
||||
private Optional<Link> getRequiredLink(final DdiControllerBase controllerBase, final String nameOfTheLink) {
|
||||
final Optional<Link> link = controllerBase != null ? controllerBase.getLink(nameOfTheLink) : Optional.empty();
|
||||
link.ifPresentOrElse(
|
||||
l -> log.debug(LOG_PREFIX + "Polling finished. Has {} link: {}", getTenantId(), getControllerId(), nameOfTheLink, l),
|
||||
() -> log.trace(LOG_PREFIX + "Polling finished. No {} link", getTenantId(), getControllerId(), nameOfTheLink));
|
||||
return link;
|
||||
}
|
||||
|
||||
private long getPollMillis(final DdiControllerBase controllerBase) {
|
||||
if (overridePollMillis >= 0) {
|
||||
return overridePollMillis;
|
||||
}
|
||||
|
||||
final String pollingTimeFromResponse = controllerBase.getConfig().getPolling().getSleep();
|
||||
if (pollingTimeFromResponse == null) {
|
||||
return DEFAULT_POLL_MS;
|
||||
} else {
|
||||
final LocalTime localtime = LocalTime.parse(pollingTimeFromResponse);
|
||||
return localtime.getLong(ChronoField.MILLI_OF_DAY);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Map.Entry<Long, DdiDeploymentBase>> getActionWithDeployment(final Link deploymentBaseLink) {
|
||||
final long actionId = getActionId(deploymentBaseLink);
|
||||
final ResponseEntity<DdiDeploymentBase> action = getDdiApi()
|
||||
.getControllerDeploymentBaseAction(getTenantId(), getControllerId(), actionId, -1, null);
|
||||
if (action.getStatusCode() != HttpStatus.OK) {
|
||||
log.warn(LOG_PREFIX + "Fail to get deployment action: {} -> {}", getTenantId(), getControllerId(), actionId, action.getStatusCode());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.ofNullable(action.getBody() == null ? null : new AbstractMap.SimpleEntry<>(actionId, action.getBody()));
|
||||
}
|
||||
|
||||
public void updateAttribute(final String mode, final String key, final String value) {
|
||||
final DdiUpdateMode updateMode = switch (mode.toLowerCase()) {
|
||||
case "replace" -> DdiUpdateMode.REPLACE;
|
||||
case "remove" -> DdiUpdateMode.REMOVE;
|
||||
default -> DdiUpdateMode.MERGE;
|
||||
};
|
||||
|
||||
final DdiConfigData configData = new DdiConfigData(Collections.singletonMap(key, value), updateMode);
|
||||
|
||||
getDdiApi().putConfigData(configData, getTenantId(), getControllerId());
|
||||
}
|
||||
|
||||
private void sendFeedback(final long actionId) {
|
||||
getDdiApi().postDeploymentBaseActionFeedback(updateStatus.feedback(), getTenantId(), getControllerId(), actionId);
|
||||
currentActionId = null;
|
||||
}
|
||||
|
||||
private void sendConfirmationFeedback(final long actionId) {
|
||||
final DdiConfirmationFeedback ddiConfirmationFeedback = new DdiConfirmationFeedback(
|
||||
DdiConfirmationFeedback.Confirmation.CONFIRMED, 0, Collections.singletonList(
|
||||
"the confirmation status for the device is" + DdiConfirmationFeedback.Confirmation.CONFIRMED));
|
||||
getDdiApi().postConfirmationActionFeedback(ddiConfirmationFeedback, getTenantId(), getControllerId(), actionId);
|
||||
}
|
||||
|
||||
private long getActionId(final Link link) {
|
||||
final String href = link.getHref();
|
||||
return Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?')));
|
||||
}
|
||||
|
||||
private class UpdateProcessor implements Runnable {
|
||||
|
||||
private static final String BUT_GOT_LOG_MESSAGE = " but got: ";
|
||||
private static final String DOWNLOAD_LOG_MESSAGE = "Download ";
|
||||
private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6;
|
||||
|
||||
private final long actionId;
|
||||
private final DdiDeployment.HandlingType updateType;
|
||||
private final List<DdiChunk> modules;
|
||||
|
||||
private UpdateProcessor(
|
||||
final long actionId, final DdiDeployment.HandlingType updateType, final List<DdiChunk> modules) {
|
||||
this.actionId = actionId;
|
||||
this.updateType = updateType;
|
||||
this.modules = modules;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!"));
|
||||
sendFeedback(actionId);
|
||||
|
||||
if (!CollectionUtils.isEmpty(modules)) {
|
||||
updateStatus = download();
|
||||
sendFeedback(actionId);
|
||||
final UpdateStatus updateStatus = getUpdateStatus();
|
||||
if (updateStatus != null && updateStatus.status() == UpdateStatus.Status.ERROR) {
|
||||
currentActionId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateType != DdiDeployment.HandlingType.SKIP) {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!"));
|
||||
sendFeedback(actionId);
|
||||
currentActionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private UpdateStatus download() {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.DOWNLOADING,
|
||||
modules.stream().flatMap(mod -> mod.getArtifacts().stream())
|
||||
.map(art -> "Download starts for: " + art.getFilename() + " with SHA1 hash "
|
||||
+ art.getHashes().getSha1() + " and size " + art.getSize())
|
||||
.collect(Collectors.toList()));
|
||||
sendFeedback(actionId);
|
||||
|
||||
log.info(LOG_PREFIX + "Start download", getTenantId(), getControllerId());
|
||||
|
||||
final List<UpdateStatus> updateStatusList = new ArrayList<>();
|
||||
modules.forEach(module -> module.getArtifacts().forEach(artifact -> {
|
||||
if (downloadAuthenticationEnabled) {
|
||||
handleArtifact(getTargetSecurityToken(), gatewayToken, updateStatusList, artifact);
|
||||
} else {
|
||||
handleArtifact(null, null, updateStatusList, artifact);
|
||||
}
|
||||
}));
|
||||
|
||||
log.info(LOG_PREFIX + "Download complete", getTenantId(), getControllerId());
|
||||
|
||||
final List<String> messages = new LinkedList<>();
|
||||
messages.add("Download complete!");
|
||||
updateStatusList.forEach(download -> messages.addAll(download.messages()));
|
||||
return new UpdateStatus(
|
||||
updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ?
|
||||
UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED,
|
||||
messages);
|
||||
}
|
||||
|
||||
private void handleArtifact(
|
||||
final String targetToken, final String gatewayToken,
|
||||
final List<UpdateStatus> status, final DdiArtifact artifact) {
|
||||
artifact.getLink("download").ifPresentOrElse(
|
||||
// HTTPS
|
||||
link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken,
|
||||
artifact.getHashes().getSha1(), artifact.getSize()))
|
||||
,
|
||||
// HTTP
|
||||
() -> status.add(downloadUrl(
|
||||
artifact.getLink("download-http")
|
||||
.map(Link::getHref)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")),
|
||||
gatewayToken, targetToken,
|
||||
artifact.getHashes().getSha1(), artifact.getSize()))
|
||||
);
|
||||
}
|
||||
|
||||
private UpdateStatus downloadUrl(
|
||||
final String url, final String gatewayToken, final String targetToken,
|
||||
final String sha1Hash, final long size) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(LOG_PREFIX + "Downloading {} with token {}, expected sha1 hash {} and size {}", getTenantId(), getControllerId(), url,
|
||||
hideTokenDetails(targetToken), sha1Hash, size);
|
||||
}
|
||||
|
||||
try {
|
||||
return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size);
|
||||
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
|
||||
log.error(LOG_PREFIX + "Failed to download {}", getTenantId(), getControllerId(), url, e);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of("Failed to download " + url + ": " + e.getMessage()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken,
|
||||
final String targetToken, final String sha1Hash, final long size)
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException {
|
||||
long overallread;
|
||||
final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts();
|
||||
final HttpGet request = new HttpGet(url);
|
||||
|
||||
if (StringUtils.hasLength(targetToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken);
|
||||
} else if (StringUtils.hasLength(gatewayToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken);
|
||||
}
|
||||
|
||||
final String sha1HashResult;
|
||||
try (final CloseableHttpResponse response = httpclient.execute(request)) {
|
||||
|
||||
if (response.getCode() != HttpStatus.OK.value()) {
|
||||
final String message = wrongStatusCode(url, response);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
if (response.getEntity().getContentLength() != size) {
|
||||
final String message = wrongContentLength(url, size, response);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
// Exception squid:S2070 - not used for hashing sensitive
|
||||
// data
|
||||
@SuppressWarnings("squid:S2070")
|
||||
final MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
|
||||
overallread = getOverallRead(response, md);
|
||||
|
||||
if (overallread != size) {
|
||||
final String message = incompleteRead(url, size, overallread);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest());
|
||||
}
|
||||
|
||||
if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) {
|
||||
final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
final String message = "Downloaded " + url + " (" + overallread + " bytes)";
|
||||
log.debug(message);
|
||||
return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message));
|
||||
}
|
||||
|
||||
private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md)
|
||||
throws IOException {
|
||||
|
||||
long overallread;
|
||||
|
||||
try (final OutputStream os = ByteStreams.nullOutputStream();
|
||||
final BufferedOutputStream bos = new BufferedOutputStream(new DigestOutputStream(os, md))) {
|
||||
|
||||
try (BufferedInputStream bis = new BufferedInputStream(response.getEntity().getContent())) {
|
||||
overallread = ByteStreams.copy(bis, bos);
|
||||
}
|
||||
}
|
||||
|
||||
return overallread;
|
||||
}
|
||||
|
||||
private static String hideTokenDetails(final String targetToken) {
|
||||
if (targetToken == null) {
|
||||
return "<NULL!>";
|
||||
}
|
||||
|
||||
if (targetToken.isEmpty()) {
|
||||
return "<EMTPTY!>";
|
||||
}
|
||||
|
||||
if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
return targetToken.substring(0, 2) + "***"
|
||||
+ targetToken.substring(targetToken.length() - 2, targetToken.length());
|
||||
}
|
||||
|
||||
private String wrongHash(final String url, final String sha1Hash, final long overallread,
|
||||
final String sha1HashResult) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: "
|
||||
+ sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String incompleteRead(final String url, final long size, final long overallread) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size
|
||||
+ BUT_GOT_LOG_MESSAGE + overallread + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String wrongContentLength(final String url, final long size,
|
||||
final CloseableHttpResponse response) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size
|
||||
+ BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String wrongStatusCode(final String url, final CloseableHttpResponse response) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getCode() + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts()
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
return HttpClients
|
||||
.custom()
|
||||
.setConnectionManager(
|
||||
PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setSSLSocketFactory(
|
||||
new SSLConnectionSocketFactory(
|
||||
SSLContextBuilder
|
||||
.create()
|
||||
.loadTrustMaterial(null, (chain, authType) -> true)
|
||||
.build()))
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2023 Bosch.IO 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
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.device;
|
||||
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiResult;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record UpdateStatus(Status status, List<String> messages) {
|
||||
|
||||
/**
|
||||
* The status to response to the hawkBit update server if an simulated update process should be respond with
|
||||
* successful or failure update.
|
||||
*/
|
||||
enum Status {
|
||||
|
||||
/**
|
||||
* Update has been successful and response the successful update.
|
||||
*/
|
||||
SUCCESSFUL(DdiStatus.ExecutionStatus.CLOSED, DdiResult.FinalResult.SUCCESS, 200),
|
||||
|
||||
/**
|
||||
* Update has been not successful and response the error update.
|
||||
*/
|
||||
ERROR(DdiStatus.ExecutionStatus.CLOSED, DdiResult.FinalResult.FAILURE, null),
|
||||
|
||||
/**
|
||||
* Update is running (intermediate status).
|
||||
*/
|
||||
RUNNING(DdiStatus.ExecutionStatus.PROCEEDING, DdiResult.FinalResult.NONE, null),
|
||||
|
||||
/**
|
||||
* Device starts to download.
|
||||
*/
|
||||
DOWNLOADING(DdiStatus.ExecutionStatus.DOWNLOAD, DdiResult.FinalResult.NONE, null),
|
||||
|
||||
/**
|
||||
* Device is finished with downloading.
|
||||
*/
|
||||
DOWNLOADED(DdiStatus.ExecutionStatus.DOWNLOADED, DdiResult.FinalResult.NONE, null);
|
||||
|
||||
private final DdiStatus.ExecutionStatus executionStatus;
|
||||
private final DdiResult.FinalResult finalResult;
|
||||
private final Integer code;
|
||||
|
||||
Status(final DdiStatus.ExecutionStatus executionStatus, final DdiResult.FinalResult finalResult,
|
||||
final Integer code) {
|
||||
this.executionStatus = executionStatus;
|
||||
this.finalResult = finalResult;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
DdiActionFeedback feedback() {
|
||||
return new DdiActionFeedback(null,
|
||||
new DdiStatus(status.executionStatus, new DdiResult(status.finalResult, null), status.code, messages));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#
|
||||
# Copyright (c) 2023 Bosch.IO 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
|
||||
#
|
||||
|
||||
|
||||
30
hawkbit-sdk/pom.xml
Normal file
30
hawkbit-sdk/pom.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
|
||||
Copyright (c) 2023 Bosch.IO 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
|
||||
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.eclipse.hawkbit</groupId>
|
||||
<artifactId>hawkbit-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hawkbit-sdk</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>hawkbit-sdk-commons</module>
|
||||
<module>hawkbit-sdk-device</module>
|
||||
<module>hawkbit-sdk-demo</module>
|
||||
</modules>
|
||||
</project>
|
||||
Reference in New Issue
Block a user