diff --git a/.gitignore b/.gitignore index 5881721788..a9f10d29c5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ maven.properties # Test Files *.tmp +spring-shell.log # Documentation .gitmodules diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java index eb91d17895..7ac886b1fb 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTenantManagementMapper.java @@ -46,4 +46,4 @@ public static MgmtSystemTenantConfigurationValue toResponseDefaultDsType(Long de .withSelfRel().expand()); return restConfValue; } -} +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-commons/pom.xml b/hawkbit-sdk/hawkbit-sdk-commons/pom.xml new file mode 100644 index 0000000000..a12fc4e114 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + org.eclipse.hawkbit + hawkbit-sdk + ${revision} + + + hawkbit-sdk-commons + hawkBit :: SDK :: Commons + SDK commons + + + 4.0.4 + 13.0 + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + ${spring-cloud-starter-openfeign.version} + + + io.github.openfeign + feign-hc5 + ${openfeign-hc5.version} + + + + org.springframework.boot + spring-boot-starter-hateoas + + + diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java new file mode 100644 index 0000000000..c52b3e6c70 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Controller.java @@ -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; +} diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java new file mode 100644 index 0000000000..f674b178f4 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitClient.java @@ -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 mgmtService(final Class serviceType, final Tenant tenantProperties) { + return service(serviceType, tenantProperties, null); + } + public T ddiService(final Class serviceType, final Tenant tenantProperties, final Controller controller) { + return service(serviceType, tenantProperties, controller); + } + + private T service(final Class 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()); + } +} diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java new file mode 100644 index 0000000000..c0641e6c35 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitSDKConfigurtion.java @@ -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<>()); + } + } + }; + } +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java new file mode 100644 index 0000000000..3e13d1ec7d --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/HawkbitServer.java @@ -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"; +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java new file mode 100644 index 0000000000..3f8d0966bd --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/java/org/eclipse/hawkbit/sdk/Tenant.java @@ -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; +} diff --git a/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties new file mode 100644 index 0000000000..8b73eecab9 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-commons/src/main/resources/application.properties @@ -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 + diff --git a/hawkbit-sdk/hawkbit-sdk-demo/pom.xml b/hawkbit-sdk/hawkbit-sdk-demo/pom.xml new file mode 100644 index 0000000000..3912a10241 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-demo/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + org.eclipse.hawkbit + hawkbit-sdk + ${revision} + + + hawkbit-sdk-test + hawkBit :: SDK :: Test / Example + Test / Example of how SDK could be used to for devices and for Mgmt API access + + + 3.1.5 + + + + + org.eclipse.hawkbit + hawkbit-sdk-device + ${project.version} + + + org.eclipse.hawkbit + hawkbit-mgmt-api + ${project.version} + + + + org.springframework.shell + spring-shell-starter + ${spring-shell.version} + + + diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java new file mode 100644 index 0000000000..e6e6f29e6e --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/SetupHelper.java @@ -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); + } +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java new file mode 100644 index 0000000000..a8c9b7cd9f --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/device/DeviceApp.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java new file mode 100644 index 0000000000..6cf092e9ce --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/java/org/eclipse/hawkbit/sdk/demo/multidevice/MultiDeviceApp.java @@ -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 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)); + } + } + } +} diff --git a/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties new file mode 100644 index 0000000000..cc3b6310fb --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-demo/src/main/resources/application.properties @@ -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 + diff --git a/hawkbit-sdk/hawkbit-sdk-device/pom.xml b/hawkbit-sdk/hawkbit-sdk-device/pom.xml new file mode 100644 index 0000000000..50c1676e3b --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + org.eclipse.hawkbit + hawkbit-sdk + ${revision} + + + hawkbit-sdk-device + hawkBit :: SDK :: Device SDK + Device SDK that could be used for development of devices on JVM based languages + + + + org.eclipse.hawkbit + hawkbit-sdk-commons + ${project.version} + + + + org.eclipse.hawkbit + hawkbit-ddi-api + ${project.version} + + + \ No newline at end of file diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java new file mode 100644 index 0000000000..57aa793b7e --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/DdiController.java @@ -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 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 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 getControllerBase() { + log.trace(LOG_PREFIX + "Polling ...", getTenantId(), getControllerId()); + final ResponseEntity 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 getRequiredLink(final DdiControllerBase controllerBase, final String nameOfTheLink) { + final Optional 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> getActionWithDeployment(final Link deploymentBaseLink) { + final long actionId = getActionId(deploymentBaseLink); + final ResponseEntity 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 modules; + + private UpdateProcessor( + final long actionId, final DdiDeployment.HandlingType updateType, final List 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 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 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 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 ""; + } + + if (targetToken.isEmpty()) { + return ""; + } + + 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(); + } + } +} diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java new file mode 100644 index 0000000000..c3c3ca1369 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/java/org/eclipse/hawkbit/sdk/device/UpdateStatus.java @@ -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 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)); + } +} diff --git a/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties b/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties new file mode 100644 index 0000000000..2abe714817 --- /dev/null +++ b/hawkbit-sdk/hawkbit-sdk-device/src/main/resources/application.properties @@ -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 +# + + diff --git a/hawkbit-sdk/pom.xml b/hawkbit-sdk/pom.xml new file mode 100644 index 0000000000..1d9328daed --- /dev/null +++ b/hawkbit-sdk/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + org.eclipse.hawkbit + hawkbit-parent + ${revision} + + + hawkbit-sdk + pom + + + hawkbit-sdk-commons + hawkbit-sdk-device + hawkbit-sdk-demo + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index d336420b15..43ed186363 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,7 @@ hawkbit-test-report hawkbit-runtime hawkbit-starters + hawkbit-sdk