SDK: Add Update & Artifact handler (#1640)
Extension points that could allow user to plug-in the update exection and simulate some behaviours, uncluding implement real updates Signed-off-by: Marinov Avgustin <Avgustin.Marinov@bosch.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Contributors to the Eclipse Foundation
|
||||
*
|
||||
* This program and the accompanying materials are made
|
||||
* available under the terms of the Eclipse Public License 2.0
|
||||
* which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.device;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Artifact handler provide plug-in endpoint allowing for customization of the artifact processing.
|
||||
* For instance, it could save downloaded files in some location and on successful finish could
|
||||
* apply them.
|
||||
*/
|
||||
public interface ArtifactHandler {
|
||||
|
||||
ArtifactHandler SKIP = url -> DownloadHandler.SKIP;
|
||||
|
||||
DownloadHandler getDownloadHandler(final String url);
|
||||
|
||||
interface DownloadHandler {
|
||||
|
||||
enum Status {
|
||||
SUCCESS, ERROR
|
||||
}
|
||||
|
||||
DownloadHandler SKIP = new DownloadHandler() {
|
||||
@Override
|
||||
public void read(byte[] buff, int off, int len) {
|
||||
// skip
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finished(Status status) {
|
||||
// skip
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Path> download() {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called on every read chunk of data
|
||||
*
|
||||
* @param buff buffer
|
||||
* @param off offset
|
||||
* @param len read bytes
|
||||
*/
|
||||
void read(byte[] buff, int off, int len);
|
||||
|
||||
/**
|
||||
* Called when the download has finished. It could have finished with error in case of network problems,
|
||||
* invalid hashes or size. In case of success the hashes and size are already checked and valid.
|
||||
*
|
||||
* @param status finish status. On error the download shall be discarded and related resources shall be released
|
||||
*/
|
||||
void finished(Status status);
|
||||
|
||||
/**
|
||||
* Return download path if existing
|
||||
*
|
||||
* @return the path to the download
|
||||
*/
|
||||
Optional<Path> download();
|
||||
}
|
||||
}
|
||||
@@ -9,43 +9,21 @@
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.device;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalTime;
|
||||
import java.time.temporal.ChronoField;
|
||||
import java.util.AbstractMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
|
||||
import org.apache.hc.core5.ssl.SSLContextBuilder;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiArtifact;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiChunk;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiConfigData;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback;
|
||||
@@ -58,14 +36,11 @@ import org.eclipse.hawkbit.sdk.Controller;
|
||||
import org.eclipse.hawkbit.sdk.HawkbitClient;
|
||||
import org.eclipse.hawkbit.sdk.Tenant;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Abstract class representing DDI device connecting directly to hawkVit.
|
||||
* Class representing DDI device connecting directly to hawkBit.
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@@ -82,6 +57,7 @@ public class DdiController {
|
||||
|
||||
private final String tenantId;
|
||||
private final String controllerId;
|
||||
private final UpdateHandler updateHandler;
|
||||
private final DdiRootControllerRestApi ddiApi;
|
||||
|
||||
// configuration
|
||||
@@ -95,22 +71,23 @@ public class DdiController {
|
||||
// state
|
||||
private volatile ScheduledExecutorService executorService;
|
||||
private volatile Long currentActionId;
|
||||
private volatile UpdateStatus updateStatus;
|
||||
|
||||
/**
|
||||
* Creates a new device instance.
|
||||
*
|
||||
* @param tenant the tenant of the device belongs to
|
||||
* @param controller the the controller
|
||||
* @param hawkbitClient a factory for creaint to {@link DdiRootControllerRestApi} (and moreused)
|
||||
* @param hawkbitClient a factory for creating to {@link DdiRootControllerRestApi} (and used)
|
||||
* for communication to hawkBit
|
||||
*/
|
||||
public DdiController(final Tenant tenant, final Controller controller, final HawkbitClient hawkbitClient) {
|
||||
public DdiController(final Tenant tenant, final Controller controller,
|
||||
final UpdateHandler updateHandler, final HawkbitClient hawkbitClient) {
|
||||
this.tenantId = tenant.getTenantId();
|
||||
gatewayToken = tenant.getGatewayToken();
|
||||
downloadAuthenticationEnabled = tenant.isDownloadAuthenticationEnabled();
|
||||
this.controllerId = controller.getControllerId();
|
||||
this.targetSecurityToken = controller.getSecurityToken();
|
||||
this.updateHandler = updateHandler == null ? UpdateHandler.SKIP : updateHandler;
|
||||
ddiApi = hawkbitClient.ddiService(DdiRootControllerRestApi.class, tenant, controller);
|
||||
}
|
||||
|
||||
@@ -128,7 +105,7 @@ public class DdiController {
|
||||
}
|
||||
|
||||
private void poll() {
|
||||
Optional.ofNullable(executorService).ifPresent(executor -> {
|
||||
Optional.ofNullable(executorService).ifPresent(executor ->
|
||||
getControllerBase().ifPresentOrElse(
|
||||
controllerBase -> {
|
||||
final Optional<Link> confirmationBaseLink = getRequiredLink(controllerBase, CONFIRMATION_BASE_LINK);
|
||||
@@ -150,7 +127,8 @@ public class DdiController {
|
||||
final List<DdiChunk> modules = deployment.getChunks();
|
||||
|
||||
currentActionId = actionId;
|
||||
executor.submit(new UpdateProcessor(actionId, updateType, modules));
|
||||
executor.submit(
|
||||
updateHandler.getUpdateProcessor(this, updateType, modules));
|
||||
} else if (currentActionId != actionId) {
|
||||
// TODO - cancel and start new one?
|
||||
log.info(LOG_PREFIX + "Action {} is canceled while in process!", getTenantId(),
|
||||
@@ -170,8 +148,7 @@ public class DdiController {
|
||||
// error has occurred or no controller base hasn't been acquired
|
||||
executor.schedule(this::poll, DEFAULT_POLL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
);
|
||||
});
|
||||
));
|
||||
}
|
||||
|
||||
private Optional<DdiControllerBase> getControllerBase() {
|
||||
@@ -238,9 +215,13 @@ public class DdiController {
|
||||
getDdiApi().putConfigData(configData, getTenantId(), getControllerId());
|
||||
}
|
||||
|
||||
private void sendFeedback(final long actionId) {
|
||||
getDdiApi().postDeploymentBaseActionFeedback(updateStatus.feedback(), getTenantId(), getControllerId(), actionId);
|
||||
currentActionId = null;
|
||||
void sendFeedback(final UpdateStatus updateStatus) {
|
||||
getDdiApi().postDeploymentBaseActionFeedback(
|
||||
updateStatus.feedback(), getTenantId(), getControllerId(), currentActionId);
|
||||
if (updateStatus.status() == UpdateStatus.Status.SUCCESSFUL ||
|
||||
updateStatus.status() == UpdateStatus.Status.ERROR) {
|
||||
currentActionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendConfirmationFeedback(final long actionId) {
|
||||
@@ -254,239 +235,4 @@ public class DdiController {
|
||||
final String href = link.getHref();
|
||||
return Long.parseLong(href.substring(href.lastIndexOf('/') + 1, href.indexOf('?')));
|
||||
}
|
||||
|
||||
private class UpdateProcessor implements Runnable {
|
||||
|
||||
private static final String BUT_GOT_LOG_MESSAGE = " but got: ";
|
||||
private static final String DOWNLOAD_LOG_MESSAGE = "Download ";
|
||||
private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6;
|
||||
|
||||
private final long actionId;
|
||||
private final DdiDeployment.HandlingType updateType;
|
||||
private final List<DdiChunk> modules;
|
||||
|
||||
private UpdateProcessor(
|
||||
final long actionId, final DdiDeployment.HandlingType updateType, final List<DdiChunk> modules) {
|
||||
this.actionId = actionId;
|
||||
this.updateType = updateType;
|
||||
this.modules = modules;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!"));
|
||||
sendFeedback(actionId);
|
||||
|
||||
if (!CollectionUtils.isEmpty(modules)) {
|
||||
updateStatus = download();
|
||||
sendFeedback(actionId);
|
||||
final UpdateStatus updateStatus = getUpdateStatus();
|
||||
if (updateStatus != null && updateStatus.status() == UpdateStatus.Status.ERROR) {
|
||||
currentActionId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateType != DdiDeployment.HandlingType.SKIP) {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!"));
|
||||
sendFeedback(actionId);
|
||||
currentActionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private UpdateStatus download() {
|
||||
updateStatus = new UpdateStatus(UpdateStatus.Status.DOWNLOADING,
|
||||
modules.stream().flatMap(mod -> mod.getArtifacts().stream())
|
||||
.map(art -> "Download starts for: " + art.getFilename() + " with SHA1 hash "
|
||||
+ art.getHashes().getSha1() + " and size " + art.getSize())
|
||||
.collect(Collectors.toList()));
|
||||
sendFeedback(actionId);
|
||||
|
||||
log.info(LOG_PREFIX + "Start download", getTenantId(), getControllerId());
|
||||
|
||||
final List<UpdateStatus> updateStatusList = new ArrayList<>();
|
||||
modules.forEach(module -> module.getArtifacts().forEach(artifact -> {
|
||||
if (downloadAuthenticationEnabled) {
|
||||
handleArtifact(getTargetSecurityToken(), gatewayToken, updateStatusList, artifact);
|
||||
} else {
|
||||
handleArtifact(null, null, updateStatusList, artifact);
|
||||
}
|
||||
}));
|
||||
|
||||
log.info(LOG_PREFIX + "Download complete", getTenantId(), getControllerId());
|
||||
|
||||
final List<String> messages = new LinkedList<>();
|
||||
messages.add("Download complete!");
|
||||
updateStatusList.forEach(download -> messages.addAll(download.messages()));
|
||||
return new UpdateStatus(
|
||||
updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ?
|
||||
UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED,
|
||||
messages);
|
||||
}
|
||||
|
||||
private void handleArtifact(
|
||||
final String targetToken, final String gatewayToken,
|
||||
final List<UpdateStatus> status, final DdiArtifact artifact) {
|
||||
artifact.getLink("download").ifPresentOrElse(
|
||||
// HTTPS
|
||||
link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken,
|
||||
artifact.getHashes().getSha1(), artifact.getSize()))
|
||||
,
|
||||
// HTTP
|
||||
() -> status.add(downloadUrl(
|
||||
artifact.getLink("download-http")
|
||||
.map(Link::getHref)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")),
|
||||
gatewayToken, targetToken,
|
||||
artifact.getHashes().getSha1(), artifact.getSize()))
|
||||
);
|
||||
}
|
||||
|
||||
private UpdateStatus downloadUrl(
|
||||
final String url, final String gatewayToken, final String targetToken,
|
||||
final String sha1Hash, final long size) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(LOG_PREFIX + "Downloading {} with token {}, expected sha1 hash {} and size {}", getTenantId(), getControllerId(), url,
|
||||
hideTokenDetails(targetToken), sha1Hash, size);
|
||||
}
|
||||
|
||||
try {
|
||||
return readAndCheckDownloadUrl(url, gatewayToken, targetToken, sha1Hash, size);
|
||||
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
|
||||
log.error(LOG_PREFIX + "Failed to download {}", getTenantId(), getControllerId(), url, e);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of("Failed to download " + url + ": " + e.getMessage()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken,
|
||||
final String targetToken, final String sha1Hash, final long size)
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException {
|
||||
long overallread;
|
||||
final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts();
|
||||
final HttpGet request = new HttpGet(url);
|
||||
|
||||
if (StringUtils.hasLength(targetToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken);
|
||||
} else if (StringUtils.hasLength(gatewayToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken);
|
||||
}
|
||||
|
||||
final String sha1HashResult;
|
||||
try (final CloseableHttpResponse response = httpclient.execute(request)) {
|
||||
|
||||
if (response.getCode() != HttpStatus.OK.value()) {
|
||||
final String message = wrongStatusCode(url, response);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
if (response.getEntity().getContentLength() != size) {
|
||||
final String message = wrongContentLength(url, size, response);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
// Exception squid:S2070 - not used for hashing sensitive
|
||||
// data
|
||||
@SuppressWarnings("squid:S2070")
|
||||
final MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
|
||||
overallread = getOverallRead(response, md);
|
||||
|
||||
if (overallread != size) {
|
||||
final String message = incompleteRead(url, size, overallread);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
sha1HashResult = BaseEncoding.base16().lowerCase().encode(md.digest());
|
||||
}
|
||||
|
||||
if (!sha1Hash.equalsIgnoreCase(sha1HashResult)) {
|
||||
final String message = wrongHash(url, sha1Hash, overallread, sha1HashResult);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
|
||||
final String message = "Downloaded " + url + " (" + overallread + " bytes)";
|
||||
log.debug(message);
|
||||
return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message));
|
||||
}
|
||||
|
||||
private static long getOverallRead(final CloseableHttpResponse response, final MessageDigest md)
|
||||
throws IOException {
|
||||
|
||||
long overallread;
|
||||
|
||||
try (final OutputStream os = ByteStreams.nullOutputStream();
|
||||
final BufferedOutputStream bos = new BufferedOutputStream(new DigestOutputStream(os, md))) {
|
||||
|
||||
try (BufferedInputStream bis = new BufferedInputStream(response.getEntity().getContent())) {
|
||||
overallread = ByteStreams.copy(bis, bos);
|
||||
}
|
||||
}
|
||||
|
||||
return overallread;
|
||||
}
|
||||
|
||||
private static String hideTokenDetails(final String targetToken) {
|
||||
if (targetToken == null) {
|
||||
return "<NULL!>";
|
||||
}
|
||||
|
||||
if (targetToken.isEmpty()) {
|
||||
return "<EMTPTY!>";
|
||||
}
|
||||
|
||||
if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
return targetToken.substring(0, 2) + "***"
|
||||
+ targetToken.substring(targetToken.length() - 2, targetToken.length());
|
||||
}
|
||||
|
||||
private String wrongHash(final String url, final String sha1Hash, final long overallread,
|
||||
final String sha1HashResult) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed with SHA1 hash missmatch (Expected: "
|
||||
+ sha1Hash + BUT_GOT_LOG_MESSAGE + sha1HashResult + ") (" + overallread + " bytes)";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String incompleteRead(final String url, final long size, final long overallread) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " is incomplete (Expected: " + size
|
||||
+ BUT_GOT_LOG_MESSAGE + overallread + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String wrongContentLength(final String url, final long size,
|
||||
final CloseableHttpResponse response) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " has wrong content length (Expected: " + size
|
||||
+ BUT_GOT_LOG_MESSAGE + response.getEntity().getContentLength() + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private String wrongStatusCode(final String url, final CloseableHttpResponse response) {
|
||||
final String message = LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed (" + response.getCode() + ")";
|
||||
log.error(message, getTenantId(), getControllerId());
|
||||
return message;
|
||||
}
|
||||
|
||||
private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts()
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
return HttpClients
|
||||
.custom()
|
||||
.setConnectionManager(
|
||||
PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setSSLSocketFactory(
|
||||
new SSLConnectionSocketFactory(
|
||||
SSLContextBuilder
|
||||
.create()
|
||||
.loadTrustMaterial(null, (chain, authType) -> true)
|
||||
.build()))
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Contributors to the Eclipse Foundation
|
||||
*
|
||||
* This program and the accompanying materials are made
|
||||
* available under the terms of the Eclipse Public License 2.0
|
||||
* which is available at https://www.eclipse.org/legal/epl-2.0/
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.eclipse.hawkbit.sdk.device;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
|
||||
import org.apache.hc.core5.ssl.SSLContextBuilder;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiArtifact;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiArtifactHash;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiChunk;
|
||||
import org.eclipse.hawkbit.ddi.json.model.DdiDeployment;
|
||||
import org.springframework.hateoas.Link;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Update handler provide plug-in endpoint allowing for customization of the update processing.
|
||||
*/
|
||||
public interface UpdateHandler {
|
||||
|
||||
UpdateHandler SKIP = (controller, updateType, modules) ->
|
||||
new UpdateProcessor(controller, updateType, modules, ArtifactHandler.SKIP);
|
||||
|
||||
/**
|
||||
* Creates an update processor for a single software update
|
||||
*
|
||||
* @param controller controller instance
|
||||
* @param updateType the update handleing type
|
||||
* @param modules the modules to be installed
|
||||
* @return the update processor
|
||||
*/
|
||||
UpdateProcessor getUpdateProcessor(
|
||||
final DdiController controller,
|
||||
final DdiDeployment.HandlingType updateType, final List<DdiChunk> modules);
|
||||
|
||||
@Slf4j
|
||||
class UpdateProcessor implements Runnable {
|
||||
|
||||
private static final String LOG_PREFIX = "[{}:{}] ";
|
||||
|
||||
private static final String DOWNLOAD_LOG_MESSAGE = "Download ";
|
||||
private static final String BUT_GOT_LOG_MESSAGE = " but got: ";
|
||||
private static final int MINIMUM_TOKENLENGTH_FOR_HINT = 6;
|
||||
|
||||
private final DdiController ddiController;
|
||||
|
||||
private final DdiDeployment.HandlingType updateType;
|
||||
private final List<DdiChunk> modules;
|
||||
|
||||
private final ArtifactHandler artifactHandler;
|
||||
protected final Map<String, Path> downloads = new HashMap<>();
|
||||
|
||||
public UpdateProcessor(
|
||||
final DdiController ddiController,
|
||||
final DdiDeployment.HandlingType updateType, final List<DdiChunk> modules,
|
||||
final ArtifactHandler artifactHandler) {
|
||||
this.ddiController = ddiController;
|
||||
|
||||
this.updateType = updateType;
|
||||
this.modules = modules;
|
||||
|
||||
this.artifactHandler = artifactHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
ddiController.sendFeedback(new UpdateStatus(UpdateStatus.Status.RUNNING, List.of("Update begins!")));
|
||||
|
||||
if (!CollectionUtils.isEmpty(modules)) {
|
||||
try {
|
||||
final UpdateStatus updateStatus = download();
|
||||
ddiController.sendFeedback(updateStatus);
|
||||
if (updateStatus.status() == UpdateStatus.Status.ERROR) {
|
||||
return;
|
||||
} else {
|
||||
ddiController.sendFeedback(update());
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (updateType != DdiDeployment.HandlingType.SKIP) {
|
||||
ddiController.sendFeedback(
|
||||
new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update complete!")));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension point. An overriding implementation could completely skip the default download and provide its own.
|
||||
* By contract, it shall fill up {@link #downloads};
|
||||
*
|
||||
* @return the status of the download
|
||||
*/
|
||||
protected UpdateStatus download() {
|
||||
ddiController.sendFeedback(
|
||||
new UpdateStatus(
|
||||
UpdateStatus.Status.DOWNLOADING,
|
||||
modules.stream().flatMap(mod -> mod.getArtifacts().stream())
|
||||
.map(art -> "Download starts for: " + art.getFilename() +
|
||||
" with size " + art.getSize() +
|
||||
" and hashes " + art.getHashes())
|
||||
.collect(Collectors.toList())));
|
||||
|
||||
log.info(LOG_PREFIX + "Start download", ddiController.getTenantId(), ddiController.getControllerId());
|
||||
|
||||
final List<UpdateStatus> updateStatusList = new ArrayList<>();
|
||||
modules.forEach(module -> module.getArtifacts().forEach(artifact -> {
|
||||
if (ddiController.isDownloadAuthenticationEnabled()) {
|
||||
handleArtifact(
|
||||
ddiController.getTargetSecurityToken(), ddiController.getGatewayToken(),
|
||||
updateStatusList, artifact);
|
||||
} else {
|
||||
handleArtifact(null, null, updateStatusList, artifact);
|
||||
}
|
||||
}));
|
||||
|
||||
log.info(LOG_PREFIX + "Download complete", ddiController.getTenantId(), ddiController.getControllerId());
|
||||
|
||||
final List<String> messages = new LinkedList<>();
|
||||
messages.add("Download complete!");
|
||||
updateStatusList.forEach(download -> messages.addAll(download.messages()));
|
||||
return new UpdateStatus(
|
||||
updateStatusList.stream().anyMatch(status -> status.status() == UpdateStatus.Status.ERROR) ?
|
||||
UpdateStatus.Status.ERROR : UpdateStatus.Status.DOWNLOADED,
|
||||
messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension point. Called after all artifacts has been successfully downloadec. An overriding implementation
|
||||
* may get the {@link #downloads} map and apply them
|
||||
*/
|
||||
protected UpdateStatus update() {
|
||||
log.info(LOG_PREFIX + "Updated", ddiController.getTenantId(), ddiController.getControllerId());
|
||||
return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of("Update applied"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension point. Called after download and update has been finished. By default it deletes all downloaded
|
||||
* files (if any).
|
||||
*/
|
||||
protected void cleanup() {
|
||||
downloads.values().forEach(path -> {
|
||||
if (!path.toFile().delete()) {
|
||||
log.warn(LOG_PREFIX + "Failed to cleanup {}",
|
||||
ddiController.getTenantId(), ddiController.getControllerId(),
|
||||
path.toFile().getAbsolutePath());
|
||||
}
|
||||
});
|
||||
log.debug(LOG_PREFIX + "Cleaned up", ddiController.getTenantId(), ddiController.getControllerId());
|
||||
}
|
||||
|
||||
private void handleArtifact(
|
||||
final String targetToken, final String gatewayToken,
|
||||
final List<UpdateStatus> status, final DdiArtifact artifact) {
|
||||
artifact.getLink("download").ifPresentOrElse(
|
||||
// HTTPS
|
||||
link -> status.add(downloadUrl(link.getHref(), gatewayToken, targetToken,
|
||||
artifact.getHashes(), artifact.getSize()))
|
||||
,
|
||||
// HTTP
|
||||
() -> status.add(downloadUrl(
|
||||
artifact.getLink("download-http")
|
||||
.map(Link::getHref)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Nor https nor http found!")),
|
||||
gatewayToken, targetToken,
|
||||
artifact.getHashes(), artifact.getSize()))
|
||||
);
|
||||
}
|
||||
|
||||
private UpdateStatus downloadUrl(
|
||||
final String url, final String gatewayToken, final String targetToken,
|
||||
final DdiArtifactHash hash, final long size) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(LOG_PREFIX + "Downloading {} with token {}, expected hash {} and size {}",
|
||||
ddiController.getTenantId(), ddiController.getControllerId(), url,
|
||||
hideTokenDetails(targetToken), hash, size);
|
||||
}
|
||||
|
||||
try {
|
||||
return readAndCheckDownloadUrl(url, gatewayToken, targetToken, hash, size);
|
||||
} catch (final IOException | KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
|
||||
log.error(LOG_PREFIX + "Failed to download {}",
|
||||
ddiController.getTenantId(), ddiController.getControllerId(), url, e);
|
||||
return new UpdateStatus(
|
||||
UpdateStatus.Status.ERROR,
|
||||
List.of("Failed to download " + url + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private UpdateStatus readAndCheckDownloadUrl(final String url, final String gatewayToken,
|
||||
final String targetToken, final DdiArtifactHash hash, final long size)
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException {
|
||||
final Validator sizeValidator = sizeValidator(size);
|
||||
final Validator hashValidator = hashValidator(hash);
|
||||
final ArtifactHandler.DownloadHandler downloadHandler = artifactHandler.getDownloadHandler(url);
|
||||
|
||||
try (final CloseableHttpClient httpclient = createHttpClientThatAcceptsAllServerCerts()) {
|
||||
final HttpGet request = new HttpGet(url);
|
||||
if (StringUtils.hasLength(targetToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "TargetToken " + targetToken);
|
||||
} else if (StringUtils.hasLength(gatewayToken)) {
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "GatewayToken " + gatewayToken);
|
||||
}
|
||||
|
||||
return httpclient.execute(request, response -> {
|
||||
try {
|
||||
if (response.getCode() != HttpStatus.OK.value()) {
|
||||
throw new IllegalStateException("Unexpected status code: " + response.getCode());
|
||||
}
|
||||
|
||||
if (response.getEntity().getContentLength() != size) {
|
||||
throw new IllegalArgumentException("Wrong content length " + "(Expected: " + size + BUT_GOT_LOG_MESSAGE + response.getEntity()
|
||||
.getContentLength() + ")!");
|
||||
}
|
||||
|
||||
final byte[] buff = new byte[32 * 1024];
|
||||
try (final InputStream is = response.getEntity().getContent()) {
|
||||
for (int read; (read = is.read(buff)) != -1; ) {
|
||||
sizeValidator.read(buff, read);
|
||||
hashValidator.read(buff, read);
|
||||
downloadHandler.read(buff, 0, read);
|
||||
}
|
||||
}
|
||||
sizeValidator.validate();
|
||||
hashValidator.validate();
|
||||
|
||||
final String message = "Downloaded " + url + " (" + size + " bytes)";
|
||||
log.debug(LOG_PREFIX + message, ddiController.getTenantId(), ddiController.getControllerId());
|
||||
downloadHandler.finished(ArtifactHandler.DownloadHandler.Status.SUCCESS);
|
||||
downloadHandler.download().ifPresent(path -> downloads.put(url, path));
|
||||
return new UpdateStatus(UpdateStatus.Status.SUCCESSFUL, List.of(message));
|
||||
} catch (final Exception e) {
|
||||
final String message = e.getMessage();
|
||||
if (log.isTraceEnabled()) {
|
||||
log.error(LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed: " + message,
|
||||
ddiController.getTenantId(), ddiController.getControllerId(), e);
|
||||
} else {
|
||||
log.error(LOG_PREFIX + DOWNLOAD_LOG_MESSAGE + url + " failed: " + message,
|
||||
ddiController.getTenantId(), ddiController.getControllerId());
|
||||
}
|
||||
downloadHandler.finished(ArtifactHandler.DownloadHandler.Status.ERROR);
|
||||
return new UpdateStatus(UpdateStatus.Status.ERROR, List.of(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static String hideTokenDetails(final String targetToken) {
|
||||
if (targetToken == null) {
|
||||
return "<NULL!>";
|
||||
}
|
||||
|
||||
if (targetToken.isEmpty()) {
|
||||
return "<EMPTY!>";
|
||||
}
|
||||
|
||||
if (targetToken.length() <= MINIMUM_TOKENLENGTH_FOR_HINT) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
return targetToken.substring(0, 2) + "***" + targetToken.substring(targetToken.length() - 2);
|
||||
}
|
||||
|
||||
private static CloseableHttpClient createHttpClientThatAcceptsAllServerCerts()
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
return HttpClients
|
||||
.custom()
|
||||
.setConnectionManager(
|
||||
PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setSSLSocketFactory(
|
||||
new SSLConnectionSocketFactory(
|
||||
SSLContextBuilder
|
||||
.create()
|
||||
.loadTrustMaterial(null, (chain, authType) -> true)
|
||||
.build()))
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
private interface Validator {
|
||||
|
||||
void read(final byte[] buff, final int len);
|
||||
|
||||
void validate();
|
||||
}
|
||||
|
||||
private static Validator sizeValidator(final long size) {
|
||||
return new Validator() {
|
||||
|
||||
private int read;
|
||||
|
||||
@Override
|
||||
public void read(final byte[] buff, final int len) {
|
||||
read += len;
|
||||
if (read > size) {
|
||||
throw new SecurityException("Size mismatch: read more " +
|
||||
"(Expected: " + size + BUT_GOT_LOG_MESSAGE + read + ")!");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
if (read != size) {
|
||||
throw new SecurityException("Size mismatch " +
|
||||
"(Expected: " + size + BUT_GOT_LOG_MESSAGE + read + ")!");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Validator hashValidator(final DdiArtifactHash hash) throws NoSuchAlgorithmException {
|
||||
|
||||
class HashValidator {
|
||||
|
||||
private final String expected;
|
||||
private final MessageDigest messageDigest;
|
||||
|
||||
private HashValidator(final String expected, final MessageDigest messageDigest) {
|
||||
this.expected = expected;
|
||||
this.messageDigest = messageDigest;
|
||||
}
|
||||
|
||||
private void update(final byte[] buff, final int len) {
|
||||
messageDigest.update(buff, 0, len);
|
||||
}
|
||||
|
||||
private void check() {
|
||||
final String actual = BaseEncoding.base16().lowerCase().encode(messageDigest.digest());
|
||||
if (!actual.equals(expected)) {
|
||||
throw new SecurityException(
|
||||
messageDigest.getAlgorithm() + " hash mismatch " +
|
||||
"(Expected: " + expected + BUT_GOT_LOG_MESSAGE + actual + ")!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<HashValidator> hashValidators = new ArrayList<>(3);
|
||||
if (!ObjectUtils.isEmpty(hash.getSha256())) {
|
||||
hashValidators.add(new HashValidator(hash.getSha256(), MessageDigest.getInstance("SHA-256")));
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(hash.getSha1())) {
|
||||
hashValidators.add(new HashValidator(hash.getSha1(), MessageDigest.getInstance("SHA-1")));
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(hash.getMd5())) {
|
||||
hashValidators.add(new HashValidator(hash.getMd5(), MessageDigest.getInstance("MD5")));
|
||||
}
|
||||
if (hashValidators.isEmpty()) {
|
||||
throw new SecurityException("No hashes in " + hash + "!");
|
||||
}
|
||||
|
||||
return new Validator() {
|
||||
@Override
|
||||
public void read(final byte[] buff, final int len) {
|
||||
hashValidators.forEach(hashValidator -> hashValidator.update(buff, len));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
hashValidators.forEach(HashValidator::check);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ import org.eclipse.hawkbit.ddi.json.model.DdiStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record UpdateStatus(Status status, List<String> messages) {
|
||||
public record UpdateStatus(Status status, List<String> messages) {
|
||||
|
||||
/**
|
||||
* The status to response to the hawkBit update server if an simulated update process should be respond with
|
||||
* successful or failure update.
|
||||
*/
|
||||
enum Status {
|
||||
public enum Status {
|
||||
|
||||
/**
|
||||
* Update has been successful and response the successful update.
|
||||
|
||||
Reference in New Issue
Block a user