diff --git a/hawkbit-device-simulator/.gitignore b/hawkbit-device-simulator/.gitignore index 09e3bc9..cfb6698 100644 --- a/hawkbit-device-simulator/.gitignore +++ b/hawkbit-device-simulator/.gitignore @@ -1,2 +1,5 @@ /bin/ /target/ +/pom2.xml +/pom3.xml +/pom4.xml diff --git a/hawkbit-device-simulator/README.md b/hawkbit-device-simulator/README.md index a6cae6c..dbe9362 100644 --- a/hawkbit-device-simulator/README.md +++ b/hawkbit-device-simulator/README.md @@ -12,19 +12,6 @@ Or: run org.eclipse.hawkbit.simulator.DeviceSimulator ``` -## Deploy to cloud foundry environment - -- Go to ```target``` subfolder. -- Run ```cf push``` - -## Notes - -The simulator has user authentication enabled in **cloud profile**. Default credentials: -* username : admin -* passwd : admin - -This can be configured/disabled by spring boot properties - ## hawkBit APIs The simulator supports `DDI` as well as the `DMF` integration APIs. diff --git a/hawkbit-device-simulator/pom.xml b/hawkbit-device-simulator/pom.xml index 0e97b58..a139442 100644 --- a/hawkbit-device-simulator/pom.xml +++ b/hawkbit-device-simulator/pom.xml @@ -104,11 +104,15 @@ org.springframework.cloud - spring-cloud-commons + spring-cloud-context org.springframework.cloud - spring-cloud-context + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-jackson com.google.guava diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java new file mode 100644 index 0000000..6ee6204 --- /dev/null +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/AllowAllWebSecurityConfig.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Spring security configuration to grant access for all incomming requests. + */ +@Configuration +public class AllowAllWebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(final HttpSecurity httpSec) throws Exception { + httpSec.csrf().disable().authorizeRequests().antMatchers("/**").permitAll().anyRequest().authenticated(); + } +} diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java index 67bc4da..e6024a4 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -18,7 +18,7 @@ import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder; +import org.springframework.cloud.openfeign.support.ResponseEntityDecoder; import org.springframework.hateoas.hal.Jackson2HalModule; import org.springframework.stereotype.Service; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java index c660fe4..f4eb195 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -8,17 +8,21 @@ */ package org.eclipse.hawkbit.simulator; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.Optional; + import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; +import org.eclipse.hawkbit.simulator.amqp.SimulatedUpdate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.net.MalformedURLException; -import java.net.URL; - /** * REST endpoint for controlling the device simulator. */ @@ -33,8 +37,11 @@ public class SimulationController { private final SimulationProperties simulationProperties; + private Optional spSenderService = Optional.empty(); + @Autowired - public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { + public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, + final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { this.repository = repository; this.deviceFactory = deviceFactory; this.amqpProperties = amqpProperties; @@ -56,8 +63,7 @@ public SimulationController(final DeviceSimulatorRepository repository, final Si * the URL endpoint to be used of the hawkbit-update-server for * DDI devices * @param pollDelay - * number of delay in seconds to delay polling of DDI - * devices + * number of delay in seconds to delay polling of DDI devices * @param gatewayToken * the hawkbit-update-server gatewaytoken in case authentication * is enforced in hawkbit @@ -87,9 +93,7 @@ ResponseEntity start(@RequestParam(value = "name", defaultValue = "simul } if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { - return ResponseEntity.badRequest() - .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" - + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + return createAmqpDisabledResponse(); } for (int i = 0; i < amount; i++) { @@ -102,11 +106,17 @@ ResponseEntity start(@RequestParam(value = "name", defaultValue = "simul return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); } + private ResponseEntity createAmqpDisabledResponse() { + return ResponseEntity.badRequest().body( + "The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + /** * Update an attribute of a device. * * NOTE: This represents not the expected client behaviour for DDI, since a - * DDI client shall only update its attributes if requested by hawkBit. + * DDI client shall only update its attributes if requested by hawkBit. * * @param tenant * The tenant the device belongs to @@ -150,7 +160,7 @@ ResponseEntity update(@RequestParam(value = "tenant", required = false) * if not found. */ @GetMapping("/remove") - ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, @RequestParam(value = "controllerid") final String controllerId) { final AbstractSimulatedDevice controller = repository @@ -176,6 +186,39 @@ ResponseEntity reset() { return ResponseEntity.ok("All simulated devices have been removed."); } + /** + * Report action as FINISHED Sends an UpdateActionStatus event with value + * FINISHED + * + * @return A response string that the action_finished event was sent + */ + @GetMapping("/finishAction") + ResponseEntity finishAction(@RequestParam(value = "actionId") final long actionId, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerId") final String controllerId) { + + if (!spSenderService.isPresent()) { + return createAmqpDisabledResponse(); + } + + final String theTenant = tenant != null && !tenant.isEmpty() ? tenant : simulationProperties.getDefaultTenant(); + final AbstractSimulatedDevice device = repository.get(theTenant, controllerId); + + if (device == null) { + return ResponseEntity.notFound().build(); + } + + spSenderService.get().finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + Collections.singletonList("Simulation Finished.")); + + return ResponseEntity.ok(String.format("Action with id: [%d] reported as FINISHED", actionId)); + } + + @Autowired(required = false) + public void setSpSenderService(final DmfSenderService spSenderService) { + this.spSenderService = Optional.of(spSenderService); + } + private boolean isDmfDisabled() { return !amqpProperties.isEnabled(); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java index 6088aa6..0687b33 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java @@ -44,12 +44,12 @@ public UpdateStatus(final ResponseStatus responseStatus, final String message) { } /** - * Constructor including status message. + * Constructor including status messages. * * @param responseStatus * of the update - * @param messages - * of the update status + * @param statusMessages + * list of status messages */ public UpdateStatus(final ResponseStatus responseStatus, final List statusMessages) { this(responseStatus); diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java index d235af0..3e8bbe6 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java @@ -14,23 +14,19 @@ import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; import org.eclipse.hawkbit.simulator.SimulationProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.FanoutExchange; import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.retry.backoff.ExponentialBackOffPolicy; -import org.springframework.retry.support.RetryTemplate; import com.google.common.collect.Maps; @@ -43,33 +39,6 @@ @ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "enabled") public class AmqpConfiguration { - private static final Logger LOGGER = LoggerFactory.getLogger(AmqpConfiguration.class); - - @Autowired - private AmqpProperties amqpProperties; - - @Bean - RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { - final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); - rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); - - final RetryTemplate retryTemplate = new RetryTemplate(); - retryTemplate.setBackOffPolicy(new ExponentialBackOffPolicy()); - rabbitTemplate.setRetryTemplate(retryTemplate); - - rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { - if (ack) { - LOGGER.debug("Message with correlation ID {} confirmed by broker.", correlationData.getId()); - } else { - LOGGER.error("Broker is unable to handle message with correlation ID {} : {}", correlationData.getId(), - cause); - } - - }); - - return rabbitTemplate; - } - @Bean DmfReceiverService dmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, @@ -83,6 +52,21 @@ DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final Amq return new DmfSenderService(rabbitTemplate, amqpProperties, simulationProperties); } + @Bean + public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { + final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + + // It is necessary to define rabbitTemplate as a Bean and set + // Jackson2JsonMessageConverter explicitly here in order to convert only + // OUTCOMING messages to json. In case of INCOMING messages, + // Jackson2JsonMessageConverter can not handle messages with NULL + // payload (e.g. REQUEST_ATTRIBUTES_UPDATE), so the + // SimpleMessageConverter is used instead per default. + rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); + + return rabbitTemplate; + } + /** * Creates the receiver queue from update server for receiving message from * update server. @@ -90,11 +74,16 @@ DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final Amq * @return the queue */ @Bean - Queue receiverConnectorQueueFromHawkBit() { - final Map arguments = getTTLMaxArgs(); - + Queue receiverConnectorQueueFromHawkBit(final AmqpProperties amqpProperties) { return QueueBuilder.nonDurable(amqpProperties.getReceiverConnectorQueueFromSp()).autoDelete() - .withArguments(arguments).build(); + .withArguments(getTTLMaxArgs()).build(); + } + + private static Map getTTLMaxArgs() { + final Map args = Maps.newHashMapWithExpectedSize(2); + args.put("x-message-ttl", Duration.ofDays(1).toMillis()); + args.put("x-max-length", 100_000); + return args; } /** @@ -103,7 +92,7 @@ Queue receiverConnectorQueueFromHawkBit() { * @return the exchange */ @Bean - FanoutExchange exchangeQueueToConnector() { + FanoutExchange exchangeQueueToConnector(final AmqpProperties amqpProperties) { return new FanoutExchange(amqpProperties.getSenderForSpExchange(), false, true); } @@ -115,15 +104,18 @@ FanoutExchange exchangeQueueToConnector() { * @return the binding and create the queue and exchange */ @Bean - Binding bindReceiverQueueToSpExchange() { - return BindingBuilder.bind(receiverConnectorQueueFromHawkBit()).to(exchangeQueueToConnector()); + Binding bindReceiverQueueToSpExchange(final AmqpProperties amqpProperties) { + return BindingBuilder.bind(receiverConnectorQueueFromHawkBit(amqpProperties)) + .to(exchangeQueueToConnector(amqpProperties)); } - private static Map getTTLMaxArgs() { - final Map args = Maps.newHashMapWithExpectedSize(2); - args.put("x-message-ttl", Duration.ofDays(1).toMillis()); - args.put("x-max-length", 100_000); - return args; - } + @Configuration + @ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "customVhost") + protected static class CachingConnectionFactoryInitializer { + CachingConnectionFactoryInitializer(final CachingConnectionFactory connectionFactory, + final AmqpProperties amqpProperties) { + connectionFactory.setVirtualHost(amqpProperties.getCustomVhost()); + } + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java index 9bafd4f..1a0c246 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java @@ -51,6 +51,8 @@ public class AmqpProperties { */ private int deadLetterTtl = 60_000; + private String customVhost; + public boolean isCheckDmfHealth() { return checkDmfHealth; } @@ -90,4 +92,12 @@ public boolean isEnabled() { public void setEnabled(final boolean enabled) { this.enabled = enabled; } + + public String getCustomVhost() { + return customVhost; + } + + public void setCustomVhost(final String customVhost) { + this.customVhost = customVhost; + } } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java index 17c8193..0fa117d 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -10,6 +10,8 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -22,7 +24,6 @@ import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; -import org.eclipse.jetty.util.ConcurrentHashSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.amqp.AmqpRejectAndDontRequeueException; @@ -47,7 +48,7 @@ public class DmfReceiverService extends MessageService { private final DeviceSimulatorRepository repository; - private final Set openPings = new ConcurrentHashSet<>(); + private final Set openPings = Collections.synchronizedSet(new HashSet<>()); /** * Constructor. @@ -126,8 +127,7 @@ public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYP } if (MessageType.PING_RESPONSE.equals(messageType)) { - final String correlationId = new String(message.getMessageProperties().getCorrelationId(), - StandardCharsets.UTF_8); + final String correlationId = message.getMessageProperties().getCorrelationId(); if (!openPings.remove(correlationId)) { LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); } diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java index 4c45354..eb62973 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.simulator.amqp; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; import java.util.Map; @@ -28,8 +27,8 @@ import org.slf4j.LoggerFactory; import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.connection.CorrelationData; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.amqp.rabbit.support.CorrelationData; import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; /** @@ -63,7 +62,7 @@ public void ping(final String tenant, final String correlationId) { final MessageProperties messageProperties = new MessageProperties(); messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); - messageProperties.setCorrelationId(correlationId.getBytes()); + messageProperties.setCorrelationId(correlationId); messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); @@ -77,9 +76,6 @@ public void ping(final String tenant, final String correlationId) { * the simulated update object * @param updateResultMessages * a description according the update process - * @param actionType - * indicating whether to download and install or skip - * installation due to maintenance window. */ public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, @@ -118,7 +114,7 @@ public void sendMessage(final String address, final Message message) { final String correlationId = UUID.randomUUID().toString(); if (isCorrelationIdEmpty(message)) { - message.getMessageProperties().setCorrelationId(correlationId.getBytes(StandardCharsets.UTF_8)); + message.getMessageProperties().setCorrelationId(correlationId); } if (LOGGER.isTraceEnabled()) { @@ -132,7 +128,7 @@ public void sendMessage(final String address, final Message message) { private static boolean isCorrelationIdEmpty(final Message message) { return message.getMessageProperties().getCorrelationId() == null - || message.getMessageProperties().getCorrelationId().length <= 0; + || message.getMessageProperties().getCorrelationId().length() <= 0; } /** diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java index d47ab59..10ba185 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java @@ -35,8 +35,6 @@ public class MessageService { * the rabbit template * @param amqpProperties * the amqp properties - * @param messageConverter - * the message converter */ public MessageService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties) { this.rabbitTemplate = rabbitTemplate; diff --git a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java index 3c46e89..6aaa700 100644 --- a/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java +++ b/hawkbit-device-simulator/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java @@ -24,7 +24,15 @@ public class SimulatedUpdate implements Serializable { private final Long actionId; private transient LocalDateTime startCacheTime; - SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { + /** + * @param tenant + * the tenant for this thing and for this simulated update + * @param thingId + * the thing id that this simulated update correlates to + * @param actionId + * the id of the action related to this simulated update + */ + public SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { this.tenant = tenant; this.thingId = thingId; this.actionId = actionId; diff --git a/hawkbit-device-simulator/src/main/resources/application.properties b/hawkbit-device-simulator/src/main/resources/application.properties index b7a0588..addd4b0 100644 --- a/hawkbit-device-simulator/src/main/resources/application.properties +++ b/hawkbit-device-simulator/src/main/resources/application.properties @@ -28,7 +28,8 @@ hawkbit.device.simulator.attributes[1].value=1.1 hawkbit.device.simulator.attributes[2].key=serial hawkbit.device.simulator.attributes[2].value=${random.value} -endpoints.health.enabled=true +management.endpoints.enabled-by-default=false +management.endpoint.health.enabled=true ## Configuration for local RabbitMQ integration spring.rabbitmq.username=guest @@ -38,5 +39,4 @@ spring.rabbitmq.host=localhost spring.rabbitmq.port=5672 spring.rabbitmq.dynamic=true -security.basic.enabled=false server.port=8083 diff --git a/hawkbit-gcp-iot-core/.gitignore b/hawkbit-gcp-iot-core/.gitignore new file mode 100644 index 0000000..cfb6698 --- /dev/null +++ b/hawkbit-gcp-iot-core/.gitignore @@ -0,0 +1,5 @@ +/bin/ +/target/ +/pom2.xml +/pom3.xml +/pom4.xml diff --git a/hawkbit-gcp-iot-core/README.md b/hawkbit-gcp-iot-core/README.md new file mode 100644 index 0000000..708a95f --- /dev/null +++ b/hawkbit-gcp-iot-core/README.md @@ -0,0 +1,150 @@ +# hawkBit GCP Device Simulator + + +## Spin a VM + +Use the installation script: [vmInstallDependencies.sh](./vmInstallDependencies.sh) + +or +install the following: +### git: +`sudo apt-get install git` + +### java 8 + +- `sudo apt-get install openjdk-8-jdk` + +- `sudo update-alternatives --config java` + +### docker + +Please read the following if you want to know more about how to install it [here](https://docs.docker.com/install/linux/docker-ce/debian/) + +- `sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common` +- `curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -` +- `sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"` +- `sudo apt-get update` +- `sudo apt-get install docker-ce docker-ce-cli containerd.io` + +### maven + +`sudo apt-get install maven` + +### create a service account +### add the service account to the VM in the configuration + +## First Credentials for GCP +- use the same service account +- Create a json file [link](https://docs.cloudendure.com/Content/Generating_and_Using_Your_Credentials/Working_with_GCP_Credentials/Generating_the_Required_GCP_Credentials/Generating_the_Required_GCP_Credentials.htm) + +- Rename the downloaded file to `keys.json` + +- Add it to `src/main/resources` + +## Device Registry + +For now, this handler supports only one registry + +## GCP Config + +- Set the projectId and the cloud region in the GCP_OTA.java +- Create a `state` subscription on the state topic +- Create a bucket: gsutil mb gs:/firmware-ota/ +- enable the Token Service API: `cloud iot token` + + +# hawkBit Device Simulator + +The device simulator handles software update commands from the update server. It is designed to be used very conveniently, +for example, from within a browser. Hence, all the endpoints use the GET verb. +-Dhawkbit.device.simulator.amqp.enabled=true + +# Open Ports + +- 8080/tcp —> hawkbit +- 8083/tcp —> gcp manager +- 3306/tcp, 33060/tcp —> Mysql +- 4369/tcp, 5671-5672/tcp, 15671-15672/tcp, 25672/tcp —> rabbitMQ + +# docker on debian + +if you had any difficulty installing docker compose follow the following + +1- Open `docker-compose-stack.yml` and remove the hawkBit simulator part, since we want to run the GCP Manager on the same port +``` + + image: "hawkbit/hawkbit-device-simulator:latest" + networks: + - hawknet + ports: + - "8083:8083" + deploy: + restart_policy: + condition: on-failure + environment: + - 'HAWKBIT_DEVICE_SIMULATOR_AUTOSTARTS_[0]_TENANT=DEFAULT' + - 'SPRING_RABBITMQ_VIRTUALHOST=/' + - 'SPRING_RABBITMQ_HOST=rabbitmq' + - 'SPRING_RABBITMQ_PORT=5672' + - 'SPRING_RABBITMQ_USERNAME=guest' + - 'SPRING_RABBITMQ_PASSWORD=guest' +``` + +2 - Run the following to start it +``` +sudo docker swarm init +sudo docker stack deploy -c docker-compose-stack.yml hawkbit +``` + + +## Firebase config +Follow these steps to configurate firebase with the java sdk [steps](https://firebase.google.com/docs/admin/setup) +Generate the file and place it in `src/main/resources` and name it `firebasekeys.json` + +## MySQL Info + MYSQL_DATABASE: "hawkbit" + MYSQL_USER: "root" + port : 3306 + +## Run on your own workstation +``` +mvn spring-boot:run +``` +or use the the [runSpring.sh](./runSpring.sh) + +## Create Software Distribution + +## Tag your Devices + +## Create a Target Filter + +## Configure the Rollout + +- Error threshold 0 +- Trigger threshold 100 + + + +Follow the same config as in the +![image](./images/rolloutConfig.png) + + + +## Notes + +The simulator has user authentication enabled in **cloud profile**. Default credentials: +* username : admin +* passwd : admin + +This can be configured/disabled by spring boot properties + +## hawkBit APIs + +In case there is no AMQP message broker (like rabbitMQ) running, you can disable the AMQP support for the device simulator, so the simulator is not trying to connect to an amqp message broker. + +Configuration property `hawkbit.device.simulator.amqp.enabled=false` + +## Populate GCP devices +``` +http://localhost:8083/gcp +``` \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile b/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile new file mode 100644 index 0000000..08f5a30 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.2.0-SNAPSHOT/Dockerfile @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Kai Zimmermann + +ENV HAWKBIT_SIM_VERSION=0.2.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator + +# Http port +EXPOSE 8083 + +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=0.2.0-SNAPSHOT" + +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] diff --git a/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile new file mode 100644 index 0000000..18cc6b5 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile @@ -0,0 +1,10 @@ +FROM openjdk:8-jre +FROM jetty + +MAINTAINER Charbel Kaed + +EXPOSE 8083 + +ADD target/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar /opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar + +ENTRYPOINT ["java","-jar","/opt/hawkbit-gcp-iot-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original new file mode 100644 index 0000000..5c4ebd3 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/0.3.0-SNAPSHOT/Dockerfile.original @@ -0,0 +1,18 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Kai Zimmermann + +ENV HAWKBIT_SIM_VERSION=0.3.0-SNAPSHOT \ + HAWKBIT_SIM_HOME=/opt/hawkbit-simulator + +# Http port +EXPOSE 8083 + +RUN set -x \ + && mkdir -p $HAWKBIT_SIM_HOME \ + && cd $HAWKBIT_SIM_HOME \ + && apk add --no-cache libressl wget \ + && wget -O hawkbit-simluator.jar --no-verbose "http://repo.eclipse.org/service/local/artifact/maven/redirect?r=hawkbit-snapshots&g=org.eclipse.hawkbit&a=hawkbit-device-simulator&v=${HAWKBIT_SIM_VERSION}" + +WORKDIR $HAWKBIT_SIM_HOME +ENTRYPOINT ["java","-jar","hawkbit-simluator.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/docker/Dockerfile b/hawkbit-gcp-iot-core/docker/Dockerfile new file mode 100644 index 0000000..2fec7d4 --- /dev/null +++ b/hawkbit-gcp-iot-core/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8u171-jre-alpine + +MAINTAINER Charbel Kaed + +# Http port +EXPOSE 8083 + +ENTRYPOINT ["java","-jar","hawkbit-gcp-manager-0.3.0-SNAPSHOT.jar"] \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/images/rolloutConfig.png b/hawkbit-gcp-iot-core/images/rolloutConfig.png new file mode 100644 index 0000000..e694287 Binary files /dev/null and b/hawkbit-gcp-iot-core/images/rolloutConfig.png differ diff --git a/hawkbit-gcp-iot-core/pom.xml b/hawkbit-gcp-iot-core/pom.xml new file mode 100644 index 0000000..fbefed8 --- /dev/null +++ b/hawkbit-gcp-iot-core/pom.xml @@ -0,0 +1,219 @@ + + 4.0.0 + + org.eclipse.hawkbit + 0.3.0-SNAPSHOT + hawkbit-examples-parent + + + hawkbit-gcp-iot-integration + hawkbit-gcp-iot-manager + hawkBit :: GCP :: Manager + Device Management Federation API with GCP + + + + charbull + charbelk@google.com + Google LLC + https://www.google.com + + Lead + Committer + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + ${baseDir} + org.eclipse.hawkbit.handler.GcpModuleIntegrator + JAR + + + + + + + + src/main/resources + + + cf + true + ${project.build.directory} + + keys.jsons + + + + + + + + org.eclipse.hawkbit + hawkbit-dmf-api + + + org.eclipse.hawkbit + hawkbit-example-ddi-feign-client + ${project.version} + + + org.springframework.amqp + spring-rabbit + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jetty + + + org.springframework.boot + spring-boot-starter-logging + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-commons + + + org.springframework.cloud + spring-cloud-context + + + org.apache.httpcomponents + httpclient + + + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.0 + + + org.json + json + 20090211 + + + io.jsonwebtoken + jjwt + 0.7.0 + + + joda-time + joda-time + 2.1 + + + com.google.apis + google-api-services-cloudiot + v1-rev20181120-1.27.0 + + + com.google.oauth-client + google-oauth-client + 1.23.0 + + + com.google.api-client + google-api-client + 1.28.0 + + + com.google.auth + google-auth-library-appengine + 0.12.0 + + + + com.google.apis + google-api-services-iam + v1-rev267-1.25.0 + + + commons-cli + commons-cli + 1.4 + + + com.google.cloud + google-cloud-pubsub + 1.63.0 + + + + com.google.apis + google-api-services-storage + v1-rev149-1.25.0 + + + com.google.cloud + google-cloud-firestore + 0.81.0-beta + + + com.google.firebase + firebase-admin + 6.7.0 + + + + com.google.guava + guava + 23.6-jre + + + + + junit + junit + 4.12 + test + + + com.google.truth + truth + 0.34 + test + + + \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/runSpring.sh b/hawkbit-gcp-iot-core/runSpring.sh new file mode 100755 index 0000000..58540b1 --- /dev/null +++ b/hawkbit-gcp-iot-core/runSpring.sh @@ -0,0 +1,2 @@ +mvn clean install +mvn spring-boot:run diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java new file mode 100644 index 0000000..1a86216 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpBucketHandler.java @@ -0,0 +1,279 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.InputStreamContent; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.iam.v1.IamScopes; +import com.google.api.services.storage.Storage; +import com.google.api.services.storage.model.Bucket; +import com.google.api.services.storage.model.Buckets; +import com.google.api.services.storage.model.Objects; +import com.google.api.services.storage.model.StorageObject; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + + + + +public class GcpBucketHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpBucketHandler.class); + + private static Storage storage = null; + static Gson gson = new Gson(); + + private static HttpTransport httpTransport; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + + + + private static Storage getStorage() { + try { + if(storage == null) + { + GoogleCredential credential = GcpCredentials.getCredential() + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + } + } catch (Exception e) { + e.printStackTrace(); + } + return storage; + } + + public static void uploadFirmwareToBucket(String fileUrl, String artifactName, String targetToken) throws FileNotFoundException, IOException, GeneralSecurityException { + + Storage gcs = getStorage(); + String data = HawkBitSoftwareModuleHandler.downloadFileData(fileUrl, targetToken); + if(!checkIfExists(artifactName)) + { + LOGGER.info("Uploading to GCS artifact: "+artifactName); + uploadSimple(gcs, GcpOTA.BUCKET_NAME, artifactName, data); + } + } + + public static String getFirmwareInfoBucket(String artifactName) + { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + JsonObject jsonObject = new JsonObject(); + LOGGER.info(artifactName+" exists!"); + jsonObject.addProperty("ObjectName", storageObject.getName()); + jsonObject.addProperty("Url", storageObject.getMediaLink()); + jsonObject.addProperty("Md5Hash", storageObject.getMd5Hash()); + + JsonObject jsonConfig = new JsonObject(); + jsonConfig.add("firmware-update", jsonObject); + return gson.toJson(jsonConfig); + } + return null; + } + + + public static Map> getFirmwareInfoBucket_Map(String artifactName) + { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map> fw_update = new HashMap<>(1); + Map mapContent = new HashMap<>(3); + LOGGER.info(artifactName+" exists!"); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); + fw_update.put(GcpOTA.FW_UPDATE, mapContent); + return fw_update; + } + return null; + } + + + public static Map>> getFirmwareInfoBucket_MapList(List modules) + { + Map>> fw_update_Map = + new HashMap>>(1); + + + List fwNameList = modules.stream().flatMap(mod -> mod.getArtifacts().stream()) + .map(art -> art.getFilename()) + .collect(Collectors.toList()); + + List> list_fw_update = new ArrayList<>(fwNameList.size()); + + fwNameList.forEach(artifactName -> { + StorageObject storageObject = GcpBucketHandler.getStorageObjectInfo(artifactName); + if(storageObject != null) + { + Map mapContent = new HashMap<>(3); + LOGGER.info(artifactName+" exists!"); + mapContent.put(GcpOTA.OBJECT_NAME, storageObject.getName()); + mapContent.put(GcpOTA.URL, storageObject.getMediaLink()); + mapContent.put(GcpOTA.MD5HASH, storageObject.getMd5Hash()); + list_fw_update.add(mapContent); + } + }); + fw_update_Map.put(GcpOTA.FW_UPDATE, list_fw_update); + return fw_update_Map; + } + + private static boolean checkIfExists(String artifactName) throws IOException { + + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.info(artifactName+" already exists!"); + return true; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + return false; + } + + public static StorageObject getStorageObjectInfo(String artifactName) { + try { + Storage.Objects.List objectsList = getStorage().objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + if(object.getName().equalsIgnoreCase(artifactName)) + { + LOGGER.info(artifactName+" exists!"); + return object; + } + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + LOGGER.warn(artifactName+" not found"); + } catch (Exception e) { + } + return null; + } + + + private static void listBuckets() throws FileNotFoundException, IOException, GeneralSecurityException { + ClassLoader classLoader = GcpBucketHandler.class.getClassLoader(); + String path = classLoader.getResource("keys.json").getPath(); + GoogleCredential credential = GoogleCredential.fromStream(new FileInputStream(path)) + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + httpTransport = GoogleNetHttpTransport.newTrustedTransport(); + + + Storage storage = new Storage.Builder( + httpTransport, + JSON_FACTORY, + credential + ).build(); + + Storage.Buckets.List bucketsList = storage.buckets().list(GcpOTA.PROJECT_ID); + Buckets buckets; + do { + buckets = bucketsList.execute(); + List items = buckets.getItems(); + if (items != null) { + for (Bucket bucket: items) { + System.out.println("[BucketHandler] BucketName : "+bucket.getName()); + } + } + bucketsList.setPageToken(buckets.getNextPageToken()); + } while (buckets.getNextPageToken() != null); + + Storage.Objects.List objectsList = storage.objects().list(GcpOTA.BUCKET_NAME); + Objects objects; + do { + objects = objectsList.execute(); + List items = objects.getItems(); + if (items != null) { + for (StorageObject object : items) { + System.out.println("[BucketHandler] ObjectName: "+object.getName()); + System.out.println("[BucketHandler] MediaLink: "+object.getMediaLink()); + System.out.println("[BucketHandler] Md5Hash: "+object.getMd5Hash()); + } + } + objectsList.setPageToken(objects.getNextPageToken()); + } while (objects.getNextPageToken() != null); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + String data) throws UnsupportedEncodingException, IOException { + return uploadSimple(storage, bucketName, objectName, new ByteArrayInputStream( + data.getBytes("UTF-8")), "text/plain"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + File data) throws FileNotFoundException, IOException { + return uploadSimple(storage, bucketName, objectName, new FileInputStream(data), + "application/octet-stream"); + } + + private static StorageObject uploadSimple(Storage storage, String bucketName, String objectName, + InputStream data, String contentType) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(contentType, data); + Storage.Objects.Insert insertObject = storage.objects().insert(bucketName, null, mediaContent) + .setName(objectName); + // The media uploader gzips content by default, and alters the Content-Encoding accordingly. + // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior, + // so the service stores exactly what is in the InputStream, without transformation. + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } + + private static StorageObject uploadWithMetadata(Storage storage, StorageObject object, + InputStream data) throws IOException { + InputStreamContent mediaContent = new InputStreamContent(object.getContentType(), data); + Storage.Objects.Insert insertObject = storage.objects().insert(object.getBucket(), object, + mediaContent); + insertObject.getMediaHttpUploader().setDisableGZipContent(true); + return insertObject.execute(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java new file mode 100644 index 0000000..47eb1aa --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpCredentials.java @@ -0,0 +1,92 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; + +public class GcpCredentials { + + + private static Path keysFile = null; + private static GoogleCredentials googleCredentials; + private static GoogleCredential googleCredential; + private static CredentialsProvider credentialsProvider; + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpCredentials.class); + + protected static GoogleCredential getCredential() { + if(googleCredential == null) { + try { + googleCredential = GoogleCredential.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + System.out.println("Please make sure to put your keys.json in the project"); + LOGGER.error("Please make sure to put your keys.json in the project"); + } + } + return googleCredential; + } + + + protected static CredentialsProvider getCredentialProvider() { + if(credentialsProvider == null) { + try { + credentialsProvider = FixedCredentialsProvider.create( + ServiceAccountCredentials.fromStream(Files.newInputStream(keysFile))); + } catch (IOException e) { + e.printStackTrace(); + } + } + return credentialsProvider; + } + + protected static GoogleCredentials getCredentials() { + if(googleCredentials == null) { + try { + googleCredentials = GoogleCredentials.fromStream(Files.newInputStream(keysFile)); + } catch (IOException e) { + e.printStackTrace(); + } + } + return googleCredentials; + } + + public static void setKeysFilePath(String keysPath) { + LOGGER.info("==========> Setting keys path to "+keysPath); + keysFile = Paths.get(keysPath); + LOGGER.info("==========> Setting keys path to "+keysFile.toString()); + int n; + try (InputStream in = Files.newInputStream(keysFile)) { + while ((n = in.read()) != -1) { + System.out.print((char) n); + } + } catch (IOException x) { + System.err.format("IOException: %s%n", x); + } + LOGGER.info("============================================================ "); + + getCredentials(); + getCredential(); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java new file mode 100644 index 0000000..448e81d --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpFireStore.java @@ -0,0 +1,85 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.core.ApiFuture; +import com.google.api.services.iam.v1.IamScopes; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.SetOptions; +import com.google.cloud.firestore.WriteResult; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.cloud.FirestoreClient; + +public class GcpFireStore { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpFireStore.class); + + private static Firestore db; + + public static void init() { + + GoogleCredentials credentials = GcpCredentials.getCredentials() + .createScoped(Collections.singleton(IamScopes.CLOUD_PLATFORM)); + + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(credentials) + .setProjectId(GcpOTA.PROJECT_ID) + .build(); + FirebaseApp.initializeApp(options); + + db = FirestoreClient.getFirestore(); + } + + + public static void addDocumentMapList(String deviceId, Map>> mapList) { + try { + DocumentReference docRef = db + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(mapList, SetOptions.merge()); + LOGGER.info("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + public static void addDocument(String deviceId, Map> map) { + try { + DocumentReference docRef = db + .collection(GcpOTA.FIRESTORE_DEVICES_COLLECTION) + .document(deviceId) + .collection(GcpOTA.FIRESTORE_CONFIG_COLLECTION) + .document(deviceId); + ApiFuture result = docRef.set(map); + LOGGER.info("Update time : " + result.get().getUpdateTime()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java new file mode 100644 index 0000000..9493995 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpIoTHandler.java @@ -0,0 +1,435 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.cloudiot.v1.CloudIot; +import com.google.api.services.cloudiot.v1.CloudIotScopes; +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.api.services.cloudiot.v1.model.DeviceConfig; +import com.google.api.services.cloudiot.v1.model.DeviceRegistry; +import com.google.api.services.cloudiot.v1.model.DeviceState; +import com.google.api.services.cloudiot.v1.model.ListDeviceStatesResponse; +import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceRequest; +import com.google.api.services.cloudiot.v1.model.SendCommandToDeviceResponse; + + +public class GcpIoTHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpIoTHandler.class); + + public static GoogleCredential getCredentialsFromFile() + { + GoogleCredential credential = null; + credential = GcpCredentials.getCredential() + .createScoped(CloudIotScopes.all()); + return credential; + } + + +// public static List getAllDevices(String projectId, String cloudRegion, String registryId) throws GeneralSecurityException, IOException +// { +// List allDevices_per_project = new ArrayList(); +// List gcp_registries = GcpIoTHandler.listRegistries(GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION); +// for(DeviceRegistry gcp_registry : gcp_registries) +// { +// allDevices_per_project.addAll(listDevices(projectId, cloudRegion, gcp_registry.getId())); +// } +// return allDevices_per_project; +// } + + public static List listDevices(String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String registryPath = + String.format( + "projects/%s/locations/%s/registries/%s", projectId, cloudRegion, registryName); + + List devices = + service + .projects() + .locations() + .registries() + .devices() + .list(registryPath) + .execute() + .getDevices(); + + if (devices != null) { + System.out.println("Found " + devices.size() + " devices"); + for (Device d : devices) { + System.out.println("Id: " + d.getId()); + if (d.getConfig() != null) { + // Note that this will show the device config in Base64 encoded format. + System.out.println("Config: " + d.getConfig().toPrettyString()); + } + System.out.println(); + } + } else { + LOGGER.warn("Registry has no devices."); + System.out.println("Registry has no devices."); + } + return devices; + } + + public static boolean atLeastOnceConnected(String deviceId) { + try { + return atLeastOnceConnected(deviceId, + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + return false; + } + + + private static boolean atLeastOnceConnected(String deviceId, String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + String lastTimeEvent = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastEventTime(); + + String lastHRbeat = + service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute() + .getLastHeartbeatTime(); + System.out.println(lastHRbeat+" : last hear beat, lastTimeEvent "+lastTimeEvent); + return (lastTimeEvent !=null || lastHRbeat!=null); + } + + + + /** + * Retrieves Device Metadata + * @return Map of metadata + * */ + public static Map getDeviceMetadata(String deviceId) { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service; + try { + service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String deviceUniqueId = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, + deviceId); + + return service + .projects() + .locations() + .registries() + .devices() + .get(deviceUniqueId) + .execute().getMetadata(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } catch(GoogleJsonResponseException e) { + e.printStackTrace(); + LOGGER.error("Couldn't find the device: "+deviceId+" in the registry"); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + + + + + + /** Lists all of the registries associated with the given project. */ + public static List listRegistries(String projectId, String cloudRegion) + throws GeneralSecurityException, IOException { + + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String projectPath = "projects/" + projectId + "/locations/" + cloudRegion; + + List registries = + service + .projects() + .locations() + .registries() + .list(projectPath) + .execute() + .getDeviceRegistries(); + + if (registries != null) { + LOGGER.info("Found " + registries.size() + " registries"); + for (DeviceRegistry r: registries) { + LOGGER.info("Id: " + r.getId()); + LOGGER.info("Name: " + r.getName()); + if (r.getMqttConfig() != null) { + LOGGER.info("Config: " + r.getMqttConfig().toPrettyString()); + } + System.out.println(); + } + } else { + LOGGER.warn("Project has no registries."); + } + return registries; + + } + + /** List all of the configs for the given device. */ + public static void listDeviceConfigs( + String deviceId, String projectId, String cloudRegion, String registryName) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + for (DeviceConfig config : deviceConfigs) { + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + + /** List all of the configs for the given device. */ + public static long getLatestConfig( + String deviceId, String projectId, String cloudRegion, String registryName) + { + long configVersion = 0; + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Listing device configs for " + devicePath); + List deviceConfigs = + service + .projects() + .locations() + .registries() + .devices() + .configVersions() + .list(devicePath) + .execute() + .getDeviceConfigs(); + + + for (DeviceConfig config : deviceConfigs) { + LOGGER.info("\nConfig version: " + config.getVersion()); + LOGGER.info("Contents: " + config.getBinaryData()); + if(configVersion < config.getVersion()) + { + configVersion = config.getVersion(); + } + } + + } catch (Exception e) { + e.printStackTrace(); + } + + return configVersion; + } + + + /** Set a device configuration to the specified data (string, JSON) and version (0 for latest). */ + public static void setDeviceConfiguration( + String deviceId, String projectId, String cloudRegion, String registryName, + String data, long version) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest(); + req.setVersionToUpdate(version); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + + DeviceConfig config = + service + .projects() + .locations() + .registries() + .devices() + .modifyCloudToDeviceConfig(devicePath, req).execute(); + + LOGGER.info("Updated: " + config.getVersion()); + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + } + + + } + + /** Retrieves device metadata from a registry. **/ + public static List getDeviceStates( + String deviceId, String projectId, String cloudRegion, String registryName) + { + + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + LOGGER.info("Retrieving device states " + devicePath); + + ListDeviceStatesResponse resp = service + .projects() + .locations() + .registries() + .devices() + .states() + .list(devicePath).execute(); + + return resp.getDeviceStates(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public static void sendCommand( + String deviceId, String projectId, String cloudRegion, String registryName, String data) + { + try { + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(getCredentialsFromFile()); + final CloudIot service = + new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init) + .build(); + + final String devicePath = + String.format( + "projects/%s/locations/%s/registries/%s/devices/%s", + projectId, cloudRegion, registryName, deviceId); + + SendCommandToDeviceRequest req = new SendCommandToDeviceRequest(); + + // Data sent through the wire has to be base64 encoded. + Base64.Encoder encoder = Base64.getEncoder(); + String encPayload = encoder.encodeToString(data.getBytes("UTF-8")); + req.setBinaryData(encPayload); + LOGGER.info("Sending command to %s\n", devicePath); + SendCommandToDeviceResponse res = + service + .projects() + .locations() + .registries() + .devices() + .sendCommandToDevice(devicePath, req) + .execute(); + + LOGGER.info("Command response: " + res.toString()); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** Retrieves registry metadata from a project. **/ + public static DeviceRegistry getRegistry( + String projectId, String cloudRegion, String registryName) + throws GeneralSecurityException, IOException { + GoogleCredential credential = + GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all()); + JsonFactory jsonFactory = JacksonFactory.getDefaultInstance(); + HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential); + final CloudIot service = new CloudIot.Builder( + GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init).build(); + + final String registryPath = String.format("projects/%s/locations/%s/registries/%s", + projectId, cloudRegion, registryName); + + return service.projects().locations().registries().get(registryPath).execute(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java new file mode 100644 index 0000000..076daf6 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpOTA.java @@ -0,0 +1,40 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +public class GcpOTA { + + public static String PROJECT_ID = ""; + public static String CLOUD_REGION = ""; + public static String REGISTRY_NAME = ""; + public static String BUCKET_NAME = ""; + + + public final static String FW_MSG_RECEIVED = "msg-received"; + public final static String FW_INSTALLING = "installing"; + public final static String FW_DOWNLOADING = "downloading"; + public final static String FW_INSTALLED = "installed"; + + public final static String SUBSCRIPTION_STATE_ID = "state"; + public final static String SUBSCRIPTION_FW_STATE = "fw-state"; + public final static String DEVICE_ID = "deviceId"; + + + public final static String FIRESTORE_DEVICES_COLLECTION = "devices"; + public final static String FIRESTORE_CONFIG_COLLECTION = "config"; + public final static String FW_UPDATE = "firmware-update"; + + public final static String OBJECT_NAME = "ObjectName"; + public final static String URL = "Url"; + public final static String MD5HASH = "Md5Hash"; + + public final static boolean FW_VIA_COMMAND = false; +} + diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java new file mode 100644 index 0000000..d519ea1 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpProperties.java @@ -0,0 +1,71 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; + +public class GcpProperties { + + public static void parseCLI(String[] args) { + // create Options object + Options options = new Options(); + Option keys = Option.builder().argName("KEYS").hasArg().required() + .longOpt("KEYS").desc("Keys file path from a GCP project Service Account").build(); + Option gcpProjectID = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("PROJECT_ID").desc("GCP PROJECT_ID").build(); + Option gcpCloudRegion = Option.builder().argName("PROJECT_ID").hasArg().required() + .longOpt("CLOUD_REGION").desc("GCP CLOUD_REGION").build(); + Option gcpRegistryName = Option.builder().argName("REGISTRY_NAME").hasArg().required() + .longOpt("REGISTRY_NAME").desc("GCP REGISTRY_NAME").build(); + Option gcpBucketName = Option.builder().argName("BUCKET_NAME").hasArg().required() + .longOpt("BUCKET_NAME").desc("GCP BUCKET_NAME").build(); + + options.addOption(keys); + options.addOption(gcpProjectID); + options.addOption(gcpCloudRegion); + options.addOption(gcpRegistryName); + options.addOption(gcpBucketName); + + DefaultParser defaultParser = new DefaultParser(); + HelpFormatter formatter = new HelpFormatter(); + + CommandLine line; + try { + line = defaultParser.parse(options, args); + + if (line.hasOption("PROJECT_ID")) { + GcpOTA.PROJECT_ID = line.getOptionValue("PROJECT_ID"); + } + if (line.hasOption("CLOUD_REGION")) { + GcpOTA.CLOUD_REGION = line.getOptionValue("CLOUD_REGION"); + } + if (line.hasOption("REGISTRY_NAME")) { + GcpOTA.REGISTRY_NAME = line.getOptionValue("REGISTRY_NAME"); + } + if (line.hasOption("BUCKET_NAME")) { + GcpOTA.BUCKET_NAME = line.getOptionValue("BUCKET_NAME"); + } + if (line.hasOption("KEYS")) { + GcpCredentials.setKeysFilePath(line.getOptionValue("KEYS")); + } + + } catch (IllegalArgumentException | ParseException e) { + System.out.println(e.getMessage()); + formatter.printHelp("help", options); + System.exit(0); + } + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java new file mode 100644 index 0000000..ed63747 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/GcpSubscriber.java @@ -0,0 +1,240 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.UpdateStatus; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsub.v1.Subscriber; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; + +public class GcpSubscriber { + + private static final BlockingQueue messages = new LinkedBlockingDeque<>(); + private static Map mapCallbacks = new HashMap(); + + private static Map mapDevices = new HashMap(); + + private static Gson gson = new Gson(); + + private static final Logger LOGGER = LoggerFactory.getLogger(GcpSubscriber.class); + + static class StateMessageReceiver implements MessageReceiver { + + @Override + public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { + messages.offer(message); + consumer.ack(); + } + } + + /** Receive messages over a subscription. */ + public static void init() { + ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of( + GcpOTA.PROJECT_ID, GcpOTA.SUBSCRIPTION_STATE_ID); + Subscriber subscriber = null; + try { + + // create a subscriber bound to the asynchronous message receiver + subscriber = + Subscriber.newBuilder(subscriptionName, new StateMessageReceiver()).setCredentialsProvider(GcpCredentials.getCredentialProvider()).build(); + subscriber.startAsync().awaitRunning(); + // Continue to listen to messages + while (true) { + PubsubMessage message = messages.take(); + if(!GcpOTA.FW_VIA_COMMAND) { + updateHawkbitStatus(message); + } + } + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + if (subscriber != null) { + subscriber.stopAsync(); + } + } + } + + + + public static void updateHawkbitStatus(PubsubMessage message){ + if(message.getData().toStringUtf8().contains(GcpOTA.SUBSCRIPTION_FW_STATE)) { + JsonObject payloadJson = gson.fromJson(message.getData() + .toStringUtf8(), JsonObject.class); + if(payloadJson.has(GcpOTA.SUBSCRIPTION_FW_STATE)) { + String deviceId = message.getAttributesMap().get(GcpOTA.DEVICE_ID); + String fw_state = payloadJson.get(GcpOTA.SUBSCRIPTION_FW_STATE).getAsString(); + + if(deviceId != null && fw_state != null) { + UpdateStatus updateStatus = null; + LOGGER.info("====> New state received "+fw_state+ " from device "+deviceId); + switch (fw_state) { + case GcpOTA.FW_MSG_RECEIVED : + updateStatus = new UpdateStatus(ResponseStatus.RUNNING, "Message sent to initiate fw update!"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_DOWNLOADING: + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADING, "Payload downloading"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_INSTALLING : + updateStatus = new UpdateStatus(ResponseStatus.DOWNLOADED, "Payload installing"); + sendUpate(deviceId, updateStatus); + break; + case GcpOTA.FW_INSTALLED: + updateStatus = new UpdateStatus(ResponseStatus.SUCCESSFUL, "Payload installed"); + sendUpate(deviceId, updateStatus); + + //remove device and callback + mapCallbacks.remove(deviceId); + mapDevices.remove(deviceId); + + break; + default: + LOGGER.error("Unknown fw-state: "+fw_state); + updateStatus = new UpdateStatus(ResponseStatus.ERROR, "Unknown State"); + sendUpate(deviceId, updateStatus); + break; + } + + } else { + LOGGER.error("state: %s, deviceId %s", fw_state, deviceId); + + //Device never connected + if(!GcpIoTHandler.atLeastOnceConnected(deviceId)) { + LOGGER.error(deviceId+" : device was never connected"); + sendUpate(deviceId, new UpdateStatus(ResponseStatus.ERROR, "Device was never connected")); + } + } + } else { + LOGGER.info("Ignoring message"); + } + } else { + LOGGER.info("Ignoring message"); + } + + } + + + private static String getStringFromListMap(Map>> listMap) { + JsonObject fw_update = new JsonObject(); + JsonArray fw_update_list = new JsonArray(); + + listMap.get(GcpOTA.FW_UPDATE).forEach(map -> { + JsonObject mapJsonObject = new JsonObject(); + mapJsonObject.addProperty(GcpOTA.OBJECT_NAME, map.get(GcpOTA.OBJECT_NAME)); + mapJsonObject.addProperty(GcpOTA.URL, map.get(GcpOTA.URL)); + mapJsonObject.addProperty(GcpOTA.MD5HASH, map.get(GcpOTA.MD5HASH)); + fw_update_list.add(mapJsonObject); + }); + fw_update.add(GcpOTA.FW_UPDATE,fw_update_list); + return gson.toJson(fw_update); + } + + + + private static void sendAsyncFwUpgradeList(String deviceId, List softwareModuleList) { + Map>> data = + GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList); + if(data != null) { + long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, getStringFromListMap(data), configVersion); + + LOGGER.info("Writing to Firestore "); + GcpFireStore.addDocumentMapList(deviceId + , GcpBucketHandler.getFirmwareInfoBucket_MapList(softwareModuleList)); + } + else LOGGER.error("Artifacts is empty for device "+deviceId); + } + + + + + @SuppressWarnings("unused") + private static void sendAsyncFwUpgrade(String deviceId, String artifactName) { + String data = GcpBucketHandler.getFirmwareInfoBucket(artifactName); + if(data != null) { + long configVersion = GcpIoTHandler.getLatestConfig(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME); + LOGGER.info("Sending Configuration Message to %s with data:\n%s", deviceId, data); + GcpIoTHandler.setDeviceConfiguration(deviceId, GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, data, configVersion); + + LOGGER.info("Writing to Firestore "); + GcpFireStore.addDocument(deviceId, GcpBucketHandler.getFirmwareInfoBucket_Map(artifactName)); + } + else LOGGER.error(artifactName+" not found in bucket for device "+deviceId); + } + + + private static void sendUpate(String deviceId, UpdateStatus updateStatus) { + AbstractSimulatedDevice device = mapDevices.get(deviceId); + UpdaterCallback callback = mapCallbacks.get(deviceId); + if(device != null && callback != null) { + device.setUpdateStatus(updateStatus); + callback.sendFeedback(device); + } else { + if(device == null) { + LOGGER.error("Map didnt find device on "+ updateStatus.getResponseStatus().toString()); + } + if(callback == null) { + LOGGER.error("Map didnt find callback on "+ updateStatus.getResponseStatus().toString()); + } + } + } + + public static void updateDevice(AbstractSimulatedDevice device, UpdaterCallback callback, + List modules, EventTopic actionType) { + + LOGGER.info("Update device with eventTopic: "+actionType); + + //if the device is still updating, wait until it is finished + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL + || actionType == EventTopic.DOWNLOAD) { + if(!mapDevices.containsKey(device.getId())) { + LOGGER.info("ActionType "+actionType); + + sendAsyncFwUpgradeList(device.getId(), modules); + + mapCallbacks.put(device.getId(), callback); + mapDevices.put(device.getId(), device); + } else { + LOGGER.error("Device ID already exist on actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.RUNNING, "Payload Reached")); + } + } + else { + LOGGER.error("Unsupported actionType: "+actionType); + sendUpate(device.getId(), new UpdateStatus(ResponseStatus.ERROR, "Unsupported Action")); + + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java new file mode 100644 index 0000000..d34462b --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/HawkBitSoftwareModuleHandler.java @@ -0,0 +1,78 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +public class HawkBitSoftwareModuleHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(HawkBitSoftwareModuleHandler.class); + + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + + + + protected static String downloadFileData(final String url,final String targetToken) + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException { + + final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts(); + final HttpGet request = new HttpGet(url); + + if (!StringUtils.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } + + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + String message = "download "+url+" failed (" + response.getStatusLine().getStatusCode()+ ")"; + LOGGER.error(message); + return null; + } + String payload = null; + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + System.out.println("Payload ==========> "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + } + + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java new file mode 100644 index 0000000..391441c --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/google/gcp/RetryHttpInitializerWrapper.java @@ -0,0 +1,97 @@ +/** + * Copyright 2019 Google LLC + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.google.gcp; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.util.ExponentialBackOff; +import com.google.api.client.util.Sleeper; +import com.google.common.base.Preconditions; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * RetryHttpInitializerWrapper will automatically retry upon RPC failures, preserving the + * auto-refresh behavior of the Google Credentials. + */ +public class RetryHttpInitializerWrapper implements HttpRequestInitializer { + + /** A private logger. */ + private static final Logger LOG = Logger.getLogger(RetryHttpInitializerWrapper.class.getName()); + + /** One minutes in milliseconds. */ + private static final int ONE_MINUTE_MILLIS = 60 * 1000; + + /** + * Intercepts the request for filling in the "Authorization" header field, as well as recovering + * from certain unsuccessful error codes wherein the Credential must refresh its token for a + * retry. + */ + private final Credential wrappedCredential; + + /** A sleeper; you can replace it with a mock in your test. */ + private final Sleeper sleeper; + + /** + * A constructor. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + */ + public RetryHttpInitializerWrapper(final Credential wrappedCredential) { + this(wrappedCredential, Sleeper.DEFAULT); + } + + /** + * A protected constructor only for testing. + * + * @param wrappedCredential Credential which will be wrapped and used for providing auth header. + * @param sleeper Sleeper for easy testing. + */ + RetryHttpInitializerWrapper(final Credential wrappedCredential, final Sleeper sleeper) { + this.wrappedCredential = Preconditions.checkNotNull(wrappedCredential); + this.sleeper = sleeper; + } + + /** Initializes the given request. */ + @Override + public final void initialize(final HttpRequest request) { + request.setReadTimeout(2 * ONE_MINUTE_MILLIS); // 2 minutes read timeout + final HttpUnsuccessfulResponseHandler backoffHandler = + new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff()).setSleeper(sleeper); + request.setInterceptor(wrappedCredential); + request.setUnsuccessfulResponseHandler( + new HttpUnsuccessfulResponseHandler() { + @Override + public boolean handleResponse( + final HttpRequest request, final HttpResponse response, final boolean supportsRetry) + throws IOException { + if (wrappedCredential.handleResponse(request, response, supportsRetry)) { + // If credential decides it can handle it, the return code or message indicated + // something specific to authentication, and no backoff is desired. + return true; + } else if (backoffHandler.handleResponse(request, response, supportsRetry)) { + // Otherwise, we defer to the judgment of our internal backoff handler. + LOG.info("Retrying " + request.getUrl().toString()); + return true; + } else { + return false; + } + } + }); + request.setIOExceptionHandler( + new HttpBackOffIOExceptionHandler(new ExponentialBackOff()).setSleeper(sleeper)); + } +} \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java new file mode 100644 index 0000000..9ce11e9 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/AbstractSimulatedDevice.java @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +/** + * The bean of a simulated device which can be stored in the + * {@link DeviceSimulatorRepository} or shown in the UI. + * + */ +public abstract class AbstractSimulatedDevice { + + private String id; + private String tenant; + private UpdateStatus updateStatus; + private Protocol protocol = Protocol.DMF_AMQP; + private String targetSecurityToken; + private int pollDelaySec; + private int nextPollCounterSec; + + /** + * Enum definition of the protocol to be used for the simulated device. + * + */ + public enum Protocol { + /** + * Device Management Federation API via AMQP, push mechanism. + */ + DMF_AMQP, + /** + * Direct Device Interface via HTTP, poll mechanism. + */ + DDI_HTTP + } + + /** + * empty constructor. + */ + AbstractSimulatedDevice() { + + } + + /** + * Creates a new simulated device. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param pollDelaySec + */ + AbstractSimulatedDevice(final String id, final String tenant, final Protocol protocol, final int pollDelaySec) { + this.id = id; + this.tenant = tenant; + this.protocol = protocol; + this.pollDelaySec = pollDelaySec; + } + + /** + * Can be called by a scheduler to trigger a device polling, like in real + * scenarios devices are frequently asking for updates etc. + */ + public abstract void poll(); + + public int getPollDelaySec() { + return pollDelaySec; + } + + public void setPollDelaySec(final int pollDelaySec) { + this.pollDelaySec = pollDelaySec; + } + + public abstract void updateAttribute(final String mode, final String key, final String value); + + /** + * Method to clean-up resource e.g. when the simulated device has been + * removed from the repository. + */ + public void clean() { + this.updateStatus = null; + } + + public String getId() { + return id; + } + + public String getTenant() { + return tenant; + } + + public void setId(final String id) { + this.id = id; + } + + public void setTenant(final String tenant) { + this.tenant = tenant; + } + + public UpdateStatus getUpdateStatus() { + return updateStatus; + } + + public void setUpdateStatus(final UpdateStatus updateStatus) { + this.updateStatus = updateStatus; + } + + public Protocol getProtocol() { + return protocol; + } + + public int getNextPollCounterSec() { + return nextPollCounterSec; + } + + public void setNextPollCounterSec(final int nextPollDelayInSec) { + this.nextPollCounterSec = nextPollDelayInSec; + } + + public String getTargetSecurityToken() { + return targetSecurityToken; + } + + public void setTargetSecurityToken(final String targetSecurityToken) { + this.targetSecurityToken = targetSecurityToken; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java new file mode 100644 index 0000000..2beee8c --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DDISimulatedDevice.java @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +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.DdiControllerBase; +import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType; +import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase; +import org.eclipse.hawkbit.ddi.json.model.DdiResult; +import org.eclipse.hawkbit.ddi.json.model.DdiResult.FinalResult; +import org.eclipse.hawkbit.ddi.json.model.DdiStatus; +import org.eclipse.hawkbit.ddi.json.model.DdiStatus.ExecutionStatus; +import org.eclipse.hawkbit.ddi.json.model.DdiUpdateMode; +import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifactHash; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater.UpdaterCallback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * A simulated device using the DDI API of the hawkBit update server. + * + */ +public class DDISimulatedDevice extends AbstractSimulatedDevice { + + private static final Logger LOGGER = LoggerFactory.getLogger(DDISimulatedDevice.class); + + private final DdiRootControllerRestApi controllerResource; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final String gatewayToken; + + private volatile boolean removed; + private volatile Long currentActionId; + + /** + * @param id + * the ID of the device + * @param tenant + * the tenant of the simulated device + * @param pollDelaySec + * the delay of the poll interval in sec + * @param controllerResource + * the http controller resource + * @param deviceUpdater + * the service to update devices + * @param gatewayToken + * to authenticate at DDI and for download as well + */ + public DDISimulatedDevice(final String id, final String tenant, final int pollDelaySec, + final DdiRootControllerRestApi controllerResource, final DeviceSimulatorUpdater deviceUpdater, + final String gatewayToken) { + super(id, tenant, Protocol.DDI_HTTP, pollDelaySec); + this.controllerResource = controllerResource; + this.deviceUpdater = deviceUpdater; + this.gatewayToken = gatewayToken; + LOGGER.info("Id: %s, tenant: %s \n", id, tenant); + } + + @Override + public void clean() { + super.clean(); + removed = true; + } + + /** + * Polls the base URL for the DDI API interface. + */ + @Override + public void poll() { + if (!removed) { + ResponseEntity poll = null; + try { + poll = controllerResource.getControllerBase(getTenant(), getId()); + } catch (final RuntimeException ex) { + LOGGER.error("Failed base poll", ex); + return; + } + + if (!HttpStatus.OK.equals(poll.getStatusCode())) { + return; + } + + final String href = poll.getBody().getLink("deploymentBase").getHref(); + if (href == null) { + return; + } + + final long actionId = Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?'))); + if (currentActionId == null || currentActionId == actionId) { + final ResponseEntity action = controllerResource + .getControllerBasedeploymentAction(getTenant(), getId(), actionId, -1, null); + + if (!HttpStatus.OK.equals(action.getStatusCode())) { + return; + } + + final HandlingType updateType = action.getBody().getDeployment().getUpdate(); + final List modules = action.getBody().getDeployment().getChunks(); + + currentActionId = actionId; + startDdiUpdate(actionId, updateType, modules); + } + } + } + + @Override + public void updateAttribute(final String mode, final String key, final String value) { + + final DdiUpdateMode updateMode; + switch (mode.toLowerCase()) { + case "replace": + updateMode = DdiUpdateMode.REPLACE; + break; + case "remove": + updateMode = DdiUpdateMode.REMOVE; + break; + case "merge": + default: + updateMode = DdiUpdateMode.MERGE; + break; + } + + final DdiStatus status = new DdiStatus(ExecutionStatus.CLOSED, new DdiResult(FinalResult.SUCCESS, null), null); + + final DdiConfigData configData = new DdiConfigData(null, null, status, Collections.singletonMap(key, value), + updateMode); + + controllerResource.putConfigData(configData, super.getTenant(), super.getId()); + } + + private static DmfSoftwareModule convertChunk(final DdiChunk ddi) { + final DmfSoftwareModule converted = new DmfSoftwareModule(); + converted.setModuleVersion(ddi.getVersion()); + converted.setArtifacts( + ddi.getArtifacts().stream().map(DDISimulatedDevice::convertArtifact).collect(Collectors.toList())); + + return converted; + } + + private static DmfArtifact convertArtifact(final DdiArtifact ddi) { + final DmfArtifact converted = new DmfArtifact(); + converted.setSize(ddi.getSize()); + converted.setFilename(ddi.getFilename()); + converted.setHashes(new DmfArtifactHash(ddi.getHashes().getSha1(), (ddi.getHashes().getMd5()))); + final Map urls = new HashMap<>(); + + if (ddi.getLink("download") != null) { + urls.put("HTTPS", ddi.getLink("download").getHref()); + } + + if (ddi.getLink("download-http") != null) { + urls.put("HTTP", ddi.getLink("download-http").getHref()); + } + + converted.setUrls(urls); + + return converted; + } + + private void startDdiUpdate(final long actionId, final HandlingType updateType, final List modules) { + + deviceUpdater.startUpdate(getTenant(), getId(), + modules.stream().map(DDISimulatedDevice::convertChunk).collect(Collectors.toList()), null, gatewayToken, + sendFeedback(actionId), + HandlingType.SKIP.equals(updateType) ? EventTopic.DOWNLOAD : EventTopic.DOWNLOAD_AND_INSTALL); + } + + private UpdaterCallback sendFeedback(final long actionId) { + return device -> { + final DdiActionFeedback feedback = calculateFeedback(actionId, device); + controllerResource.postBasedeploymentActionFeedback(feedback, getTenant(), getId(), actionId); + currentActionId = null; + }; + } + + private DdiActionFeedback calculateFeedback(final long actionId, final AbstractSimulatedDevice device) { + DdiActionFeedback feedback; + + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.CLOSED, + new DdiResult(FinalResult.SUCCESS, null), device.getUpdateStatus().getStatusMessages())); + break; + case ERROR: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.CLOSED, + new DdiResult(FinalResult.FAILURE, null), device.getUpdateStatus().getStatusMessages())); + break; + case DOWNLOADING: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.DOWNLOAD, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + case DOWNLOADED: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.DOWNLOADED, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + case RUNNING: + feedback = new DdiActionFeedback(actionId, null, new DdiStatus(ExecutionStatus.PROCEEDING, + new DdiResult(FinalResult.NONE, null), device.getUpdateStatus().getStatusMessages())); + break; + default: + throw new IllegalStateException("simulated device has an unknown response status + " + + device.getUpdateStatus().getResponseStatus()); + } + return feedback; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java new file mode 100644 index 0000000..7092ae7 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DMFSimulatedDevice.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; + +/** + * A simulated device using the DMF API of the hawkBit update server. + */ +public class DMFSimulatedDevice extends AbstractSimulatedDevice { + private final DmfSenderService spSenderService; + + private static final Logger LOGGER = LoggerFactory.getLogger(DMFSimulatedDevice.class); + + + /** + * @param id + * the ID of the device + * @param tenant + * the tenant of the simulated device + */ + public DMFSimulatedDevice(final String id, final String tenant, final DmfSenderService spSenderService, + final int pollDelaySec) { + super(id, tenant, Protocol.DMF_AMQP, pollDelaySec); + this.spSenderService = spSenderService; + LOGGER.info(" Id: {}, tenant: {} \n", id, tenant); + } + + @Override + public void poll() { + LOGGER.info("handling event of tenant "+super.getTenant()); + + spSenderService.createOrUpdateThing(super.getTenant(), super.getId()); + } + + @Override + public void updateAttribute(final String mode, final String key, final String value) { + System.out.println("[DMFSimulatedDevice] handling updateAttribute"); + + final DmfUpdateMode updateMode; + + switch (mode.toLowerCase()) { + + case "replace" : + updateMode = DmfUpdateMode.REPLACE; + break; + case "remove" : + updateMode = DmfUpdateMode.REMOVE; + break; + case "merge" : + default : + updateMode = DmfUpdateMode.MERGE; + break; + } + + spSenderService.updateAttributesOfThing(super.getTenant(), super.getId(), updateMode, key, value); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java new file mode 100644 index 0000000..16e6a11 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulator.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.eclipse.hawkbit.google.gcp.GcpProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler; + +/** + * The main-method to start the Spring-Boot application. + * + */ +@SpringBootApplication +@EnableScheduling +public class DeviceSimulator { + + public DeviceSimulator() { + // utility class + } + + /** + * @return central ScheduledExecutorService + */ + @Bean + ScheduledExecutorService threadPool() { + return Executors.newScheduledThreadPool(4); + } + + @Bean + TaskScheduler taskScheduler() { + return new ConcurrentTaskScheduler(threadPool()); + } + + /** + * Start the Spring Boot Application. + * + * @param args + * the args + */ + // Exception squid:S2095 - Spring boot standard behavior + @SuppressWarnings({ "squid:S2095" }) + public static void main(final String[] args) { + GcpProperties.parseCLI(args); + SpringApplication.run(DeviceSimulator.class, args); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java new file mode 100644 index 0000000..2d97738 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorRepository.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +/** + * An in-memory simulated device repository to hold the simulated device in + * memory and be able to retrieve them again. + * + */ +@Service +public class DeviceSimulatorRepository { + + private final Map devices = new ConcurrentHashMap<>(); + + private final Set tenants = new HashSet<>(); + + /** + * Adds a simulated device to the repository. + * + * @param simulatedDevice + * the device to add + * @return the device which has been added to the repository + */ + public AbstractSimulatedDevice add(final AbstractSimulatedDevice simulatedDevice) { + devices.put(new DeviceKey(simulatedDevice.getTenant().toLowerCase(), simulatedDevice.getId()), simulatedDevice); + tenants.add(simulatedDevice.getTenant().toLowerCase()); + return simulatedDevice; + } + + /** + * @return all simulated devices + */ + public Collection getAll() { + return devices.values(); + } + + /** + * Retrieves a single simulated devices or {@code null} if device does not + * exists. + * + * @param tenant + * the tenant of the simulated device + * @param id + * the ID of the device + * @return a simulated device from the repository or {@code null} if device + * does not exixts. + */ + public AbstractSimulatedDevice get(final String tenant, final String id) { + return devices.get(new DeviceKey(tenant.toLowerCase(), id)); + } + + /** + * Removes a device from the simulation. + * + * @param tenant + * the tenant of the simulated device + * @param id + * the ID of the device + * @return the simulated device or null if it was not in the + * repository + */ + public AbstractSimulatedDevice remove(final String tenant, final String id) { + return devices.remove(new DeviceKey(tenant.toLowerCase(), id)); + } + + public Set getTenants() { + return tenants; + } + + /** + * Clears all stored devices. + */ + public void clear() { + devices.values().forEach(AbstractSimulatedDevice::clean); + devices.clear(); + tenants.clear(); + } + + private static final class DeviceKey { + private final String tenant; + private final String id; + + private DeviceKey(final String tenant, final String id) { + this.tenant = tenant; + this.id = id; + } + + @Override + public int hashCode() {// NOSONAR - as this is generated + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((tenant == null) ? 0 : tenant.hashCode()); + return result; + } + + @Override + public boolean equals(final Object obj) {// NOSONAR - as this is + // generated + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final DeviceKey other = (DeviceKey) obj; + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + if (tenant == null) { + if (other.tenant != null) { + return false; + } + } else if (!tenant.equals(other.tenant)) { + return false; + } + return true; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java new file mode 100644 index 0000000..3c2110e --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/DeviceSimulatorUpdater.java @@ -0,0 +1,403 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +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.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; +import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.google.gcp.GcpOTA; +import org.eclipse.hawkbit.google.gcp.GcpSubscriber; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.UpdateStatus.ResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import com.google.common.base.Charsets; +import com.google.common.io.ByteStreams; + +/** + * Update simulation handler. + */ +@Service +public class DeviceSimulatorUpdater { + + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceSimulatorUpdater.class); + + @Autowired + private ScheduledExecutorService threadPool; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Starting an simulated update process of an simulated device. + * + * @param tenant + * the tenant of the device + * @param id + * the ID of the simulated device + * @param modules + * the software module version from the hawkbit update server + * @param targetSecurityToken + * the target security token for download authentication + * @param gatewayToken + * as alternative to target token the gateway token for download + * authentication + * @param callback + * the callback which gets called when the simulated update + * process has been finished + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void startUpdate(final String tenant, final String id, final List modules, + final String targetSecurityToken, final String gatewayToken, final UpdaterCallback callback, + final EventTopic actionType) { + + AbstractSimulatedDevice device = repository.get(tenant, id); + + // plug and play - non existing device will be auto created + if (device == null) { + device = repository + .add(deviceFactory.createSimulatedDevice(id, tenant, Protocol.DMF_AMQP, 1800, null, null)); + } + + device.setTargetSecurityToken(targetSecurityToken); + + if(GcpOTA.FW_VIA_COMMAND) { + threadPool.schedule(new DeviceSimulatorUpdateThread(device, callback, modules, actionType, gatewayToken), 2_000, + TimeUnit.MILLISECONDS); + } + else //use the subscription on state + { + GcpSubscriber.updateDevice(device, callback, modules, actionType); + } + } + + private static final class DeviceSimulatorUpdateThread 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 EventTopic actionType; + + private final AbstractSimulatedDevice device; + private final UpdaterCallback callback; + private final List modules; + private final String gatewayToken; + private static String payload = ""; + + private DeviceSimulatorUpdateThread(final AbstractSimulatedDevice device, final UpdaterCallback callback, + final List modules, final EventTopic actionType, final String gatewayToken) { + this.device = device; + this.callback = callback; + this.modules = modules; + this.actionType = actionType; + this.gatewayToken = gatewayToken; + } + + @Override + public void run() { + + + if(GcpOTA.FW_VIA_COMMAND) + { + device.setUpdateStatus(new UpdateStatus(ResponseStatus.RUNNING, "Simulation begins!")); + callback.sendFeedback(device); + + if (!CollectionUtils.isEmpty(modules)) { + device.setUpdateStatus(simulateDownloads()); + callback.sendFeedback(device); + if (isErrorResponse(device.getUpdateStatus())) { + device.clean(); + return; + } + } + + if (actionType == EventTopic.DOWNLOAD_AND_INSTALL) { + LOGGER.info("Download & Install"); + device.setUpdateStatus(new UpdateStatus(ResponseStatus.SUCCESSFUL, "Simulation complete!")); + callback.sendFeedback(device); + device.clean(); + } + } + } + + + + private void syncDownloadGCP(String deviceId, String data) + { + LOGGER.info("Attempting download to the device \n"+data); + GcpIoTHandler.sendCommand(device.getId(), GcpOTA.PROJECT_ID, GcpOTA.CLOUD_REGION, + GcpOTA.REGISTRY_NAME, "This is a payload from HawkBit:\n"+data); + } + + private UpdateStatus simulateDownloads() { + + device.setUpdateStatus(new UpdateStatus(ResponseStatus.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()))); + callback.sendFeedback(device); + + final List status = new ArrayList<>(); + + LOGGER.info("Simulate downloads for "+device.getId()); + + + modules.forEach(module -> { + module.getArtifacts().forEach( + artifact -> handleArtifact(device.getTargetSecurityToken(), gatewayToken, status, artifact)); + }); + + syncDownloadGCP(device.getId(), payload); + + final UpdateStatus result = new UpdateStatus(ResponseStatus.DOWNLOADED); + result.getStatusMessages().add("Simulator: Download complete!"); + status.forEach(download -> { + result.getStatusMessages().addAll(download.getStatusMessages()); + if (isErrorResponse(download)) { + result.setResponseStatus(ResponseStatus.ERROR); + } + }); + + LOGGER.info("Download simulations complete for {}", device.getId()); + + return result; + } + + private static boolean isErrorResponse(final UpdateStatus status) { + if (status == null) { + return false; + } + + return ResponseStatus.ERROR.equals(status.getResponseStatus()); + } + + private static void handleArtifact(final String targetToken, final String gatewayToken, + final List status, final DmfArtifact artifact) { + + LOGGER.info(" handleArtifact "+artifact.getSize()); + if (artifact.getUrls().containsKey("HTTPS")) { + status.add(downloadUrl(artifact.getUrls().get("HTTPS"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } else if (artifact.getUrls().containsKey("HTTP")) { + status.add(downloadUrl(artifact.getUrls().get("HTTP"), gatewayToken, targetToken, + artifact.getHashes().getSha1(), artifact.getSize())); + } + } + + private static UpdateStatus downloadUrl(final String url, final String gatewayToken, final String targetToken, + final String sha1Hash, final long size) { + LOGGER.info(" downloadingUrl "+url); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Downloading {} with token {}, expected sha1 hash {} and size {}", url, + hideTokenDetails(targetToken), sha1Hash, size); + } + + try { + return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size); + } catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + LOGGER.error("Failed to download " + url, e); + return new UpdateStatus(ResponseStatus.ERROR, "Failed to download " + url + ": " + e.getMessage()); + } + + } + + private static 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.isEmpty(targetToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken); + } else if (!StringUtils.isEmpty(gatewayToken)) { + request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken); + } + + final String sha1HashResult; + try (final CloseableHttpResponse response = httpclient.execute(request)) { + + if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) { + final String message = wrongStatusCode(url, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + if (response.getEntity().getContentLength() != size) { + final String message = wrongContentLength(url, size, response); + return new UpdateStatus(ResponseStatus.ERROR, message); + } + + // Exception squid:S2070 - not used for hashing sensitive + // data + @SuppressWarnings("squid:S2070") + final MessageDigest md = MessageDigest.getInstance("SHA-1"); + + //overallread = getOverallRead(response, md); + payload = getPayload(response); + + // if (overallread != size) { + // final String message = incompleteRead(url, size, overallread); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } + // + // sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest()); + } + + // if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) { + // final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult); + // return new UpdateStatus(ResponseStatus.ERROR, message); + // } + + final String message = "Downloaded " + url + " (" + payload.getBytes().length + " bytes)"; + LOGGER.debug(message); + return new UpdateStatus(ResponseStatus.SUCCESSFUL, 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 getPayload(final CloseableHttpResponse response) + throws IOException { + try { + InputStream is = response.getEntity().getContent(); + payload = new String(ByteStreams.toByteArray(is),Charsets.UTF_8); + LOGGER.info("Payload: "+payload); + } catch (Exception e) { + e.printStackTrace(); + } + return payload; + } + + 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 static String wrongHash(final String url, final String sha1Hash, final long overallread, + final String sha1HashResult) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: " + + sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)"; + LOGGER.error(message); + return message; + } + + private static String incompleteRead(final String url, final long size, final long overallread) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size + + BUT_GOT_LOG_MESSAGE + overallread + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongContentLength(final String url, final long size, + final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size + + BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")"; + LOGGER.error(message); + return message; + } + + private static String wrongStatusCode(final String url, final CloseableHttpResponse response) { + final String message = DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getStatusLine().getStatusCode() + + ")"; + LOGGER.error(message); + return message; + } + + private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts() + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + final SSLContextBuilder builder = SSLContextBuilder.create(); + builder.loadTrustMaterial(null, (chain, authType) -> true); + final SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(builder.build()); + return HttpClients.custom().setSSLSocketFactory(sslsf).build(); + } + } + + /** + * Callback interface which is called when the simulated update process has + * been finished and the caller of starting the simulated update process can + * send the result back to the hawkBit update server. + */ + @FunctionalInterface + public interface UpdaterCallback { + /** + * Callback method to indicate that the simulated update process has + * been finished. + * + * @param device + * the device which has been updated + */ + void sendFeedback(AbstractSimulatedDevice device); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java new file mode 100644 index 0000000..7eb90ac --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/NextPollTimeController.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.Collection; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Poll time trigger which executes the {@link DDISimulatedDevice#poll()} every + * second. + */ +@Component +public class NextPollTimeController { + + private static final Logger LOGGER = LoggerFactory.getLogger(NextPollTimeController.class); + + private static final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); + private static final ExecutorService pollService = Executors.newFixedThreadPool(1); + + @Autowired + private DeviceSimulatorRepository repository; + + /** + * Constructor which schedules the poll trigger runnable every second. + */ + public NextPollTimeController() { + executorService.scheduleWithFixedDelay(new NextPollUpdaterRunnable(), 1, 1, TimeUnit.SECONDS); + } + + private class NextPollUpdaterRunnable implements Runnable { + @Override + public void run() { + final Collection devices = repository.getAll(); + + devices.forEach(device -> { + int nextCounter = device.getNextPollCounterSec() - 1; + if (nextCounter < 0) { + try { + pollService.submit(() -> device.poll()); + } catch (final IllegalStateException e) { + LOGGER.trace("Device could not be polled", e); + } + nextCounter = device.getPollDelaySec(); + } + + device.setNextPollCounterSec(nextCounter); + }); + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java new file mode 100644 index 0000000..efe8f45 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatedDeviceFactory.java @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + + +import java.net.URL; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.hawkbit.ddi.client.resource.RootControllerResourceClient; +import org.eclipse.hawkbit.feign.core.client.IgnoreMultipleConsumersProducersSpringMvcContract; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.amqp.DmfSenderService; +import org.eclipse.hawkbit.simulator.http.GatewayTokenInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import feign.Feign; +import feign.Logger.Level; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.slf4j.Slf4jLogger; + +/** + * The simulated device factory to create either {@link DMFSimulatedDevice} or + * {@link DDISimulatedDevice}. + */ +@Service +public class SimulatedDeviceFactory { + @Autowired + private DeviceSimulatorUpdater deviceUpdater; + + @Autowired(required = false) + private DmfSenderService spSenderService; + + @Autowired + private ScheduledExecutorService threadPool; + + /** + * Creating a simulated device. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param protocol + * the protocol which should be used be the simulated device + * @param pollDelaySec + * the poll delay time in seconds which should be used for + * {@link DDISimulatedDevice}s and {@link DMFSimulatedDevice} + * @param baseEndpoint + * the http base endpoint which should be used for + * {@link DDISimulatedDevice}s + * @param gatewayToken + * the gatewayToken to be used to authenticate + * {@link DDISimulatedDevice}s at the endpoint + * @return the created simulated device + */ + public AbstractSimulatedDevice createSimulatedDevice(final String id, final String tenant, final Protocol protocol, + final int pollDelaySec, final URL baseEndpoint, final String gatewayToken) { + return createSimulatedDevice(id, tenant, protocol, pollDelaySec, baseEndpoint, gatewayToken, false); + } + + private AbstractSimulatedDevice createSimulatedDevice(final String id, final String tenant, final Protocol protocol, + final int pollDelaySec, final URL baseEndpoint, final String gatewayToken, final boolean pollImmediatly) { + switch (protocol) { + case DMF_AMQP: + return createDmfDevice(id, tenant, pollDelaySec, pollImmediatly); + case DDI_HTTP: + return createDdiDevice(id, tenant, pollDelaySec, baseEndpoint, gatewayToken); + default: + throw new IllegalArgumentException("Protocol " + protocol + " unknown"); + } + } + + private AbstractSimulatedDevice createDdiDevice(final String id, final String tenant, final int pollDelaySec, + final URL baseEndpoint, final String gatewayToken) { + + final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(new Jackson2HalModule()); + + final RootControllerResourceClient controllerResource = Feign.builder() + .requestInterceptor(new GatewayTokenInterceptor(gatewayToken)) + .contract(new IgnoreMultipleConsumersProducersSpringMvcContract()).logLevel(Level.HEADERS) + .decoder(new ResponseEntityDecoder(new JacksonDecoder(mapper))).encoder(new JacksonEncoder()) + .logger(new Slf4jLogger()).decode404() + .target(RootControllerResourceClient.class, baseEndpoint.toString()); + + return new DDISimulatedDevice(id, tenant, pollDelaySec, controllerResource, deviceUpdater, gatewayToken); + } + + private AbstractSimulatedDevice createDmfDevice(final String id, final String tenant, final int pollDelaySec, + final boolean pollImmediatly) { + final AbstractSimulatedDevice device = new DMFSimulatedDevice(id, tenant, spSenderService, pollDelaySec); + device.setNextPollCounterSec(pollDelaySec); + if (pollImmediatly) { + spSenderService.createOrUpdateThing(tenant, id); + } + + threadPool.schedule(() -> spSenderService.updateAttributesOfThing(tenant, id), 2_000, TimeUnit.MILLISECONDS); + + return device; + } + + /** + * Creating a simulated device and send an immediate DMF poll to update + * server. + * + * @param id + * the ID of the simulated device + * @param tenant + * the tenant of the simulated device + * @param protocol + * the protocol which should be used be the simulated device + * @param pollDelaySec + * the poll delay time in seconds which should be used for + * {@link DDISimulatedDevice}s and {@link DMFSimulatedDevice} + * @param baseEndpoint + * the http base endpoint which should be used for + * {@link DDISimulatedDevice}s + * @param gatewayToken + * the gatewayToken to be used to authenticate + * {@link DDISimulatedDevice}s at the endpoint + * @return the created simulated device + */ + public AbstractSimulatedDevice createSimulatedDeviceWithImmediatePoll(final String id, final String tenant, + final Protocol protocol, final int pollDelaySec, final URL baseEndpoint, final String gatewayToken) { + return createSimulatedDevice(id, tenant, protocol, pollDelaySec, baseEndpoint, gatewayToken, true); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java new file mode 100644 index 0000000..e1c9ab3 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationController.java @@ -0,0 +1,277 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.util.List; + +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.google.gcp.GcpOTA; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.google.api.services.cloudiot.v1.model.Device; +import com.google.gson.Gson; + +/** + * REST endpoint for controlling the device simulator. + */ +@RestController +public class SimulationController { + + private final DeviceSimulatorRepository repository; + + private final SimulatedDeviceFactory deviceFactory; + + private final AmqpProperties amqpProperties; + + private final SimulationProperties simulationProperties; + + private static final Logger LOGGER = LoggerFactory.getLogger(SimulationController.class); + + Gson gson = new Gson(); + + @Autowired + public SimulationController(final DeviceSimulatorRepository repository, final SimulatedDeviceFactory deviceFactory, final AmqpProperties amqpProperties, final SimulationProperties simulationProperties) { + this.repository = repository; + this.deviceFactory = deviceFactory; + this.amqpProperties = amqpProperties; + this.simulationProperties = simulationProperties; + } + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/gcp") + ResponseEntity gcp(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "1") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + + try { + List allDevices_gcp = GcpIoTHandler.listDevices(GcpOTA.PROJECT_ID, + GcpOTA.CLOUD_REGION, GcpOTA.REGISTRY_NAME); + for(Device gcp_device : allDevices_gcp) + { + LOGGER.info("GCP Device: "+gcp_device.getId()); + repository.add(deviceFactory. + createSimulatedDevice(gcp_device.getId(), + simulationProperties.getDefaultTenant(), + protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + } catch (GeneralSecurityException | IOException e) { + e.printStackTrace(); + + } + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + + + /** + * The start resource to start a device creation. + * + * @param name + * the name prefix of the generated device naming + * @param amount + * the amount of devices to be created + * @param tenant + * the tenant to create the device to + * @param api + * the api-protocol to be used either {@code dmf} or {@code ddi} + * @param endpoint + * the URL endpoint to be used of the hawkbit-update-server for + * DDI devices + * @param pollDelay + * number of delay in seconds to delay polling of DDI + * devices + * @param gatewayToken + * the hawkbit-update-server gatewaytoken in case authentication + * is enforced in hawkbit + * @return a response string that devices has been created + * @throws MalformedURLException + */ + @GetMapping("/start") + ResponseEntity start(@RequestParam(value = "name", defaultValue = "simulated") final String name, + @RequestParam(value = "amount", defaultValue = "20") final int amount, + @RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "api", defaultValue = "dmf") final String api, + @RequestParam(value = "endpoint", defaultValue = "http://localhost:8080") final String endpoint, + @RequestParam(value = "polldelay", defaultValue = "30") final int pollDelay, + @RequestParam(value = "gatewaytoken", defaultValue = "") final String gatewayToken) + throws MalformedURLException { + + final Protocol protocol; + switch (api.toLowerCase()) { + case "dmf": + protocol = Protocol.DMF_AMQP; + break; + case "ddi": + protocol = Protocol.DDI_HTTP; + break; + default: + return ResponseEntity.badRequest().body("query param api only allows value of 'dmf' or 'ddi'"); + } + + if (protocol == Protocol.DMF_AMQP && isDmfDisabled()) { + return ResponseEntity.badRequest() + .body("The AMQP interface has been disabled, to use DMF protocol you need to enable the AMQP interface via '" + + AmqpProperties.CONFIGURATION_PREFIX + ".enabled=true'"); + } + + for (int i = 0; i < amount; i++) { + final String deviceId = name + i; + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + (tenant != null ? tenant : simulationProperties.getDefaultTenant()), protocol, pollDelay, + new URL(endpoint), gatewayToken)); + } + + return ResponseEntity.ok("Updated " + amount + " " + protocol + " connected targets!"); + } + + /** + * Update an attribute of a device. + * + * NOTE: This represents not the expected client behaviour for DDI, since a + * DDI client shall only update its attributes if requested by hawkBit. + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be updated. + * @param mode + * Update mode ('merge', 'replace', or 'remove') + * @param key + * Key of the attribute to be updated + * @param value + * Value of the attribute + * @return HTTP OK (200) if the update has been triggered. + */ + @GetMapping("/attributes") + ResponseEntity update(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId, + @RequestParam(value = "mode", defaultValue = "merge") final String mode, + @RequestParam(value = "key") final String key, + @RequestParam(value = "value", required = false) final String value) { + + final AbstractSimulatedDevice simulatedDevice = repository + .get((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (simulatedDevice == null) { + return ResponseEntity.notFound().build(); + } + + simulatedDevice.updateAttribute(mode, key, value); + + return ResponseEntity.ok("Update triggered"); + } + + /** + * Remove a simulated device + * + * @param tenant + * The tenant the device belongs to + * @param controllerId + * The controller id of the device that should be removed. + * @return HTTP OK (200) if the device was removed, or HTTP NO FOUND (404) + * if not found. + */ + @GetMapping("/remove") + ResponseEntity remove(@RequestParam(value = "tenant", required = false) final String tenant, + @RequestParam(value = "controllerid") final String controllerId) { + + final AbstractSimulatedDevice controller = repository + .remove((tenant != null ? tenant : simulationProperties.getDefaultTenant()), controllerId); + + if (controller == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok("Deleted"); + } + + + @GetMapping("/hi") + ResponseEntity hi() { + return ResponseEntity.ok("hi"); + } + + + /** + * Reset the device simulator by removing all simulated devices + * + * @return A response string that the simulator has been reset + */ + @GetMapping("/reset") + ResponseEntity reset() { + + repository.clear(); + + return ResponseEntity.ok("All simulated devices have been removed."); + } + + private boolean isDmfDisabled() { + return !amqpProperties.isEnabled(); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java new file mode 100644 index 0000000..a30833e --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulationProperties.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import javax.validation.constraints.NotEmpty; + +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice.Protocol; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.google.common.base.Splitter; + +/** + * General simulator service properties. + * + */ +@Component +@ConfigurationProperties("hawkbit.device.simulator") +// Exception for squid:S2245 : not security relevant random number generation +@SuppressWarnings("squid:S2245") +public class SimulationProperties { + private static final Splitter SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults(); + private static final Random RANDOM = new Random(); + + private String defaultTenant = "DEFAULT"; + + /** + * List of tenants where the simulator should auto start simulations after + * startup. + */ + private final List autostarts = new ArrayList<>(); + + private final List attributes = new ArrayList<>(); + + public String getDefaultTenant() { + return defaultTenant; + } + + public void setDefaultTenant(final String defaultTenant) { + this.defaultTenant = defaultTenant; + } + + public List getAttributes() { + return attributes; + } + + public List getAutostarts() { + return this.autostarts; + } + + /** + * Properties for target attributes set as part of simulation. + * + */ + public static class Attribute { + private String key; + private String value; + private String random; + + public String getKey() { + return key; + } + + public String getValue() { + return Optional.ofNullable(value).orElseGet(this::getRandomElement); + } + + public void setKey(final String key) { + this.key = key; + } + + public void setValue(final String value) { + this.value = value; + } + + public void setRandom(final String random) { + this.random = random; + } + + public String getRandom() { + return random; + } + + private String getRandomElement() { + final List options = SPLITTER.splitToList(random); + return options.get(RANDOM.nextInt(options.size())); + } + } + + /** + * Auto start configuration for simulation setups that the simulator begins + * after startup. + * + */ + public static class Autostart { + /** + * Name prefix of simulated devices, followed by counter, e.g. + * simulated0, simulated1, simulated2.... + */ + private String name = "simulated"; + + /** + * Amount of simulated devices. + */ + private int amount = 0; + + /** + * Tenant name for the simulation. + */ + @NotEmpty + private String tenant; + + /** + * API for simulation. + */ + private Protocol api = Protocol.DMF_AMQP; + + /** + * Endpoint in case of DDI API based simulation. + */ + private String endpoint = "http://localhost:8080"; + + /** + * Poll time in {@link TimeUnit#SECONDS} for simulated devices. + */ + private int pollDelay = (int) TimeUnit.MINUTES.toSeconds(30); + + /** + * Optional gateway token for DDI API based simulation. + */ + private String gatewayToken = ""; + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public int getAmount() { + return amount; + } + + public void setAmount(final int amount) { + this.amount = amount; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(final String tenant) { + this.tenant = tenant; + } + + public Protocol getApi() { + return api; + } + + public void setApi(final Protocol api) { + this.api = api; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(final String endpoint) { + this.endpoint = endpoint; + } + + public int getPollDelay() { + return pollDelay; + } + + public void setPollDelay(final int pollDelay) { + this.pollDelay = pollDelay; + } + + public String getGatewayToken() { + return gatewayToken; + } + + public void setGatewayToken(final String gatewayToken) { + this.gatewayToken = gatewayToken; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java new file mode 100644 index 0000000..478cd3f --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/SimulatorStartup.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.eclipse.hawkbit.google.gcp.GcpFireStore; +import org.eclipse.hawkbit.google.gcp.GcpSubscriber; +import org.eclipse.hawkbit.simulator.amqp.AmqpProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * Execution of operations after startup. Set up of simulations. + * + */ +@Component +@ConditionalOnProperty(prefix = "hawkbit.device.simulator", name = "autostart", matchIfMissing = true) +public class SimulatorStartup implements ApplicationListener { + private static final Logger LOGGER = LoggerFactory.getLogger(SimulatorStartup.class); + + @Autowired + private SimulationProperties simulationProperties; + + @Autowired + private DeviceSimulatorRepository repository; + + @Autowired + private SimulatedDeviceFactory deviceFactory; + + @Autowired + private AmqpProperties amqpProperties; + + @Override + public void onApplicationEvent(final ApplicationReadyEvent event) { + System.out.println("AutoStarting application ..."); + LOGGER.debug("{} autostarts will be executed", simulationProperties.getAutostarts().size()); + + + amqpProperties.setEnabled(true); + + LOGGER.info("Init Firestore ... "); + GcpFireStore.init(); + LOGGER.info("Init Subscriber ... "); + GcpSubscriber.init(); + + //TODO: Nice to have: at startup read the Hawkbit artifacts and upload them to the bucket + simulationProperties.getAutostarts().forEach(autostart -> { + LOGGER.info("Autostart runs for tenant {} and API {}", autostart.getTenant(), autostart.getApi()); + for (int i = 0; i < autostart.getAmount(); i++) { + final String deviceId = autostart.getName() + i; + try { + if (amqpProperties.isEnabled()) { + repository.add(deviceFactory.createSimulatedDeviceWithImmediatePoll(deviceId, + autostart.getTenant(), autostart.getApi(), autostart.getPollDelay(), + new URL(autostart.getEndpoint()), autostart.getGatewayToken())); + } + + } catch (final MalformedURLException e) { + LOGGER.error("Creation of simulated device at startup failed.", e); + } + } + }); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java new file mode 100644 index 0000000..6088aa6 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/UpdateStatus.java @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator; + +import java.util.ArrayList; +import java.util.List; + +/** + * Update status of the simulated update. + * + */ +public class UpdateStatus { + private ResponseStatus responseStatus; + private List statusMessages; + + /** + * Constructor. + * + * @param responseStatus + * of the update + */ + public UpdateStatus(final ResponseStatus responseStatus) { + this.responseStatus = responseStatus; + } + + /** + * Constructor including status message. + * + * @param responseStatus + * of the update + * @param message + * of the update status + */ + public UpdateStatus(final ResponseStatus responseStatus, final String message) { + this(responseStatus); + statusMessages = new ArrayList<>(); + statusMessages.add(message); + } + + /** + * Constructor including status message. + * + * @param responseStatus + * of the update + * @param messages + * of the update status + */ + public UpdateStatus(final ResponseStatus responseStatus, final List statusMessages) { + this(responseStatus); + this.statusMessages = statusMessages; + } + + public ResponseStatus getResponseStatus() { + return responseStatus; + } + + public void setResponseStatus(final ResponseStatus responseStatus) { + this.responseStatus = responseStatus; + } + + public List getStatusMessages() { + if (statusMessages == null) { + statusMessages = new ArrayList<>(); + } + + return statusMessages; + } + + /** + * The status to response to the hawkBit update server if an simulated + * update process should be respond with successful or failure update. + */ + public enum ResponseStatus { + /** + * Update has been successful and response the successful update. + */ + SUCCESSFUL, + + /** + * Update has been not successful and response the error update. + */ + ERROR, + + /** + * Update is running (intermediate status). + */ + RUNNING, + + /** + * Device starts to download. + */ + DOWNLOADING, + + /** + * Device is finished with downloading. + */ + DOWNLOADED; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java new file mode 100644 index 0000000..d235af0 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpConfiguration.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.time.Duration; +import java.util.Map; + +import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; +import org.eclipse.hawkbit.simulator.SimulationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.FanoutExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +import com.google.common.collect.Maps; + +/** + * The spring AMQP configuration to use a AMQP for communication with SP update + * server. + */ +@Configuration +@EnableConfigurationProperties(AmqpProperties.class) +@ConditionalOnProperty(prefix = AmqpProperties.CONFIGURATION_PREFIX, name = "enabled") +public class AmqpConfiguration { + + private static final Logger LOGGER = LoggerFactory.getLogger(AmqpConfiguration.class); + + @Autowired + private AmqpProperties amqpProperties; + + @Bean + RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) { + final RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); + + final RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setBackOffPolicy(new ExponentialBackOffPolicy()); + rabbitTemplate.setRetryTemplate(retryTemplate); + + rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { + if (ack) { + LOGGER.debug("Message with correlation ID {} confirmed by broker.", correlationData.getId()); + } else { + LOGGER.error("Broker is unable to handle message with correlation ID {} : {}", correlationData.getId(), + cause); + } + + }); + + return rabbitTemplate; + } + + @Bean + DmfReceiverService dmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + return new DmfReceiverService(rabbitTemplate, amqpProperties, spSenderService, deviceUpdater, repository); + } + + @Bean + DmfSenderService dmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + return new DmfSenderService(rabbitTemplate, amqpProperties, simulationProperties); + } + + /** + * Creates the receiver queue from update server for receiving message from + * update server. + * + * @return the queue + */ + @Bean + Queue receiverConnectorQueueFromHawkBit() { + final Map arguments = getTTLMaxArgs(); + + return QueueBuilder.nonDurable(amqpProperties.getReceiverConnectorQueueFromSp()).autoDelete() + .withArguments(arguments).build(); + } + + /** + * Creates the receiver exchange for sending messages to update server. + * + * @return the exchange + */ + @Bean + FanoutExchange exchangeQueueToConnector() { + return new FanoutExchange(amqpProperties.getSenderForSpExchange(), false, true); + } + + /** + * Create the Binding + * {@link AmqpConfiguration#receiverConnectorQueueFromHawkBit()} to + * {@link AmqpConfiguration#exchangeQueueToConnector()}. + * + * @return the binding and create the queue and exchange + */ + @Bean + Binding bindReceiverQueueToSpExchange() { + return BindingBuilder.bind(receiverConnectorQueueFromHawkBit()).to(exchangeQueueToConnector()); + } + + private static Map getTTLMaxArgs() { + final Map args = Maps.newHashMapWithExpectedSize(2); + args.put("x-message-ttl", Duration.ofDays(1).toMillis()); + args.put("x-max-length", 100_000); + return args; + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java new file mode 100644 index 0000000..9bafd4f --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/AmqpProperties.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Bean which holds the necessary properties for configuring the AMQP + * connection. + * + */ +@Component +@ConfigurationProperties("hawkbit.device.simulator.amqp") +public class AmqpProperties { + + /** + * The prefix for this configuration. + */ + public static final String CONFIGURATION_PREFIX = "hawkbit.device.simulator.amqp"; + + /** + * Indicates if the AMQP interface is enabled for the device simulator. + */ + private boolean enabled; + + /** + * Set to true for the simulator run DMF health check. + */ + private boolean checkDmfHealth = false; + + /** + * Queue for receiving DMF messages from update server. + */ + private String receiverConnectorQueueFromSp = "simulator_receiver"; + + /** + * Exchange for sending DMF messages to update server. + */ + private String senderForSpExchange = "simulator.replyTo"; + + /** + * Message time to live (ttl) for the deadletter queue. Default ttl is 1 + * hour. + */ + private int deadLetterTtl = 60_000; + + public boolean isCheckDmfHealth() { + return checkDmfHealth; + } + + public void setCheckDmfHealth(final boolean checkDmfHealth) { + this.checkDmfHealth = checkDmfHealth; + } + + public String getReceiverConnectorQueueFromSp() { + return receiverConnectorQueueFromSp; + } + + public void setReceiverConnectorQueueFromSp(final String receiverConnectorQueueFromSp) { + this.receiverConnectorQueueFromSp = receiverConnectorQueueFromSp; + } + + public String getSenderForSpExchange() { + return senderForSpExchange; + } + + public void setSenderForSpExchange(final String senderForSpExchange) { + this.senderForSpExchange = senderForSpExchange; + } + + public int getDeadLetterTtl() { + return deadLetterTtl; + } + + public void setDeadLetterTtl(final int deadLetterTtl) { + this.deadLetterTtl = deadLetterTtl; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java new file mode 100644 index 0000000..64859ff --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfReceiverService.java @@ -0,0 +1,317 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; +import org.eclipse.hawkbit.dmf.amqp.api.MessageType; +import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; +import org.eclipse.hawkbit.google.gcp.GcpBucketHandler; +import org.eclipse.hawkbit.simulator.AbstractSimulatedDevice; +import org.eclipse.hawkbit.simulator.DeviceSimulatorRepository; +import org.eclipse.hawkbit.simulator.DeviceSimulatorUpdater; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.scheduling.annotation.Scheduled; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + + +/** + * Handle all incoming Messages from hawkBit update server. + * + */ +public class DmfReceiverService extends MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DmfReceiverService.class); + + private final DmfSenderService spSenderService; + + private final DeviceSimulatorUpdater deviceUpdater; + + private final DeviceSimulatorRepository repository; + + private final Set openPings = new HashSet(); + + private Gson gson = new Gson(); + + /** + * Constructor. + * + * @param rabbitTemplate + * for sending messages + * @param amqpProperties + * for amqp configuration + * @param spSenderService + * to send messages + * @param deviceUpdater + * simulator service for updates + * @param repository + * to manage simulated devices + */ + DmfReceiverService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final DmfSenderService spSenderService, final DeviceSimulatorUpdater deviceUpdater, + final DeviceSimulatorRepository repository) { + super(rabbitTemplate, amqpProperties); + this.spSenderService = spSenderService; + this.deviceUpdater = deviceUpdater; + this.repository = repository; + LOGGER.info("Init"); + } + + /** + * Method to validate if content type is set in the message properties. + * + * @param message + * the message to get validated + */ + private void checkContentTypeJson(final Message message) { + LOGGER.info(" checkJson "+message.getBody()); + if (message.getBody().length == 0) { + return; + } + final MessageProperties messageProperties = message.getMessageProperties(); + final String headerContentType = (String) messageProperties.getHeaders().get("content-type"); + if (null != headerContentType) { + messageProperties.setContentType(headerContentType); + } + final String contentType = messageProperties.getContentType(); + if (contentType != null && contentType.contains("json")) { + return; + } + throw new AmqpRejectAndDontRequeueException("Content-Type is not JSON compatible"); + } + + /** + * Handle the incoming Message from Queue with the property + * (hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp). + * + * @param message + * the incoming message + * @param type + * the action type + * @param thingId + * the thing id in message header + * @param tenant + * the device belongs to + */ + @RabbitListener(queues = "${hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp}") + public void recieveMessageSp(final Message message, @Header(MessageHeaderKey.TYPE) final String type, + @Header(name = MessageHeaderKey.THING_ID, required = false) final String thingId, + @Header(MessageHeaderKey.TENANT) final String tenant) { + + try { + + final MessageType messageType = MessageType.valueOf(type); + + LOGGER.info(" Message received :\n"+message.toString()); + + if (MessageType.EVENT.equals(messageType)) { + checkContentTypeJson(message); + handleEventMessage(message, thingId); + return; + } + + if (MessageType.THING_DELETED.equals(messageType)) { + checkContentTypeJson(message); + repository.remove(tenant, thingId); + return; + } + + if (MessageType.PING_RESPONSE.equals(messageType)) { + final String correlationId = message.getMessageProperties().getCorrelationIdString(); + if (!openPings.remove(correlationId)) { + LOGGER.error("Unknown PING_RESPONSE received for correlationId: {}.", correlationId); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.info("Got ping response from tenant {} with correlationId {} with timestamp {}", tenant, + correlationId, new String(message.getBody(), StandardCharsets.UTF_8)); + } + + return; + } + + LOGGER.info("No valid message type property."); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Scheduled(fixedDelay = 5_000, initialDelay = 5_000) + void checkDmfHealth() { + LOGGER.info("Message CheckDmfHealth "); + + if (!amqpProperties.isCheckDmfHealth()) { + return; + } + + if (openPings.size() > 5) { + LOGGER.error("Currently {} open pings! DMF does not seem to be reachable.", openPings.size()); + } else { + LOGGER.debug("Currently {} open pings", openPings.size()); + } + + repository.getTenants().forEach(tenant -> { + final String correlationId = UUID.randomUUID().toString(); + spSenderService.ping(tenant, correlationId); + openPings.add(correlationId); + LOGGER.debug("Ping tenant {%s} with correlationId {%s}", tenant, correlationId); + }); + } + + private void handleEventMessage(final Message message, final String thingId) { + + LOGGER.info("handling event "+thingId); + + final Object eventHeader = message.getMessageProperties().getHeaders().get(MessageHeaderKey.TOPIC); + if (eventHeader == null) { + logAndThrowMessageError(message, "Event Topic is not set"); + } + + // Exception squid:S2259 - Checked before + @SuppressWarnings({ "squid:S2259" }) + final EventTopic eventTopic = EventTopic.valueOf(eventHeader.toString()); + LOGGER.info("EventTopic "+eventTopic); + + switch (eventTopic) { + case DOWNLOAD_AND_INSTALL: + case DOWNLOAD: + LOGGER.info("Download with message:\n"+message.toString()); + handleUpdateProcess(message, thingId, eventTopic); + break; + case CANCEL_DOWNLOAD: + LOGGER.info("Cancel Download with message:\n"+message.toString()); + handleCancelDownloadAction(message, thingId); + break; + case REQUEST_ATTRIBUTES_UPDATE: + LOGGER.info("Attributes update with message:\n"+message.toString()); + handleAttributeUpdateRequest(message, thingId); + break; + default: + LOGGER.info("No valid event property."); + break; + } + } + + private void handleAttributeUpdateRequest(final Message message, final String thingId) { + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + LOGGER.info("handleAttributeUpdateRequest event: "+thingId+ " with message: "+message.toString()); + spSenderService.updateAttributesOfThing(tenant, thingId); + } + + public void handleCancelDownloadAction(final Message message, final String thingId) { + System.out.println("[DmfReceiverService] handling Cancel/Download Action "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + System.out.println(message.toString()); + //final Long actionId = convertMessage(message, Long.class); + JsonObject actionIdJson = gson.fromJson(message.getBody().toString(), JsonObject.class); + if(actionIdJson.has("actionId")) { + final long actionId = actionIdJson.get("actionId").getAsLong(); + final SimulatedUpdate update = new SimulatedUpdate(tenant, thingId, actionId); + spSenderService.finishUpdateProcess(update, Arrays.asList("Simulation canceled")); + } else { + LOGGER.error("Action ID does not exist in message: "+message.toString()); + } + } + + private void handleUpdateProcess(final Message message, final String thingId, final EventTopic actionType) { + LOGGER.info(" handling update "+thingId); + + final MessageProperties messageProperties = message.getMessageProperties(); + final Map headers = messageProperties.getHeaders(); + final String tenant = (String) headers.get(MessageHeaderKey.TENANT); + + final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = convertMessage(message, + DmfDownloadAndUpdateRequest.class); + final Long actionId = downloadAndUpdateRequest.getActionId(); + final String targetSecurityToken = downloadAndUpdateRequest.getTargetSecurityToken(); + System.out.println("[DmfReceiverService] handleUpdateProcess event "+thingId); + + + //TODO: This does not work if the there are two or more fws (os and app) + // Following options to consider: + //1- merge into one ZIP and upload to Bucket instead and point the device to it + //2- upload each file separately and upload it to a folder which is the device id, and point the device to the folder + downloadAndUpdateRequest.getSoftwareModules().forEach(module -> { + module.getArtifacts().forEach( + artifact -> + { + try { + LOGGER.info("Handling artifact : "+artifact.getFilename()); + GcpBucketHandler.uploadFirmwareToBucket(artifact.getUrls().get("HTTP") , artifact.getFilename(), targetSecurityToken); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (GeneralSecurityException e) { + e.printStackTrace(); + } + }); + }); + + + deviceUpdater.startUpdate(tenant, thingId, downloadAndUpdateRequest.getSoftwareModules(), targetSecurityToken, + null, device -> sendFeedback(actionId, device), actionType); + } + + private void sendFeedback(final Long actionId, final AbstractSimulatedDevice device) { + LOGGER.info(" sendFeedback event "+device.getId()); + + switch (device.getUpdateStatus().getResponseStatus()) { + case SUCCESSFUL: + spSenderService.finishUpdateProcess(new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case ERROR: + spSenderService.finishUpdateProcessWithError( + new SimulatedUpdate(device.getTenant(), device.getId(), actionId), + device.getUpdateStatus().getStatusMessages()); + break; + case DOWNLOADING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOAD, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case DOWNLOADED: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.DOWNLOADED, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + case RUNNING: + spSenderService.sendActionStatusMessage(device.getTenant(), DmfActionStatus.RUNNING, + device.getUpdateStatus().getStatusMessages(), actionId); + break; + default: + break; + } + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java new file mode 100644 index 0000000..30a12e2 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/DmfSenderService.java @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; +import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; +import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; +import org.eclipse.hawkbit.dmf.amqp.api.MessageType; +import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; +import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; +import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; +import org.eclipse.hawkbit.google.gcp.GcpIoTHandler; +import org.eclipse.hawkbit.simulator.SimulationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.support.CorrelationData; +import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; + +/** + * Sender service to send messages to update server. + */ +public class DmfSenderService extends MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(DmfSenderService.class); + + private final String spExchange; + + /** + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param simulationProperties + * for attributes update class + */ + DmfSenderService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties, + final SimulationProperties simulationProperties) { + super(rabbitTemplate, amqpProperties); + spExchange = AmqpSettings.DMF_EXCHANGE; + System.out.println("[DmfSenderService] init"); + } + + public void ping(final String tenant, final String correlationId) { + System.out.println("[DmfSenderService] ping with correlationId "+correlationId); + + final MessageProperties messageProperties = new MessageProperties(); + messageProperties.getHeaders().put(MessageHeaderKey.TENANT, tenant); + messageProperties.getHeaders().put(MessageHeaderKey.TYPE, MessageType.PING.toString()); + messageProperties.setCorrelationIdString(correlationId); + messageProperties.setReplyTo(amqpProperties.getSenderForSpExchange()); + messageProperties.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN); + + sendMessage(spExchange, new Message(null, messageProperties)); + } + + /** + * Finish the update process. This will send a action status to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a description according the update process + * @param actionType + * indicating whether to download and install or skip + * installation due to maintenance window. + */ + public void finishUpdateProcess(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] Update Process"); + final Message updateResultMessage = createUpdateResultMessage(update, DmfActionStatus.FINISHED, + updateResultMessages); + sendMessage(spExchange, updateResultMessage); + } + + /** + * Finish update process with error and send error to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * list of messages for error + */ + public void finishUpdateProcessWithError(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] update error"); + sendErrorgMessage(update, updateResultMessages); + LOGGER.debug("Update process finished with error \"{}\" reported by thing {}", updateResultMessages, + update.getThingId()); + } + + /** + * Send a message if the message is not null. + * + * @param address + * the exchange name + * @param message + * the amqp message which will be send if its not null + */ + public void sendMessage(final String address, final Message message) { + + + if (message == null) { + System.out.println("[DmfSenderService] received a null message"); + return; + } + System.out.println("[DmfSenderService] send message "+message.toString()); + message.getMessageProperties().getHeaders().remove(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME); + + final String correlationId = UUID.randomUUID().toString(); + + if (isCorrelationIdEmpty(message)) { + message.getMessageProperties().setCorrelationIdString(correlationId); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.trace("Sending message {} to exchange {} with correlationId {}", message, address, correlationId); + } else { + LOGGER.debug("Sending message to exchange {} with correlationId {}", address, correlationId); + } + + rabbitTemplate.send(address, null, message, new CorrelationData(correlationId)); + } + + private static boolean isCorrelationIdEmpty(final Message message) { + System.out.println("[DmfSenderService] coorelation"); + + return message.getMessageProperties().getCorrelationIdString() == null + || message.getMessageProperties().getCorrelationIdString().length() <= 0; + } + + /** + * Convert object and message properties to message. + * + * @param object + * to get converted + * @param messageProperties + * to get converted + * @return converted message + */ + public Message convertMessage(final Object object, final MessageProperties messageProperties) { + Message m = rabbitTemplate.getMessageConverter().toMessage(object, messageProperties); + System.out.println("[DmfSenderService] Converted Message "+m.toString()); + return m; + } + + /** + * Send an error message to SP. + * + * @param tenant + * the tenant + * @param updateResultMessages + * the error message description to send + * @param actionId + * the ID of the action for the error message + */ + public void sendErrorMessage(final String tenant, final List updateResultMessages, final Long actionId) { + + + final Message message = createActionStatusMessage(tenant, DmfActionStatus.ERROR, updateResultMessages, + actionId); + System.out.println("[DmfSenderService] send error message "); + + sendMessage(spExchange, message); + } + + /** + * Send a warning message to SP. + * + * @param update + * the simulated update object + * @param updateResultMessages + * a warning description + */ + public void sendWarningMessage(final SimulatedUpdate update, final List updateResultMessages) { + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.WARNING); + System.out.println("[DmfSenderService] warning message "); + + sendMessage(spExchange, message); + } + + /** + * Method to send a action status to SP. + * + * @param tenant + * the tenant + * @param actionStatus + * the action status + * @param updateResultMessages + * the message to get send + * @param actionId + * the cached value + */ + public void sendActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] send action message"); + + final Message message = createActionStatusMessage(tenant, actionStatus, updateResultMessages, actionId); + sendMessage(message); + + } + + /** + * Create new thing created message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void createOrUpdateThing(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] create/update "); + + sendMessage(spExchange, thingCreatedMessage(tenant, targetId)); + + LOGGER.debug("Created thing created message and send to update server for Thing \"{}\"", targetId); + } + + /** + * Create new attribute update message and send to update server. + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + */ + public void updateAttributesOfThing(final String tenant, final String targetId) { + System.out.printf("Create update attributes message and send to update server for Thing \"{%s}\"", targetId); + Map metadata = GcpIoTHandler.getDeviceMetadata(targetId); + sendMessage(spExchange, + updateAttributes(tenant, + targetId, + DmfUpdateMode.MERGE, + metadata)); + } + + /** + * Create new attribute update message for specific attribute and send to + * update server + * + * @param tenant + * the tenant to create the target + * @param targetId + * the ID of the target to create or update + * @param mode + * the update mode ('merge', 'replace', or 'remove') + * @param key + * the key of the attribute + * @param value + * the value of the attribute + */ + public void updateAttributesOfThing(final String tenant, final String targetId, final DmfUpdateMode mode, + final String key, final String value) { + System.out.println("[DmfSenderService] updateAttributesOfThing"); + + sendMessage(spExchange, updateAttributes(tenant, targetId, mode, Collections.singletonMap(key, value))); + } + + private Message thingCreatedMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] thingCreatedMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.THING_CREATED.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setHeader(MessageHeaderKey.SENDER, "simulator"); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return new Message(null, messagePropertiesForSP); + } + + private MessageProperties createAttributeUpdateMessage(final String tenant, final String targetId) { + System.out.println("[DmfSenderService] createAttributeUpdateMessage"); + + final MessageProperties messagePropertiesForSP = new MessageProperties(); + messagePropertiesForSP.setHeader(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + messagePropertiesForSP.setHeader(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ATTRIBUTES); + messagePropertiesForSP.setHeader(MessageHeaderKey.TENANT, tenant); + messagePropertiesForSP.setHeader(MessageHeaderKey.THING_ID, targetId); + messagePropertiesForSP.setContentType(MessageProperties.CONTENT_TYPE_JSON); + messagePropertiesForSP.setReplyTo(amqpProperties.getSenderForSpExchange()); + return messagePropertiesForSP; + } + + private Message updateAttributes(final String tenant, final String targetId, final DmfUpdateMode mode, + final Map attributes) { + System.out.println("[DmfSenderService] AttributeUpdateMessage"); + final MessageProperties messagePropertiesForSP = createAttributeUpdateMessage(tenant, targetId); + final DmfAttributeUpdate attributeUpdate = new DmfAttributeUpdate(); + attributeUpdate.setMode(mode); + attributeUpdate.getAttributes().putAll(attributes); + + Message m = convertMessage(attributeUpdate, messagePropertiesForSP); + System.out.println("Converted Message "+m.toString()); + return m; + } + + + + /** + * Send a created message to SP. + * + * @param message + * the message to get send + */ + private void sendMessage(final Message message) { + LOGGER.info("[DmfSenderService] sending "+message.toString()); + + sendMessage(spExchange, message); + } + + /** + * Send error message to SP. + * + * @param context + * the current context + * @param updateResultMessages + * a list of descriptions according the update process + */ + private void sendErrorgMessage(final SimulatedUpdate update, final List updateResultMessages) { + System.out.println("[DmfSenderService] error"); + + final Message message = createActionStatusMessage(update, updateResultMessages, DmfActionStatus.ERROR); + sendMessage(spExchange, message); + } + + /** + * Create a action status message. + * + * @param actionStatus + * the ActionStatus + * @param actionMessage + * the message description + * @param actionId + * the action id + * @param cacheValue + * the cacheValue value + */ + private Message createActionStatusMessage(final String tenant, final DmfActionStatus actionStatus, + final List updateResultMessages, final Long actionId) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(actionId, actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, tenant); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createUpdateResultMessage(final SimulatedUpdate cacheValue, final DmfActionStatus actionStatus, + final List updateResultMessages) { + System.out.println("[DmfSenderService] createUpdateResultMessage"); + + final MessageProperties messageProperties = new MessageProperties(); + final Map headers = messageProperties.getHeaders(); + final DmfActionUpdateStatus actionUpdateStatus = new DmfActionUpdateStatus(cacheValue.getActionId(), + actionStatus); + headers.put(MessageHeaderKey.TYPE, MessageType.EVENT.name()); + headers.put(MessageHeaderKey.TENANT, cacheValue.getTenant()); + headers.put(MessageHeaderKey.TOPIC, EventTopic.UPDATE_ACTION_STATUS.name()); + headers.put(MessageHeaderKey.CONTENT_TYPE, MessageProperties.CONTENT_TYPE_JSON); + actionUpdateStatus.addMessage(updateResultMessages); + return convertMessage(actionUpdateStatus, messageProperties); + } + + private Message createActionStatusMessage(final SimulatedUpdate update, final List updateResultMessages, + final DmfActionStatus status) { + System.out.println("[DmfSenderService] createActionStatusMessage"); + + return createActionStatusMessage(update.getTenant(), status, updateResultMessages, update.getActionId()); + } + +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java new file mode 100644 index 0000000..d47ab59 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/MessageService.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.AbstractJavaTypeMapper; + +/** + * Abstract class for sender and receiver service. + * + * + * + */ +public class MessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(MessageService.class); + + protected final RabbitTemplate rabbitTemplate; + + protected final AmqpProperties amqpProperties; + + /** + * Constructor. + * + * @param rabbitTemplate + * the rabbit template + * @param amqpProperties + * the amqp properties + * @param messageConverter + * the message converter + */ + public MessageService(final RabbitTemplate rabbitTemplate, final AmqpProperties amqpProperties) { + this.rabbitTemplate = rabbitTemplate; + this.amqpProperties = amqpProperties; + } + + /** + * Method to call when error emerges. + * + * @param message + * the message that triggered the error + * @param error + * the error + */ + public void logAndThrowMessageError(final Message message, final String error) { + LOGGER.error("Error \"{}\" reported by message {}", error, message.getMessageProperties().getMessageId()); + throw new IllegalArgumentException(error); + } + + /** + * Convert a message body to a given class and set the message header + * AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME for Jackson converter. + * + * @param message + * which body will converted + * @param clazz + * the body class + * @return the converted body + */ + @SuppressWarnings("unchecked") + public T convertMessage(final Message message, final Class clazz) { + message.getMessageProperties().getHeaders().put(AbstractJavaTypeMapper.DEFAULT_CLASSID_FIELD_NAME, + clazz.getTypeName()); + return (T) rabbitTemplate.getMessageConverter().fromMessage(message); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java new file mode 100644 index 0000000..3c46e89 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/amqp/SimulatedUpdate.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.amqp; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Object for holding attributes for a simulated update for the device + * simulator. + */ +public class SimulatedUpdate implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String tenant; + private final String thingId; + private final Long actionId; + private transient LocalDateTime startCacheTime; + + SimulatedUpdate(final String tenant, final String thingId, final Long actionId) { + this.tenant = tenant; + this.thingId = thingId; + this.actionId = actionId; + this.startCacheTime = LocalDateTime.now(); + } + + public String getTenant() { + return tenant; + } + + public String getThingId() { + return thingId; + } + + public Long getActionId() { + return actionId; + } + + public LocalDateTime getStartCacheTime() { + return startCacheTime; + } +} diff --git a/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java new file mode 100644 index 0000000..8cd1f86 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/java/org/eclipse/hawkbit/simulator/http/GatewayTokenInterceptor.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.simulator.http; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +/** + * A feign interceptor to apply the gateway-token header to each http-request. + * + */ +public class GatewayTokenInterceptor implements RequestInterceptor { + + private final String gatewayToken; + + /** + * @param gatewayToken + * the gatwway token to be used in the http-header + */ + public GatewayTokenInterceptor(final String gatewayToken) { + this.gatewayToken = gatewayToken; + } + + @Override + public void apply(final RequestTemplate template) { + template.header("Authorization", "GatewayToken " + gatewayToken); + } +} diff --git a/hawkbit-gcp-iot-core/src/main/resources/.gitignore b/hawkbit-gcp-iot-core/src/main/resources/.gitignore new file mode 100644 index 0000000..81dd324 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/.gitignore @@ -0,0 +1,4 @@ +/keys.json +/rsa_cert.pem +/rsa_private.pem +/firestorekeys.json diff --git a/hawkbit-gcp-iot-core/src/main/resources/application.properties b/hawkbit-gcp-iot-core/src/main/resources/application.properties new file mode 100644 index 0000000..b7a0588 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/application.properties @@ -0,0 +1,42 @@ +# +# Copyright (c) 2015 Bosch Software Innovations GmbH and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# + +## Configuration for DMF communication + + +hawkbit.device.simulator.amqp.enabled=true +hawkbit.device.simulator.amqp.receiverConnectorQueueFromSp=simulator_receiver +hawkbit.device.simulator.amqp.deadLetterQueue=simulator_deadletter +hawkbit.device.simulator.amqp.deadLetterExchange=simulator.deadletter +hawkbit.device.simulator.amqp.senderForSpExchange=simulator.replyTo + +hawkbit.device.simulator.default-tenant=DEFAULT + +## Configuration for simulations +hawkbit.device.simulator.autostarts.[0].tenant=${hawkbit.device.simulator.default-tenant} + +hawkbit.device.simulator.attributes[0].key=isoCode +hawkbit.device.simulator.attributes[0].random=DE,US,AU,FR,DK,CA +hawkbit.device.simulator.attributes[1].key=hwRevision +hawkbit.device.simulator.attributes[1].value=1.1 +hawkbit.device.simulator.attributes[2].key=serial +hawkbit.device.simulator.attributes[2].value=${random.value} + +endpoints.health.enabled=true + +## Configuration for local RabbitMQ integration +spring.rabbitmq.username=guest +spring.rabbitmq.password=guest +spring.rabbitmq.virtualHost=/ +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.dynamic=true + +security.basic.enabled=false +server.port=8083 diff --git a/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml b/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..4a77556 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/resources/logback-spring.xml @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml b/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 0000000..8beb8e5 --- /dev/null +++ b/hawkbit-gcp-iot-core/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,6 @@ + + true + java8 + 1 + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 08473af..9defbc6 100644 --- a/pom.xml +++ b/pom.xml @@ -79,36 +79,15 @@ true + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M2 + com.mycila license-maven-plugin - - org.apache.maven.plugins - maven-enforcer-plugin - 1.4.1 - - - - enforce-no-snapshots - - enforce - - - ${snapshotDependencyAllowed} - - - No Snapshots Allowed! - - - No Snapshots Allowed! - - - - - - org.codehaus.mojo versions-maven-plugin @@ -215,4 +194,4 @@ - + \ No newline at end of file