diff --git a/hawkbit/README.md b/hawkbit/README.md new file mode 100644 index 0000000..f35e4a8 --- /dev/null +++ b/hawkbit/README.md @@ -0,0 +1,218 @@ +# hawkBit + +## Introduction + +[hawkBit](https://www.eclipse.org/hawkbit/) is a software update server for connected devices. + +This extension connects OpenRemote Manager to the hawkBit Management API, exposes firmware endpoints in OpenRemote, and syncs selected assets as hawkBit targets. + +## Limitations + +**Single realm only.** The extension is bound at startup to one OpenRemote realm, set by `HAWKBIT_REALM` (default `master`). Asset sync, metadata sync, and the firmware API endpoints only operate on that realm. Callers from other realms get `403`. + +Multi-tenant deployments are not supported, and is currently out of scope. + +## Prerequisites + +You need: + +1. A hawkBit instance reachable from OpenRemote Manager +2. Management API credentials for hawkBit +3. An OpenRemote realm that should be synced with hawkBit +4. Assets configured with the firmware meta items described below + +## Configuration + +The following environment variables can be set on the OpenRemote manager: + +| Variable | Required | Description | +|---|---|---| +| `HAWKBIT_MANAGEMENT_API_URL` | No | hawkBit Management API URL (default: `http://localhost:8083/hawkbit/rest/v1`) | +| `HAWKBIT_USERNAME` | No | hawkBit Management API username (default: `hawkbit`) | +| `HAWKBIT_PASSWORD` | No | hawkBit Management API password (default: `hawkbit`) | +| `HAWKBIT_REALM` | No | OpenRemote realm to sync with hawkBit (default: `master`) | + +### Docker Compose Example + +hawkBit can be started with a separate PostgreSQL database and persistent artifact storage: + +```yaml +volumes: + hawkbitdb-data: + hawkbit-artifact-data: + +services: + hawkbitdb: + image: openremote/postgresql:${POSTGRESQL_VERSION:-latest-slim} + restart: always + environment: + POSTGRES_DB: hawkbit + POSTGRES_USER: ${HAWKBIT_DB_USER:-postgres} + POSTGRES_PASSWORD: ${HAWKBIT_DB_PASSWORD:-postgres} + volumes: + - hawkbitdb-data:/var/lib/postgresql/data + + hawkbit: + image: openremote/hawkbit-update-server:develop + restart: always + depends_on: + hawkbitdb: + condition: service_healthy + healthcheck: + interval: 3s + timeout: 3s + start_period: 3s + retries: 100 + test: ["CMD", "curl", "--fail", "--silent", "http://localhost:8080/hawkbit"] + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://hawkbitdb:5432/hawkbit + - SPRING_DATASOURCE_USERNAME=${HAWKBIT_DB_USER:-postgres} + - SPRING_DATASOURCE_PASSWORD=${HAWKBIT_DB_PASSWORD:-postgres} + - SPRING_JPA_DATABASE=POSTGRESQL + - SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.postgresql.Driver + - HAWKBIT_DMF_RABBITMQ_ENABLED=false + - HAWKBIT_ARTIFACT_URL_PROTOCOLS_DOWNLOAD_HTTP_PORT=${HTTPS_FORWARDED_PORT:-443} + - HAWKBIT_ARTIFACT_URL_PROTOCOLS_DOWNLOAD_HTTP_PROTOCOL=https + - HAWKBIT_ARTIFACT_URL_PROTOCOLS_DOWNLOAD_HTTP_REF={protocol}://{hostnameRequest}:{port}$${server.servlet.context-path}/{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/{artifactFileName} + - HAWKBIT_SERVER_DDI_SECURITY_AUTHENTICATION_TARGETTOKEN_ENABLED=true + - SERVER_USE_FORWARD_HEADERS=true + - SERVER_FORWARD_HEADERS_STRATEGY=NATIVE + - SERVER_SERVLET_CONTEXT_PATH=/hawkbit + - HAWKBIT_SECURITY_USER_ADMIN_TENANT=#{null} + - HAWKBIT_SECURITY_USER_ADMIN_PASSWORD=#{null} + - HAWKBIT_SECURITY_USER_ADMIN_ROLES=#{null} + - HAWKBIT_SECURITY_USER_HAWKBIT_TENANT=DEFAULT + - HAWKBIT_SECURITY_USER_HAWKBIT_PASSWORD={noop}${HAWKBIT_PASSWORD:?HAWKBIT_PASSWORD must be set} + - HAWKBIT_SECURITY_USER_HAWKBIT_ROLES=TENANT_ADMIN + volumes: + - hawkbit-artifact-data:/opt/hawkbit/artifactrepo + expose: + - "8080" +``` + +Set the matching Manager environment variables: + +```env +HAWKBIT_MANAGEMENT_API_URL=http://hawkbit:8080/hawkbit/rest/v1 +HAWKBIT_PASSWORD= +``` + +### HAProxy Example + +When hawkBit is behind the OpenRemote proxy, only expose the DDI controller API publicly. OpenRemote Manager uses the Management API over the internal Docker network. + +Add the hawkBit ACLs in the `https` frontend before the final `use_backend manager_backend` rule: + +```haproxy + # hawkBit DDI API for device polling and artifact downloads + acl hawkbit_management_api path_beg /hawkbit/rest/ + acl hawkbit_ddi path_reg ^/hawkbit/[^/]+/controller/v1(/|$) + + http-request deny deny_status 404 if hawkbit_management_api + use_backend hawkbit_backend if hawkbit_ddi +``` + +Add the hawkBit backend: + +```haproxy +backend hawkbit_backend + server hawkbit "${HAWKBIT_HOST}":"${HAWKBIT_PORT}" resolvers docker_resolver +``` + +With the Compose example above, set the proxy environment variables: + +```env +HAWKBIT_HOST=hawkbit +HAWKBIT_PORT=8080 +``` + +The public DDI URL uses `/hawkbit/{tenant}/controller/v1/...`. The Management API at `/hawkbit/rest/v1/...` stays internal. + +## Asset Usage + +### Firmware Targets + +To sync an OpenRemote asset as a hawkBit target, add the `firmwareTarget` meta item to one attribute of the asset. + +When the asset is created or updated in the configured realm, the extension creates a hawkBit target using the OpenRemote asset ID as the hawkBit controller ID. The target info attribute is updated with the `controllerId` and `securityToken` returned by hawkBit. + +When the asset is deleted, the matching hawkBit target is deleted. + +### Firmware Metadata + +To sync an attribute value as hawkBit target metadata, add the `firmwareMetadata` meta item to the attribute. + +Metadata values are converted to strings. The OpenRemote attribute name is used as the hawkBit metadata key. Deleting the attribute removes the metadata entry in hawkBit. + +The parent asset must also be synced as a firmware target with `firmwareTarget`. + +## Firmware API + +OpenRemote Manager exposes endpoints that forward to the configured hawkBit instance. + +| Resource | Path | Functionality | +|---|---|---| +| Firmware targets | `firmware/target` | List targets, get target details, metadata, assigned and installed distribution sets, actions and action status messages | +| Software module types | `firmware/softwaremoduletype` | Create, list, get and delete software module types | +| Software modules | `firmware/softwaremodule` | Create, list, get and delete software modules, list and upload artifacts | +| Distribution set types | `firmware/distributionsettype` | Create, list, get and delete distribution set types, list module type assignments | +| Distribution sets | `firmware/distributionset` | Create, list, get, assign to targets and delete distribution sets | +| Target filters | `firmware/targetfilter` | Create, list, get and delete target filters, manage auto assignment | +| Rollouts | `firmware/rollout` | Create, list, get, start, pause and delete rollouts, list rollout groups | + +Read endpoints require the OpenRemote read admin role. Write endpoints require the write admin role. + +## Data Flow + +```mermaid +sequenceDiagram + participant OR as OpenRemote + participant HB as hawkBit + participant Device as Device + + OR->>HB: Create target from asset + HB-->>OR: Target controller ID and security token + OR->>HB: Sync selected asset attributes as metadata + OR->>HB: Create modules, distribution sets or rollouts + Device->>HB: Poll for assigned firmware actions +``` + +## Components + +``` +manager/ + HawkbitFirmwareService.java Starts the integration, syncs assets and registers API resources + HawkbitResponseProxy.java Proxies hawkBit client responses for OpenRemote APIs + +manager/resource/ + TargetResourceImpl.java Proxies target and action requests to hawkBit + SoftwareModuleResourceImpl.java Proxies software module requests and artifact uploads + DistributionSetResourceImpl.java Proxies distribution set requests and target assignment + DistributionSetTypeResourceImpl.java Proxies distribution set type requests + SoftwareModuleTypeResourceImpl.java Proxies software module type requests + TargetFilterResourceImpl.java Proxies target filter requests + RolloutResourceImpl.java Proxies rollout requests + +manager/hawkbit/ + HawkbitTargetsClient.java RESTEasy client proxy for hawkBit targets + HawkbitSoftwareModulesClient.java RESTEasy client proxy for software modules + HawkbitDistributionSetsClient.java RESTEasy client proxy for distribution sets + HawkbitDistributionSetTypesClient.java RESTEasy client proxy for distribution set types + HawkbitSoftwareModuleTypesClient.java RESTEasy client proxy for software module types + HawkbitTargetFiltersClient.java RESTEasy client proxy for target filters + HawkbitRolloutsClient.java RESTEasy client proxy for rollouts + HawkbitBasicAuth.java Builds hawkBit basic auth headers + HawkbitMediaType.java Defines hawkBit media types + +model/ + FirmwareMetaItemType.java Defines firmwareTarget and firmwareMetadata meta items + FirmwareModelProvider.java Registers firmware meta items in the OpenRemote model + +model/resource/ + TargetResource.java Defines OpenRemote firmware target endpoints + SoftwareModuleResource.java Defines OpenRemote firmware software module endpoints + DistributionSetResource.java Defines OpenRemote firmware distribution set endpoints + +model/hawkbit/ + hawkBit API payload records used by the RESTEasy clients and firmware resources +``` diff --git a/hawkbit/build.gradle b/hawkbit/build.gradle new file mode 100644 index 0000000..58105f2 --- /dev/null +++ b/hawkbit/build.gradle @@ -0,0 +1,88 @@ +apply plugin: "groovy" +apply plugin: "java-library" +apply plugin: "maven-publish" +apply plugin: "signing" + +base { + archivesName = "openremote-${project.name}-extension" +} + +dependencies { + api "io.openremote:openremote-manager:$openremoteVersion" + api "io.openremote:openremote-model:$openremoteVersion" + + api "org.jboss.resteasy:resteasy-client-api:$resteasyVersion" + api "org.jboss.resteasy:resteasy-jaxb-provider:$resteasyVersion" + api "org.jboss.resteasy:resteasy-multipart-provider:$resteasyVersion" + + testImplementation "io.openremote:openremote-test:$openremoteVersion" +} + +jar { + from sourceSets.main.allJava +} + +javadoc { + failOnError = false +} + +java { + withJavadocJar() + withSourcesJar() +} + +publishing { + publications { + maven(MavenPublication) { + group = "io.openremote.extension" + artifactId = "openremote-${project.name}-extension" + from components.java + pom { + name = 'OpenRemote hawkBit extension' + description = 'Adds the hawkBit extension' + url = 'https://github.com/openremote/extensions' + licenses { + license { + name = 'GNU Affero General Public License v3.0' + url = 'https://www.gnu.org/licenses/agpl-3.0.en.html' + } + } + developers { + developer { + id = 'developers' + name = 'Developers' + email = 'developers@openremote.io' + organization = 'OpenRemote' + organizationUrl = 'https://openremote.io' + } + } + scm { + connection = 'scm:git:git://github.com/openremote/extensions.git' + developerConnection = 'scm:git:ssh://github.com:openremote/extensions.git' + url = 'https://github.com/openremote/extensions/tree/main' + } + } + } + } + + repositories { + maven { + if (!version.endsWith('-LOCAL')) { + credentials { + username = findProperty("publishUsername") + password = findProperty("publishPassword") + } + } + url = version.endsWith('-LOCAL') ? layout.buildDirectory.dir('repo') : version.endsWith('-SNAPSHOT') ? findProperty("snapshotsRepoUrl") : findProperty("releasesRepoUrl") + } + } +} + +signing { + def signingKey = findProperty("signingKey") + def signingPassword = findProperty("signingPassword") + if (signingKey && signingPassword) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign publishing.publications.maven + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitFirmwareService.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitFirmwareService.java new file mode 100644 index 0000000..b75680b --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitFirmwareService.java @@ -0,0 +1,640 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.client.jaxrs.ResteasyClient; +import org.jboss.resteasy.client.jaxrs.ResteasyWebTarget; +import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataWriter; +import org.openremote.container.timer.TimerService; +import org.openremote.container.web.WebClient; +import org.openremote.container.web.WebTargetBuilder; +import org.openremote.extension.hawkbit.manager.hawkbit.*; +import org.openremote.extension.hawkbit.manager.resource.*; +import org.openremote.extension.hawkbit.model.FirmwareMetaItemType; +import org.openremote.extension.hawkbit.model.hawkbit.MetadataUpdateRequest; +import org.openremote.extension.hawkbit.model.hawkbit.Target; +import org.openremote.extension.hawkbit.model.hawkbit.TargetCreateRequest; +import org.openremote.extension.hawkbit.model.hawkbit.TargetUpdateRequest; +import org.openremote.manager.asset.AssetProcessingService; +import org.openremote.manager.event.ClientEventService; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.manager.web.ManagerWebService; +import org.openremote.model.Container; +import org.openremote.model.ContainerService; +import org.openremote.model.asset.Asset; +import org.openremote.model.asset.AssetEvent; +import org.openremote.model.asset.AssetTypeInfo; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.AttributeEvent; +import org.openremote.model.attribute.MetaMap; +import org.openremote.model.syslog.SyslogCategory; +import org.openremote.model.util.TextUtil; +import org.openremote.model.util.ValueUtil; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.openremote.container.web.WebTargetBuilder.*; +import static org.openremote.model.syslog.SyslogCategory.API; +import static org.openremote.model.util.MapAccess.getString; + +/** + * Connects the manager to the hawkBit Management API and keeps OpenRemote assets + * marked for firmware management synchronized with hawkBit targets and metadata. + */ +public class HawkbitFirmwareService implements ContainerService { + /** + * hawkBit integration is currently limited to a single realm, + * this is due to hawkBit's tenancy model being difficult to work with because of + * the authentication/security mechanisms that are in place. + */ + public static final String HAWKBIT_REALM = "HAWKBIT_REALM"; + public static final String HAWKBIT_REALM_DEFAULT = "master"; + + public static final String HAWKBIT_USERNAME = "HAWKBIT_USERNAME"; + public static final String HAWKBIT_USERNAME_DEFAULT = "hawkbit"; + public static final String HAWKBIT_PASSWORD = "HAWKBIT_PASSWORD"; + public static final String HAWKBIT_PASSWORD_DEFAULT = "hawkbit"; + + public static final String HAWKBIT_MANAGEMENT_API_URL = "HAWKBIT_MANAGEMENT_API_URL"; + public static final String HAWKBIT_MANAGEMENT_API_URL_DEFAULT = "http://localhost:8083/hawkbit/rest/v1"; + + private static final Logger LOG = SyslogCategory.getLogger(API, HawkbitFirmwareService.class); + + protected ResteasyClient client; + + protected String hawkbitRealm; + protected ClientEventService clientEventService; + protected AssetProcessingService assetProcessingService; + protected ExecutorService executorService; + + protected HawkbitTargetsClient targets; + protected HawkbitDistributionSetsClient distributionSets; + protected HawkbitDistributionSetTypesClient distributionSetTypes; + protected HawkbitSoftwareModulesClient softwareModules; + protected HawkbitSoftwareModuleTypesClient softwareModuleTypes; + protected HawkbitRolloutsClient rollouts; + protected HawkbitTargetFiltersClient targetFilters; + + @Override + public void init(Container container) throws Exception { + clientEventService = container.getService(ClientEventService.class); + assetProcessingService = container.getService(AssetProcessingService.class); + executorService = container.getExecutor(); + TimerService timerService = container.getService(TimerService.class); + ManagerIdentityService identityService = container.getService(ManagerIdentityService.class); + + container.getService(ManagerWebService.class).addApiSingleton( + new TargetResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new DistributionSetResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new DistributionSetTypeResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new SoftwareModuleResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new SoftwareModuleTypeResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new RolloutResourceImpl(timerService, identityService, this)); + container.getService(ManagerWebService.class).addApiSingleton( + new TargetFilterResourceImpl(timerService, identityService, this)); + } + + @Override + public void start(Container container) throws Exception { + String hawkbitURI = getString(container.getConfig(), HAWKBIT_MANAGEMENT_API_URL, + HAWKBIT_MANAGEMENT_API_URL_DEFAULT); + + if (TextUtil.isNullOrEmpty(hawkbitURI)) { + hawkbitURI = HAWKBIT_MANAGEMENT_API_URL_DEFAULT; + } + + if (HAWKBIT_MANAGEMENT_API_URL_DEFAULT.equals(hawkbitURI)) { + LOG.fine(HAWKBIT_MANAGEMENT_API_URL + " not configured, using default=" + + HAWKBIT_MANAGEMENT_API_URL_DEFAULT); + } + + URI uri; + + try { + uri = new URI(hawkbitURI); + } catch (URISyntaxException e) { + LOG.log(Level.SEVERE, "Invalid " + HAWKBIT_MANAGEMENT_API_URL + " value", e); + throw e; + } + + String hawkbitUsername = getString(container.getConfig(), HAWKBIT_USERNAME, HAWKBIT_USERNAME_DEFAULT); + String hawkbitPassword = getString(container.getConfig(), HAWKBIT_PASSWORD, HAWKBIT_PASSWORD_DEFAULT); + + hawkbitRealm = getString(container.getConfig(), HAWKBIT_REALM, HAWKBIT_REALM_DEFAULT); + + client = createClient(org.openremote.container.Container.EXECUTOR, CONNECTION_POOL_SIZE, + CONNECTION_TIMEOUT_MILLISECONDS, resteasyClientBuilder -> { + WebClient.registerDefaults(resteasyClientBuilder); + ResteasyJackson2Provider provider = new ResteasyJackson2Provider(); + provider.setMapper(ValueUtil.JSON); + resteasyClientBuilder.register(provider); + resteasyClientBuilder.register(MultipartFormDataWriter.class); + return resteasyClientBuilder; + }); + + ResteasyWebTarget webTarget = new WebTargetBuilder(client, uri).build(); + webTarget.register((ClientRequestFilter) requestContext -> requestContext.getHeaders().putSingle( + HttpHeaders.AUTHORIZATION, + HawkbitBasicAuth.buildAuthorizationHeader(hawkbitUsername, hawkbitPassword))); + + targets = webTarget.proxy(HawkbitTargetsClient.class); + distributionSets = webTarget.proxy(HawkbitDistributionSetsClient.class); + distributionSetTypes = webTarget.proxy(HawkbitDistributionSetTypesClient.class); + softwareModules = webTarget.proxy(HawkbitSoftwareModulesClient.class); + softwareModuleTypes = webTarget.proxy(HawkbitSoftwareModuleTypesClient.class); + rollouts = webTarget.proxy(HawkbitRolloutsClient.class); + targetFilters = webTarget.proxy(HawkbitTargetFiltersClient.class); + + clientEventService.addSubscription( + AssetEvent.class, + null, + this::onAssetChange); + + clientEventService.addSubscription( + AttributeEvent.class, + null, + this::onAttributeChange); + + LOG.info("Started hawkBit firmware service uri=" + uri + ", realm=" + hawkbitRealm); + } + + @Override + public void stop(Container container) throws Exception { + if (client != null) { + client.close(); + client = null; + } + } + + protected void onAssetChange(AssetEvent assetEvent) { + if (!Objects.equals(assetEvent.getRealm(), hawkbitRealm)) { + return; + } + + executorService.submit(() -> handleAssetChange(assetEvent)); + } + + protected void onAttributeChange(AttributeEvent attributeEvent) { + if (!Objects.equals(attributeEvent.getRealm(), hawkbitRealm)) { + return; + } + + executorService.submit(() -> handleAttributeChange(attributeEvent)); + } + + protected void handleAttributeChange(AttributeEvent attributeEvent) { + if (!hasMetadataFlag(attributeEvent.getAssetType(), attributeEvent.getName(), attributeEvent.getMeta())) { + return; + } + + if (attributeEvent.isDeleted()) { + deleteTargetMetadata(attributeEvent.getId(), attributeEvent.getName()); + return; + } + + syncTargetMetadataValue( + attributeEvent.getId(), + attributeEvent.getName(), + attributeEvent.getValue().orElse(null)); + } + + + protected void handleAssetChange(AssetEvent assetEvent) { + Asset asset = assetEvent.getAsset(); + Optional targetInfoAttributeName = getTargetInfoAttributeName(asset); + + if (targetInfoAttributeName.isEmpty()) { + return; + } + + String attributeName = targetInfoAttributeName.get(); + String controllerId = asset.getId(); + Target target = null; + + LOG.fine("Processing hawkBit target sync cause=" + assetEvent.getCause() + + ", assetId=" + asset.getId()); + + switch (assetEvent.getCause()) { + case CREATE: + case UPDATE: + Target existingTarget; + try { + existingTarget = getTarget(controllerId); + } catch (Exception e) { + LOG.log(Level.WARNING, "hawkBit target lookup failed id=" + controllerId + ", skipping sync", e); + break; + } + + if (existingTarget != null) { + LOG.fine("hawkBit target exists id=" + controllerId); + target = existingTarget; + break; + } + + LOG.fine("hawkBit target missing id=" + controllerId + ", creating"); + target = createTarget(asset); + break; + case DELETE: + deleteTarget(controllerId); + break; + } + + if (target != null) { + updateTargetInfoForAttribute(asset, attributeName, target); + syncTargetMetadata(asset); + } + } + + /** + * Synchronizes all asset attributes marked as firmware metadata to the hawkBit target. + */ + public void syncTargetMetadata(Asset asset) { + String controllerId = asset.getId(); + for (Attribute attribute : asset.getAttributes().values()) { + if (!hasMetadataFlag(asset.getType(), attribute.getName(), attribute.getMeta())) { + continue; + } + + syncTargetMetadataValue(controllerId, attribute.getName(), attribute.getValue().orElse(null)); + } + } + + /** + * Synchronizes a single metadata value to the hawkBit target. + * Deletes the metadata entry when the value is empty or {@code null}. + */ + public void syncTargetMetadataValue(String controllerId, String key, Object value) { + if (isEmptyAttributeValue(value)) { + deleteTargetMetadata(controllerId, key); + return; + } + + Optional metadataValue = ValueUtil.getStringCoerced(value); + if (metadataValue.isEmpty()) { + LOG.warning("Cannot sync hawkBit metadata id=" + controllerId + + ", key=" + key + ": value is not string-compatible"); + return; + } + + updateTargetMetadata(controllerId, key, metadataValue.get()); + } + + /** + * Updates a single hawkBit target metadata entry. + */ + public void updateTargetMetadata(String controllerId, String key, String value) { + try (Response response = targets.updateMetadata(controllerId, key, new MetadataUpdateRequest(value))) { + if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + LOG.fine("hawkBit target not found for metadata sync id=" + + controllerId + ", key=" + key); + return; + } + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + LOG.warning("Failed to update hawkBit metadata id=" + + controllerId + ", key=" + key + ", status=" + response.getStatus()); + return; + } + LOG.fine("Updated hawkBit metadata id=" + controllerId + ", key=" + key); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to update hawkBit metadata id=" + + controllerId + ", key=" + key, e); + } + } + + /** + * Deletes a single hawkBit target metadata entry. + */ + public void deleteTargetMetadata(String controllerId, String key) { + try (Response response = targets.deleteMetadata(controllerId, key)) { + if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + LOG.fine("hawkBit metadata not found for delete id=" + + controllerId + ", key=" + key); + return; + } + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + LOG.warning("Failed to delete hawkBit metadata id=" + + controllerId + ", key=" + key + ", status=" + response.getStatus()); + return; + } + LOG.fine("Deleted hawkBit metadata id=" + controllerId + ", key=" + key); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to delete hawkBit metadata id=" + + controllerId + ", key=" + key, e); + } + } + + protected TargetCreateRequest buildTargetCreateRequest(Asset asset, String securityToken) { + String controllerId = asset.getId(); + String targetName = asset.getAssetType() + "-" + controllerId; + String targetDescription = "assetId=" + asset.getId() + "; realm=" + asset.getRealm(); + return new TargetCreateRequest(controllerId, targetName, targetDescription, securityToken); + } + + /** + * Creates a hawkBit target for an asset. + * The security token is omitted so hawkBit can generate one. + */ + public Target createTarget(Asset asset) { + return createTarget(asset, null); + } + + /** + * Creates a hawkBit target for an asset using an optional security token. + * If {@code securityToken} is {@code null}, hawkBit can generate one. + */ + public Target createTarget(Asset asset, String securityToken) { + if (!isInHawkbitRealm(asset)) { + return null; + } + return createTarget(buildTargetCreateRequest(asset, securityToken)); + } + + /** + * Creates a hawkBit target from a create request. + * Returns {@code null} if creation fails or hawkBit returns no target. + */ + public Target createTarget(TargetCreateRequest target) { + LOG.fine("Creating hawkBit target id=" + target.controllerId()); + try (Response response = targets.create(new TargetCreateRequest[]{target})) { + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + LOG.warning("Failed to create hawkBit target id=" + target.controllerId() + + ", status=" + response.getStatus()); + return null; + } + Target[] created = response.readEntity(Target[].class); + if (created == null || created.length == 0) { + LOG.warning("hawkBit create returned no targets id=" + target.controllerId()); + return null; + } + LOG.info("Created hawkBit target id=" + created[0].controllerId()); + return created[0]; + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to create hawkBit target id=" + target.controllerId(), e); + return null; + } + } + + /** + * Creates or updates a hawkBit target for an asset. + */ + public Target createUpdateTarget(Asset asset, String securityToken) { + if (!isInHawkbitRealm(asset)) { + return null; + } + String controllerId = asset.getId(); + Target existingTarget; + try { + existingTarget = getTarget(controllerId); + } catch (Exception e) { + LOG.log(Level.WARNING, "hawkBit target lookup failed id=" + controllerId + ", skipping create/update", e); + return null; + } + + if (existingTarget != null) { + LOG.fine("hawkBit target exists id=" + controllerId + ", updating"); + return updateTarget(controllerId, new TargetUpdateRequest(null, null, securityToken)); + } + + LOG.fine("hawkBit target missing id=" + controllerId + ", creating"); + return createTarget(asset, securityToken); + } + + /** + * Updates a hawkBit target by controllerId. + * Returns {@code null} if the target is missing or the update fails. + */ + public Target updateTarget(String controllerId, TargetUpdateRequest target) { + LOG.fine("Updating hawkBit target id=" + controllerId); + try (Response response = targets.update(controllerId, target)) { + if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + LOG.fine("hawkBit target not found for update id=" + controllerId); + return null; + } + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + LOG.warning("Failed to update hawkBit target id=" + controllerId + + ", status=" + response.getStatus()); + return null; + } + Target updated = response.hasEntity() ? response.readEntity(Target.class) : getTarget(controllerId); + if (updated == null) { + LOG.info("Updated hawkBit target id=" + controllerId); + return null; + } + LOG.info("Updated hawkBit target id=" + updated.controllerId()); + return updated; + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to update hawkBit target id=" + controllerId, e); + return null; + } + } + + /** + * Deletes a hawkBit target by controllerId. + */ + public void deleteTarget(String controllerId) { + try (Response response = targets.delete(controllerId)) { + if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + LOG.fine("hawkBit target not found for delete id=" + controllerId); + return; + } + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + LOG.warning("Failed to delete hawkBit target id=" + controllerId + + ", status=" + response.getStatus()); + return; + } + LOG.info("Deleted hawkBit target id=" + controllerId); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to delete hawkBit target id=" + controllerId, e); + } + } + + protected void updateTargetInfoForAttribute(Asset asset, String attributeName, Target target) { + try { + Map targetInfo = new LinkedHashMap<>(); + targetInfo.put("controllerId", target.controllerId()); + targetInfo.put("securityToken", target.securityToken()); + String newValueJson = ValueUtil.asJSON(targetInfo).orElse(null); + + assetProcessingService.sendAttributeEvent( + new AttributeEvent(asset.getId(), attributeName, newValueJson), + getClass().getSimpleName()); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to update firmware target info assetId=" + asset.getId(), e); + } + } + + /** + * Queries hawkBit for a target by controllerId. + * Returns {@code null} if the target is not found (404). + */ + public Target getTarget(String controllerId) { + try (Response response = targets.get(controllerId)) { + if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode()) { + return null; + } + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + throw new WebApplicationException("hawkBit target request failed with status " + response.getStatus(), + response.getStatus()); + } + return response.readEntity(Target.class); + } + } + + protected Optional getTargetInfoAttributeName(Asset asset) { + List matchingAttributeNames = asset.getAttributes().values().stream() + .filter(attribute -> hasTargetInfoFlag(attribute.getMeta())) + .map(Attribute::getName) + .distinct() + .toList(); + + if (matchingAttributeNames.isEmpty()) { + return Optional.empty(); + } + + if (matchingAttributeNames.size() > 1) { + LOG.warning("Multiple firmware target attributes assetType=" + asset.getType() + + ", meta=" + FirmwareMetaItemType.FIRMWARE_TARGET.getName()); + return Optional.empty(); + } + + return Optional.of(matchingAttributeNames.getFirst()); + } + + protected boolean hasMetadataFlag(String assetType, String attributeName, MetaMap meta) { + if (hasMetadataMetaFlag(meta)) { + return true; + } + + if (assetType == null) { + return false; + } + + Optional assetTypeInfo = ValueUtil.getAssetInfo(assetType); + return assetTypeInfo + .map(typeInfo -> typeInfo.getAttributeDescriptors().values().stream() + .filter(attributeDescriptor -> Objects.equals(attributeDescriptor.getName(), attributeName)) + .anyMatch(attributeDescriptor -> hasMetadataMetaFlag(attributeDescriptor.getMeta()))) + .orElse(false); + } + + protected boolean hasMetadataMetaFlag(MetaMap meta) { + return meta != null + && meta.get(FirmwareMetaItemType.FIRMWARE_METADATA) + .flatMap(metaItem -> metaItem.getValue(Boolean.class)) + .orElse(false); + } + + protected boolean hasTargetInfoFlag(MetaMap meta) { + return meta != null + && meta.get(FirmwareMetaItemType.FIRMWARE_TARGET) + .flatMap(metaItem -> metaItem.getValue(Boolean.class)) + .orElse(false); + } + + protected boolean isEmptyAttributeValue(Object value) { + return value == null || ValueUtil.getStringCoerced(value) + .map(String::isEmpty) + .orElse(false); + } + + /** + * Targets are only managed for assets in the configured hawkBit realm. + */ + protected boolean isInHawkbitRealm(Asset asset) { + if (!Objects.equals(asset.getRealm(), hawkbitRealm)) { + LOG.warning("Asset realm=" + asset.getRealm() + " is not the hawkBit realm=" + hawkbitRealm + + ", skipping hawkBit target id=" + asset.getId()); + return false; + } + return true; + } + + /** + * Returns the realm this service is bound to. Firmware endpoints are scoped to it. + */ + public String getRealm() { + return hawkbitRealm; + } + + /** + * Returns the hawkBit targets client. + */ + public HawkbitTargetsClient targets() { + return targets; + } + + /** + * Returns the hawkBit distribution sets client. + */ + public HawkbitDistributionSetsClient distributionSets() { + return distributionSets; + } + + /** + * Returns the hawkBit distribution set types client. + */ + public HawkbitDistributionSetTypesClient distributionSetTypes() { + return distributionSetTypes; + } + + /** + * Returns the hawkBit software modules client. + */ + public HawkbitSoftwareModulesClient softwareModules() { + return softwareModules; + } + + /** + * Returns the hawkBit software module types client. + */ + public HawkbitSoftwareModuleTypesClient softwareModuleTypes() { + return softwareModuleTypes; + } + + /** + * Returns the hawkBit rollouts client. + */ + public HawkbitRolloutsClient rollouts() { + return rollouts; + } + + /** + * Returns the hawkBit target filters client. + */ + public HawkbitTargetFiltersClient targetFilters() { + return targetFilters; + } + +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitResponseProxy.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitResponseProxy.java new file mode 100644 index 0000000..7c9edf7 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/HawkbitResponseProxy.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +public final class HawkbitResponseProxy { + + private HawkbitResponseProxy() { + } + + /** + * Executes a hawkBit call and wraps any unexpected failure as {@code BAD_GATEWAY}. + *

+ * The upstream {@link Response} is copied, consumed, and closed by this call. + */ + public static Response proxy(String errorMessage, HawkbitCall call) { + try { + return copyResponse(call.execute()); + } catch (WebApplicationException e) { + throw e; + } catch (Exception e) { + throw new WebApplicationException(errorMessage, e, Response.Status.BAD_GATEWAY); + } + } + + private static Response copyResponse(Response response) { + try (response) { + Response.ResponseBuilder builder = Response.fromResponse(response); + if (!response.hasEntity()) { + return builder.build(); + } + + String body = response.readEntity(String.class); + if (body.isBlank()) { + return builder.entity(null).build(); + } + return builder.entity(body).build(); + } catch (Exception e) { + throw new WebApplicationException("Failed to copy hawkBit response", e, + Response.Status.BAD_GATEWAY); + } + } + + /** + * Replaces {@link java.util.function.Supplier} to allow checked exceptions + * to propagate without {@code UndeclaredThrowableException} wrapping. + */ + @FunctionalInterface + public interface HawkbitCall { + Response execute() throws Exception; + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitBasicAuth.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitBasicAuth.java new file mode 100644 index 0000000..f457aa2 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitBasicAuth.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public final class HawkbitBasicAuth { + + private HawkbitBasicAuth() { + } + + public static String buildAuthorizationHeader(String username, String password) { + String credentials = (username == null ? "" : username) + ":" + (password == null ? "" : password); + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetTypesClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetTypesClient.java new file mode 100644 index 0000000..fdb3282 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetTypesClient.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("distributionsettypes") +public interface HawkbitDistributionSetTypesClient { + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode distributionSetTypes); + + @GET + @Produces(APPLICATION_JSON) + Response getDistributionSetTypes(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") Long id); + + @GET + @Path("{id}/mandatorymoduletypes") + @Produces(APPLICATION_JSON) + Response getMandatoryModuleTypes(@PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}/optionalmoduletypes") + @Produces(APPLICATION_JSON) + Response getOptionalModuleTypes(@PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetsClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetsClient.java new file mode 100644 index 0000000..888942c --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitDistributionSetsClient.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("distributionsets") +public interface HawkbitDistributionSetsClient { + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode distributionSets); + + @POST + @Path("{id}/assignedTargets") + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response assignTargets(@PathParam("id") Long id, + @QueryParam("offline") Boolean offline, + JsonNode targets); + + @GET + @Produces(APPLICATION_JSON) + Response getDistributionSets(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") Long id); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitMediaType.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitMediaType.java new file mode 100644 index 0000000..5d89225 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitMediaType.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +public final class HawkbitMediaType { + + public static final String APPLICATION_HAL_JSON = "application/hal+json"; + + private HawkbitMediaType() { + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitRolloutsClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitRolloutsClient.java new file mode 100644 index 0000000..e628be3 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitRolloutsClient.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("rollouts") +public interface HawkbitRolloutsClient { + + @GET + @Produces(APPLICATION_JSON) + Response getRollouts(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit, + @QueryParam("representation") String representation); + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") Long id); + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode rollout); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") Long id); + + @POST + @Path("{id}/start") + Response start(@PathParam("id") Long id); + + @POST + @Path("{id}/pause") + Response pause(@PathParam("id") Long id); + + @GET + @Path("{id}/deploygroups") + @Produces(APPLICATION_JSON) + Response getRolloutGroups(@PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit, + @QueryParam("representation") String representation); + + @GET + @Path("{id}/deploygroups/{groupId}") + @Produces(APPLICATION_JSON) + Response getRolloutGroup(@PathParam("id") Long id, + @PathParam("groupId") Long groupId); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModuleTypesClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModuleTypesClient.java new file mode 100644 index 0000000..fd94fba --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModuleTypesClient.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("softwaremoduletypes") +public interface HawkbitSoftwareModuleTypesClient { + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode softwareModuleTypes); + + @GET + @Produces(APPLICATION_JSON) + Response getSoftwareModuleTypes(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") Long id); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModulesClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModulesClient.java new file mode 100644 index 0000000..2b8cbc7 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitSoftwareModulesClient.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataOutput; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static jakarta.ws.rs.core.MediaType.MULTIPART_FORM_DATA; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("softwaremodules") +public interface HawkbitSoftwareModulesClient { + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode softwareModules); + + @GET + @Produces(APPLICATION_JSON) + Response getSoftwareModules(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") Long id); + + @GET + @Path("{id}/artifacts") + @Produces(APPLICATION_JSON) + Response getArtifacts(@PathParam("id") Long id); + + @POST + @Path("{id}/artifacts") + @Consumes(MULTIPART_FORM_DATA) + @Produces(APPLICATION_HAL_JSON) + Response uploadArtifact(@PathParam("id") Long id, + @QueryParam("filename") String filename, + MultipartFormDataOutput form); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") Long id); + +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetFiltersClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetFiltersClient.java new file mode 100644 index 0000000..edebdf3 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetFiltersClient.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + +@Path("targetfilters") +public interface HawkbitTargetFiltersClient { + + @GET + @Produces(APPLICATION_JSON) + Response getTargetFilters(@QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{filterId}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("filterId") Long filterId); + + @POST + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response create(JsonNode filter); + + @DELETE + @Path("{filterId}") + Response delete(@PathParam("filterId") Long filterId); + + @GET + @Path("{filterId}/autoAssignDS") + @Produces(APPLICATION_JSON) + Response getAutoAssignDS(@PathParam("filterId") Long filterId); + + @POST + @Path("{filterId}/autoAssignDS") + @Consumes(APPLICATION_HAL_JSON) + @Produces(APPLICATION_HAL_JSON) + Response setAutoAssignDS(@PathParam("filterId") Long filterId, + JsonNode request); + + @DELETE + @Path("{filterId}/autoAssignDS") + Response deleteAutoAssignDS(@PathParam("filterId") Long filterId); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetsClient.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetsClient.java new file mode 100644 index 0000000..bd22022 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/hawkbit/HawkbitTargetsClient.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.hawkbit; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.extension.hawkbit.model.hawkbit.MetadataUpdateRequest; +import org.openremote.extension.hawkbit.model.hawkbit.TargetCreateRequest; +import org.openremote.extension.hawkbit.model.hawkbit.TargetUpdateRequest; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.openremote.extension.hawkbit.manager.hawkbit.HawkbitMediaType.APPLICATION_HAL_JSON; + + +@Path("targets") +public interface HawkbitTargetsClient { + @GET + @Produces(APPLICATION_JSON) + Response getTargets(@QueryParam("q") String query, @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit); + + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + Response create(TargetCreateRequest[] targets); + + + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + Response get(@PathParam("id") String id); + + @PUT + @Path("{id}") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + Response update(@PathParam("id") String id, TargetUpdateRequest target); + + @DELETE + @Path("{id}") + Response delete(@PathParam("id") String id); + + @GET + @Path("{id}/metadata") + @Produces(APPLICATION_JSON) + Response getMetadata(@PathParam("id") String id); + + @PUT + @Path("{id}/metadata/{key}") + @Consumes(APPLICATION_JSON) + Response updateMetadata(@PathParam("id") String id, + @PathParam("key") String key, + MetadataUpdateRequest metadata); + + @DELETE + @Path("{id}/metadata/{key}") + Response deleteMetadata(@PathParam("id") String id, @PathParam("key") String key); + + @GET + @Path("{id}/assignedDS") + @Produces(APPLICATION_JSON) + Response getAssignedDs(@PathParam("id") String id); + + @GET + @Path("{id}/installedDS") + @Produces(APPLICATION_JSON) + Response getInstalledDs(@PathParam("id") String id); + + @GET + @Path("{id}/actions") + @Produces(APPLICATION_HAL_JSON) + Response getActions(@PathParam("id") String id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @GET + @Path("{id}/actions/{actionId}") + @Produces(APPLICATION_HAL_JSON) + Response getAction(@PathParam("id") String id, @PathParam("actionId") Long actionId); + + @GET + @Path("{id}/actions/{actionId}/status") + @Produces(APPLICATION_HAL_JSON) + Response getActionStatus(@PathParam("id") String id, + @PathParam("actionId") Long actionId, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + @DELETE + @Path("{id}/actions/{actionId}") + Response cancelAction(@PathParam("id") String id, + @PathParam("actionId") Long actionId, + @QueryParam("force") Boolean force); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetResourceImpl.java new file mode 100644 index 0000000..f5cf67f --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetResourceImpl.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.DistributionSetResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class DistributionSetResourceImpl extends HawkbitWebResource + implements DistributionSetResource { + + public DistributionSetResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response createDistributionSet(RequestParams requestParams, + String realm, + JsonNode distributionSet) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware distribution set", + () -> hawkbitFirmwareService.distributionSets().create(distributionSet)); + } + + @Override + public Response assignDistributionSet(RequestParams requestParams, String realm, Long id, + Boolean offline, + JsonNode targets) { + requireHawkbitRealmAccess(realm); + if (targets == null || !targets.isArray() || targets.isEmpty()) { + throw new WebApplicationException("Assignment requires at least one target", Response.Status.BAD_REQUEST); + } + return HawkbitResponseProxy.proxy("Failed to assign firmware distribution set '" + id + "'", + () -> hawkbitFirmwareService.distributionSets().assignTargets(id, offline, targets)); + } + + @Override + public Response getDistributionSets(RequestParams requestParams, String realm, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware distribution sets", + () -> hawkbitFirmwareService.distributionSets().getDistributionSets(offset, limit)); + } + + @Override + public Response getDistributionSet(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware distribution set '" + id + "'", + () -> hawkbitFirmwareService.distributionSets().get(id)); + } + + @Override + public Response deleteDistributionSet(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware distribution set '" + id + "'", + () -> hawkbitFirmwareService.distributionSets().delete(id)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetTypeResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetTypeResourceImpl.java new file mode 100644 index 0000000..b79df60 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/DistributionSetTypeResourceImpl.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.DistributionSetTypeResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class DistributionSetTypeResourceImpl extends HawkbitWebResource + implements DistributionSetTypeResource { + + public DistributionSetTypeResourceImpl(TimerService timerService, + ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response createDistributionSetType(RequestParams requestParams, + String realm, + JsonNode distributionSetType) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware distribution set type", + () -> hawkbitFirmwareService.distributionSetTypes().create(distributionSetType)); + } + + @Override + public Response getDistributionSetTypes(RequestParams requestParams, String realm, Integer offset, + Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware distribution set types", + () -> hawkbitFirmwareService.distributionSetTypes().getDistributionSetTypes(offset, limit)); + } + + @Override + public Response getDistributionSetType(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware distribution set type '" + id + "'", + () -> hawkbitFirmwareService.distributionSetTypes().get(id)); + } + + @Override + public Response getMandatoryModuleTypes(RequestParams requestParams, String realm, Long id, Integer offset, + Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve mandatory module types for firmware distribution set type '" + id + "'", + () -> hawkbitFirmwareService.distributionSetTypes().getMandatoryModuleTypes(id, offset, limit)); + } + + @Override + public Response getOptionalModuleTypes(RequestParams requestParams, String realm, Long id, Integer offset, + Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve optional module types for firmware distribution set type '" + id + "'", + () -> hawkbitFirmwareService.distributionSetTypes().getOptionalModuleTypes(id, offset, limit)); + } + + @Override + public Response deleteDistributionSetType(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware distribution set type '" + id + "'", + () -> hawkbitFirmwareService.distributionSetTypes().delete(id)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/HawkbitWebResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/HawkbitWebResource.java new file mode 100644 index 0000000..6ab0ef8 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/HawkbitWebResource.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.manager.web.ManagerWebResource; + +import java.util.Objects; + +/** + * Base resource for hawkBit Management API proxy endpoints. + *

+ * The request path realm remains the OpenRemote authentication realm. The hawkBit target realm defaults to the + * authenticated realm, and can be supplied explicitly by endpoints for superuser cross-realm access. + */ +public abstract class HawkbitWebResource extends ManagerWebResource { + + protected final HawkbitFirmwareService hawkbitFirmwareService; + + protected HawkbitWebResource(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService); + this.hawkbitFirmwareService = hawkbitFirmwareService; + } + + /** + * Verifies that the requested hawkBit realm is accessible to the caller and is the realm configured for this + * hawkBit integration. If no target realm is supplied, the authenticated realm is used. + * + * @param realm optional target OpenRemote realm for firmware management + * @throws BadRequestException if no target realm can be resolved + * @throws ForbiddenException if the caller cannot access the realm, or hawkBit is not configured for that realm + */ + protected void requireHawkbitRealmAccess(String realm) { + realm = realm == null || realm.isBlank() ? getAuthenticatedRealmName() : realm; + if (realm == null || realm.isBlank()) { + throw new BadRequestException("Firmware realm is required"); + } + if (!isRealmActiveAndAccessible(realm)) { + throw new ForbiddenException("Realm '" + realm + "' is nonexistent, inactive or inaccessible"); + } + if (!Objects.equals(realm, hawkbitFirmwareService.getRealm())) { + throw new ForbiddenException("Firmware management is not available for this realm"); + } + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/RolloutResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/RolloutResourceImpl.java new file mode 100644 index 0000000..7717de3 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/RolloutResourceImpl.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.RolloutResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class RolloutResourceImpl extends HawkbitWebResource + implements RolloutResource { + + public RolloutResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response getRollouts(RequestParams requestParams, String realm, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + // Request full representation so totalTargetsPerStatus and totalGroups are populated; hawkBit defaults to compact. + return HawkbitResponseProxy.proxy("Failed to retrieve firmware rollouts", + () -> hawkbitFirmwareService.rollouts().getRollouts(offset, limit, "full")); + } + + @Override + public Response getRollout(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().get(id)); + } + + @Override + public Response createRollout(RequestParams requestParams, String realm, JsonNode rollout) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware rollout", + () -> hawkbitFirmwareService.rollouts().create(rollout)); + } + + @Override + public Response deleteRollout(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().delete(id)); + } + + @Override + public Response startRollout(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to start firmware rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().start(id)); + } + + @Override + public Response pauseRollout(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to pause firmware rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().pause(id)); + } + + @Override + public Response getRolloutGroups(RequestParams requestParams, String realm, Long id, + Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve groups for rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().getRolloutGroups(id, offset, limit, "full")); + } + + @Override + public Response getRolloutGroup(RequestParams requestParams, String realm, Long id, Long groupId) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve group '" + groupId + "' for rollout '" + id + "'", + () -> hawkbitFirmwareService.rollouts().getRolloutGroup(id, groupId)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleResourceImpl.java new file mode 100644 index 0000000..0b31644 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleResourceImpl.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.EntityPart; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataOutput; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.SoftwareModuleResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +import java.io.InputStream; +import java.util.List; + +public class SoftwareModuleResourceImpl extends HawkbitWebResource + implements SoftwareModuleResource { + + public SoftwareModuleResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response createSoftwareModule(RequestParams requestParams, + String realm, + JsonNode softwareModule) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware software module", + () -> hawkbitFirmwareService.softwareModules().create(softwareModule)); + } + + @Override + public Response getSoftwareModules(RequestParams requestParams, String realm, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware software modules", + () -> hawkbitFirmwareService.softwareModules().getSoftwareModules(offset, limit)); + } + + @Override + public Response getSoftwareModule(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware software module '" + id + "'", + () -> hawkbitFirmwareService.softwareModules().get(id)); + } + + @Override + public Response getSoftwareModuleArtifacts(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve artifacts for firmware software module '" + id + "'", + () -> hawkbitFirmwareService.softwareModules().getArtifacts(id)); + } + + @Override + public Response uploadSoftwareModuleArtifact(RequestParams requestParams, + String realm, + Long id, + String filename, + List parts) { + requireHawkbitRealmAccess(realm); + try { + EntityPart filePart = parts == null ? null : parts.stream() + .filter(part -> "file".equals(part.getName())) + .findFirst() + .orElse(null); + if (filePart == null) { + throw new WebApplicationException("Missing multipart field 'file'", Response.Status.BAD_REQUEST); + } + + String submittedFileName = filePart.getFileName().orElse(null); + InputStream inputStream = filePart.getContent(InputStream.class); + String effectiveFilename = filename == null || filename.isEmpty() ? submittedFileName : filename; + String uploadFilename = effectiveFilename == null || effectiveFilename.isEmpty() ? "artifact.bin" : effectiveFilename; + MultipartFormDataOutput form = new MultipartFormDataOutput(); + form.addFormData("file", inputStream, MediaType.APPLICATION_OCTET_STREAM_TYPE, uploadFilename); + return HawkbitResponseProxy.proxy("Failed to upload artifact for firmware software module '" + id + "'", + () -> hawkbitFirmwareService.softwareModules().uploadArtifact(id, + effectiveFilename == null || effectiveFilename.isEmpty() ? null : effectiveFilename, + form)); + } catch (WebApplicationException e) { + throw e; + } catch (Exception e) { + throw new WebApplicationException("Failed to upload artifact for firmware software module '" + id + "'", + e, Response.Status.BAD_GATEWAY); + } + } + + @Override + public Response deleteSoftwareModule(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware software module '" + id + "'", + () -> hawkbitFirmwareService.softwareModules().delete(id)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleTypeResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleTypeResourceImpl.java new file mode 100644 index 0000000..b64852d --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/SoftwareModuleTypeResourceImpl.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.SoftwareModuleTypeResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class SoftwareModuleTypeResourceImpl extends HawkbitWebResource + implements SoftwareModuleTypeResource { + + public SoftwareModuleTypeResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response createSoftwareModuleType(RequestParams requestParams, + String realm, + JsonNode softwareModuleType) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware software module type", + () -> hawkbitFirmwareService.softwareModuleTypes().create(softwareModuleType)); + } + + @Override + public Response getSoftwareModuleTypes(RequestParams requestParams, String realm, Integer offset, + Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware software module types", + () -> hawkbitFirmwareService.softwareModuleTypes().getSoftwareModuleTypes(offset, limit)); + } + + @Override + public Response getSoftwareModuleType(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware software module type '" + id + "'", + () -> hawkbitFirmwareService.softwareModuleTypes().get(id)); + } + + @Override + public Response deleteSoftwareModuleType(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware software module type '" + id + "'", + () -> hawkbitFirmwareService.softwareModuleTypes().delete(id)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetFilterResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetFilterResourceImpl.java new file mode 100644 index 0000000..fad9541 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetFilterResourceImpl.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.TargetFilterResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class TargetFilterResourceImpl extends HawkbitWebResource + implements TargetFilterResource { + + public TargetFilterResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response getTargetFilters(RequestParams requestParams, String realm, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware target filters", + () -> hawkbitFirmwareService.targetFilters().getTargetFilters(offset, limit)); + } + + @Override + public Response getTargetFilter(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware target filter '" + id + "'", + () -> hawkbitFirmwareService.targetFilters().get(id)); + } + + @Override + public Response createTargetFilter(RequestParams requestParams, + String realm, + JsonNode filter) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to create firmware target filter", + () -> hawkbitFirmwareService.targetFilters().create(filter)); + } + + @Override + public Response deleteTargetFilter(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware target filter '" + id + "'", + () -> hawkbitFirmwareService.targetFilters().delete(id)); + } + + @Override + public Response getAutoAssignDS(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve auto assign distribution set for filter '" + id + "'", + () -> hawkbitFirmwareService.targetFilters().getAutoAssignDS(id)); + } + + @Override + public Response setAutoAssignDS(RequestParams requestParams, String realm, Long id, + JsonNode request) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to set auto assign distribution set for filter '" + id + "'", + () -> hawkbitFirmwareService.targetFilters().setAutoAssignDS(id, request)); + } + + @Override + public Response deleteAutoAssignDS(RequestParams requestParams, String realm, Long id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to remove auto assign distribution set from filter '" + id + "'", + () -> hawkbitFirmwareService.targetFilters().deleteAutoAssignDS(id)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetResourceImpl.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetResourceImpl.java new file mode 100644 index 0000000..8d3983e --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/manager/resource/TargetResourceImpl.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager.resource; + +import jakarta.ws.rs.core.Response; +import org.openremote.container.timer.TimerService; +import org.openremote.extension.hawkbit.manager.HawkbitFirmwareService; +import org.openremote.extension.hawkbit.manager.HawkbitResponseProxy; +import org.openremote.extension.hawkbit.model.resource.TargetResource; +import org.openremote.manager.security.ManagerIdentityService; +import org.openremote.model.http.RequestParams; + +public class TargetResourceImpl extends HawkbitWebResource implements TargetResource { + + public TargetResourceImpl(TimerService timerService, ManagerIdentityService identityService, + HawkbitFirmwareService hawkbitFirmwareService) { + super(timerService, identityService, hawkbitFirmwareService); + } + + @Override + public Response getTargets(RequestParams requestParams, String realm, String query, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware targets", + () -> hawkbitFirmwareService.targets().getTargets(query, offset, limit)); + } + + @Override + public Response getTarget(RequestParams requestParams, String realm, String id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().get(id)); + } + + @Override + public Response deleteTarget(RequestParams requestParams, String realm, String id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to delete firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().delete(id)); + } + + @Override + public Response getMetadata(RequestParams requestParams, String realm, String id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve metadata for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getMetadata(id)); + } + + @Override + public Response getAssignedDs(RequestParams requestParams, String realm, String id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve assigned DS for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getAssignedDs(id)); + } + + @Override + public Response getInstalledDs(RequestParams requestParams, String realm, String id) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve installed DS for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getInstalledDs(id)); + } + + @Override + public Response getActions(RequestParams requestParams, String realm, String id, Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy("Failed to retrieve actions for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getActions(id, offset, limit)); + } + + @Override + public Response getAction(RequestParams requestParams, String realm, String id, Long actionId) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve action '" + actionId + "' for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getAction(id, actionId)); + } + + @Override + public Response getActionStatus(RequestParams requestParams, String realm, String id, Long actionId, + Integer offset, Integer limit) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to retrieve status for action '" + actionId + "' on firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().getActionStatus(id, actionId, offset, limit)); + } + + @Override + public Response cancelAction(RequestParams requestParams, String realm, String id, Long actionId, Boolean force) { + requireHawkbitRealmAccess(realm); + return HawkbitResponseProxy.proxy( + "Failed to cancel action '" + actionId + "' for firmware target '" + id + "'", + () -> hawkbitFirmwareService.targets().cancelAction(id, actionId, force)); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareMetaItemType.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareMetaItemType.java new file mode 100644 index 0000000..0b1351a --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareMetaItemType.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model; + +import org.openremote.model.util.TsIgnore; +import org.openremote.model.value.MetaItemDescriptor; +import org.openremote.model.value.ValueType; + +@TsIgnore +public final class FirmwareMetaItemType { + + /** + * Can be used on a {@link ValueType#TEXT} attribute to indicate that the parent asset should be synced as a + * firmware target to hawkBit, enabling firmware updates and DDI interactions. The attribute value will be + * updated with target details from hawkBit. + */ + public static final MetaItemDescriptor FIRMWARE_TARGET = new MetaItemDescriptor<>( + "firmwareTarget", ValueType.BOOLEAN); + /** + * Can be used on any attribute to indicate that this attribute should be synced as metadata to the corresponding + * target in hawkBit, enabling hawkBit target filters on specific metadata values. + */ + public static final MetaItemDescriptor FIRMWARE_METADATA = new MetaItemDescriptor<>( + "firmwareMetadata", ValueType.BOOLEAN); + + private FirmwareMetaItemType() { + } + +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareModelProvider.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareModelProvider.java new file mode 100644 index 0000000..1a19d27 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/FirmwareModelProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model; + +import org.openremote.model.AssetModelProvider; +import org.openremote.model.asset.Asset; +import org.openremote.model.value.MetaItemDescriptor; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class FirmwareModelProvider implements AssetModelProvider { + + @Override + public boolean useAutoScan() { + return false; + } + + @Override + public Map>> getMetaItemDescriptors() { + Collection> descriptors = List.of( + FirmwareMetaItemType.FIRMWARE_TARGET, + FirmwareMetaItemType.FIRMWARE_METADATA); + return Map.of(Asset.class.getSimpleName(), descriptors); + } +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/MetadataUpdateRequest.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/MetadataUpdateRequest.java new file mode 100644 index 0000000..a972c17 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/MetadataUpdateRequest.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.hawkbit; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record MetadataUpdateRequest(String value) { +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/Target.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/Target.java new file mode 100644 index 0000000..942901e --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/Target.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.hawkbit; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record Target( + String controllerId, + String name, + String description, + String securityToken, + String updateStatus, + Long lastControllerRequestAt, + Long installedAt, + String ipAddress, + String address, + Boolean requestAttributes, + Long targetType, + String targetTypeName, + Boolean autoConfirmActive) { +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetCreateRequest.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetCreateRequest.java new file mode 100644 index 0000000..36e83a3 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetCreateRequest.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.hawkbit; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record TargetCreateRequest(String controllerId, String name, String description, String securityToken) { +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetUpdateRequest.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetUpdateRequest.java new file mode 100644 index 0000000..2c86f69 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/hawkbit/TargetUpdateRequest.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.hawkbit; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record TargetUpdateRequest(String name, String description, String securityToken) { +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetResource.java new file mode 100644 index 0000000..1f2e250 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetResource.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API distribution-set endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitDistributionSetsClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Distribution Sets", description = "Management of firmware distribution sets") +@Path("firmware/distributionset") +public interface DistributionSetResource { + + /** + * Create a distribution set. Body is a hawkBit DistributionSet create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createDistributionSet(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode distributionSet); + + /** + * Assign a distribution set to one or more firmware targets. + *

+ * {@code targets} must be a non-empty JSON array. Empty or non-array bodies + * return {@code 400}. + *

+ * {@code offline=true} records the assignment as already-installed and skips + * the controller download step. Use this when the target was flashed out-of-band. + */ + @POST + @Path("{id}/assign") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response assignDistributionSet(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @QueryParam("offline") Boolean offline, + JsonNode targets); + + /** + * Retrieve all distribution sets, paged. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getDistributionSets(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single distribution set by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getDistributionSet(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); + + /** + * Delete a distribution set. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteDistributionSet(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetTypeResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetTypeResource.java new file mode 100644 index 0000000..92ef77f --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/DistributionSetTypeResource.java @@ -0,0 +1,114 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API distribution-set-type endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitDistributionSetTypesClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Distribution Set Types", description = "Management of firmware distribution set types") +@Path("firmware/distributionsettype") +public interface DistributionSetTypeResource { + + /** + * Create a distribution-set type. Body is a hawkBit DistributionSetType create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createDistributionSetType(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode distributionSetType); + + /** + * Retrieve all distribution-set types, paged. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getDistributionSetTypes(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single distribution-set type by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getDistributionSetType(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Retrieve the module types every DS of this type must include. + *

+ * Describes the composition contract of the DS type, not the modules of any + * specific distribution set. + */ + @GET + @Path("{id}/mandatorymoduletypes") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getMandatoryModuleTypes(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve the module types that a DS of this type may optionally include. + *

+ * Describes the composition contract of the DS type, not the modules + * of any specific distribution set. See {@link #getMandatoryModuleTypes}. + */ + @GET + @Path("{id}/optionalmoduletypes") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getOptionalModuleTypes(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Delete a distribution-set type. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteDistributionSetType(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/RolloutResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/RolloutResource.java new file mode 100644 index 0000000..4e1b19e --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/RolloutResource.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API rollout endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitRolloutsClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Rollouts", description = "Management of firmware rollouts") +@Path("firmware/rollout") +public interface RolloutResource { + + /** + * Retrieve all rollouts, paged. + *

+ * Forces {@code representation=full} so {@code totalTargetsPerStatus} and + * {@code totalGroups} are populated. hawkBit's default is {@code compact} + * which omits both. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getRollouts(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single rollout by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getRollout(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Create a rollout. Body matches hawkBit's Rollout create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createRollout(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode rollout); + + /** + * Delete a rollout. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteRollout(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Start a rollout. + */ + @POST + @Path("{id}/start") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response startRollout(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Pause a running rollout. + */ + @POST + @Path("{id}/pause") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response pauseRollout(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Retrieve deployment groups for a rollout, paged. + *

+ * Forces {@code representation=full} so per-group status counters are populated. + */ + @GET + @Path("{id}/deploygroups") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getRolloutGroups(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single deployment group within a rollout. + */ + @GET + @Path("{id}/deploygroups/{groupId}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getRolloutGroup(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @PathParam("groupId") Long groupId); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleResource.java new file mode 100644 index 0000000..fa368c9 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleResource.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.EntityPart; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import java.util.List; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static jakarta.ws.rs.core.MediaType.MULTIPART_FORM_DATA; + +/** + * Proxies the hawkBit Management API software-module endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitSoftwareModulesClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Software Modules", description = "Management of firmware software modules") +@Path("firmware/softwaremodule") +public interface SoftwareModuleResource { + + /** + * Create a software module. Body matches hawkBit's SoftwareModule create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createSoftwareModule(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode softwareModule); + + /** + * Retrieve all software modules, paged. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getSoftwareModules(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single software module by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getSoftwareModule(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); + + /** + * Retrieve all artifacts attached to a software module. + */ + @GET + @Path("{id}/artifacts") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getSoftwareModuleArtifacts(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); + + /** + * Upload an artifact file to a software module. + *

+ * Multipart body must include a part named {@code "file"}. Requests without it + * return {@code 400}. The {@code filename} query parameter overrides the + * filename from the multipart part. If neither is set the upload falls back + * to {@code "artifact.bin"}. + */ + @POST + @Path("{id}/artifacts") + @Consumes(MULTIPART_FORM_DATA) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response uploadSoftwareModuleArtifact(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + @QueryParam("filename") String filename, + List parts); + + /** + * Delete a software module. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteSoftwareModule(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); + +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleTypeResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleTypeResource.java new file mode 100644 index 0000000..6ff0aa2 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/SoftwareModuleTypeResource.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API software-module-type endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitSoftwareModuleTypesClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Software Module Types", description = "Management of firmware software module types") +@Path("firmware/softwaremoduletype") +public interface SoftwareModuleTypeResource { + + /** + * Create a software-module type. Body matches hawkBit's SoftwareModuleType create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createSoftwareModuleType(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode softwareModuleType); + + /** + * Retrieve all software-module types, paged. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getSoftwareModuleTypes(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single software-module type by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getSoftwareModuleType(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Delete a software-module type. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteSoftwareModuleType(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetFilterResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetFilterResource.java new file mode 100644 index 0000000..50142d3 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetFilterResource.java @@ -0,0 +1,121 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API target-filter endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitTargetFiltersClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Target Filters", description = "Management of firmware target filter queries") +@Path("firmware/targetfilter") +public interface TargetFilterResource { + + /** + * Retrieve all target filter queries, paged. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getTargetFilters(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single target filter query by id. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getTargetFilter(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Create a target filter query. Body matches hawkBit's TargetFilterQuery create payload. + */ + @POST + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response createTargetFilter(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + JsonNode filter); + + /** + * Delete a target filter query. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteTargetFilter(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Retrieve the auto-assign distribution set configured for a target filter. + */ + @GET + @Path("{id}/autoAssignDS") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getAutoAssignDS(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); + + /** + * Configure the auto-assign distribution set for a target filter. + *

+ * Body matches hawkBit's {@code AutoAssignDistributionSetRequest} shape + * (distribution-set {@code id} plus optional {@code actionType}). + */ + @POST + @Path("{id}/autoAssignDS") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response setAutoAssignDS(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id, + JsonNode request); + + /** + * Remove the auto-assign distribution set from a target filter. + */ + @DELETE + @Path("{id}/autoAssignDS") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteAutoAssignDS(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") Long id); +} diff --git a/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetResource.java b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetResource.java new file mode 100644 index 0000000..45aff07 --- /dev/null +++ b/hawkbit/src/main/java/org/openremote/extension/hawkbit/model/resource/TargetResource.java @@ -0,0 +1,162 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.model.resource; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; +import org.openremote.model.Constants; +import org.openremote.model.http.RequestParams; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +/** + * Proxies the hawkBit Management API target endpoints. + *

+ * Delegates to {@link org.openremote.extension.hawkbit.manager.hawkbit.HawkbitTargetsClient} + * and returns the upstream response body unchanged. + */ +@Tag(name = "Firmware Targets", description = "Management of firmware targets") +@Path("firmware/target") +public interface TargetResource { + + /** + * Retrieve firmware targets, paged. + *

+ * {@code q} is a hawkBit RSQL filter (e.g. {@code name==foo}), not free-text search. + */ + @GET + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getTargets(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @QueryParam("q") String query, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single firmware target by controllerId. + */ + @GET + @Path("{id}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getTarget(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") String id); + + /** + * Delete a firmware target by controllerId. + */ + @DELETE + @Path("{id}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response deleteTarget(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") String id); + + /** + * Retrieve all metadata key/value pairs for a firmware target. + */ + @GET + @Path("{id}/metadata") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getMetadata(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") String id); + + /** + * Retrieve the distribution set currently assigned to a firmware target. + *

+ * "Assigned" is the DS the server has scheduled. See {@link #getInstalledDs} + * for what the target has confirmed installed. + */ + @GET + @Path("{id}/assignedDS") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getAssignedDs(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") String id); + + /** + * Retrieve the distribution set currently reported as installed on a firmware target. + *

+ * "Installed" reflects the target's last confirmation. See {@link #getAssignedDs} + * for what the server has scheduled. + */ + @GET + @Path("{id}/installedDS") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getInstalledDs(@BeanParam RequestParams requestParams, @QueryParam("realm") String realm, @PathParam("id") String id); + + /** + * Retrieve the action history for a firmware target, paged. + */ + @GET + @Path("{id}/actions") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getActions(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") String id, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Retrieve a single action for a firmware target. + */ + @GET + @Path("{id}/actions/{actionId}") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getAction(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") String id, + @PathParam("actionId") Long actionId); + + /** + * Retrieve the status history for a single action, paged. + *

+ * Each entry carries the {@code messages} the controller reported over the + * DDI feedback channel (e.g. OTA progress or failure detail), plus the + * reported status code. + */ + @GET + @Path("{id}/actions/{actionId}/status") + @Produces(APPLICATION_JSON) + @RolesAllowed({Constants.READ_ADMIN_ROLE}) + Response getActionStatus(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") String id, + @PathParam("actionId") Long actionId, + @QueryParam("offset") Integer offset, + @QueryParam("limit") Integer limit); + + /** + * Cancel an in-flight action on a firmware target. + *

+ * {@code force=true} bypasses the cancel-confirmation handshake with the + * controller. The target stays in an unknown state until its next poll. + */ + @DELETE + @Path("{id}/actions/{actionId}") + @RolesAllowed({Constants.WRITE_ADMIN_ROLE}) + Response cancelAction(@BeanParam RequestParams requestParams, + @QueryParam("realm") String realm, + @PathParam("id") String id, + @PathParam("actionId") Long actionId, + @QueryParam("force") Boolean force); +} diff --git a/hawkbit/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider b/hawkbit/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider new file mode 100644 index 0000000..ac20c07 --- /dev/null +++ b/hawkbit/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider @@ -0,0 +1 @@ +org.openremote.extension.hawkbit.model.FirmwareModelProvider diff --git a/hawkbit/src/main/resources/META-INF/services/org.openremote.model.ContainerService b/hawkbit/src/main/resources/META-INF/services/org.openremote.model.ContainerService new file mode 100644 index 0000000..b99fe38 --- /dev/null +++ b/hawkbit/src/main/resources/META-INF/services/org.openremote.model.ContainerService @@ -0,0 +1 @@ +org.openremote.extension.hawkbit.manager.HawkbitFirmwareService diff --git a/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitFirmwareServiceTest.groovy b/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitFirmwareServiceTest.groovy new file mode 100644 index 0000000..d40868a --- /dev/null +++ b/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitFirmwareServiceTest.groovy @@ -0,0 +1,303 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager + +import jakarta.ws.rs.core.Response +import org.openremote.extension.hawkbit.manager.hawkbit.HawkbitTargetsClient +import org.openremote.extension.hawkbit.model.FirmwareMetaItemType +import org.openremote.extension.hawkbit.model.hawkbit.MetadataUpdateRequest +import org.openremote.extension.hawkbit.model.hawkbit.Target +import org.openremote.extension.hawkbit.model.hawkbit.TargetCreateRequest +import org.openremote.extension.hawkbit.model.hawkbit.TargetUpdateRequest +import org.openremote.model.asset.Asset +import org.openremote.model.asset.AssetEvent +import org.openremote.model.attribute.* +import org.openremote.test.ManagerContainerTrait +import spock.lang.Specification + +import static org.openremote.model.value.ValueType.TEXT + +class HawkbitFirmwareServiceTest extends Specification implements ManagerContainerTrait { + + private static final String CONTROLLER_ID = "test-asset-id" + + /** + * Subclass that bypasses asset type descriptor lookup so asset sync logic can be tested + * with any asset type. + */ + static class TestableHawkbitFirmwareService extends HawkbitFirmwareService { + TestableHawkbitFirmwareService() { + hawkbitRealm = "test-realm" + } + + Optional getTargetInfoAttributeName(Asset asset) { + return Optional.of("firmwareTarget") + } + } + + def "getTargetInfoAttributeName rejects multiple marked attributes"() { + given: "an asset with multiple attributes marked as firmware target" + def service = new HawkbitFirmwareService() + def meta = new MetaMap() + meta.put(new MetaItem<>(FirmwareMetaItemType.FIRMWARE_TARGET, true)) + + def asset = Mock(Asset) + asset.getType() >> "unknown:asset:type" + asset.getAttributes() >> new AttributeMap([ + new Attribute<>("firmwareTargetInfo", TEXT).setMeta(meta), + new Attribute<>("otherFirmwareTargetInfo", TEXT).setMeta(meta) + ]) + + expect: "ambiguous firmware target info attributes are ignored" + service.getTargetInfoAttributeName(asset) == Optional.empty() + } + + def "handleAssetChange with CREATE cause creates target when it does not exist"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + def createdTarget = new Target(CONTROLLER_ID, null, null, "token", null, null, null, null, null, null, null, null, null) + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + asset.getAttributes() >> new AttributeMap() + + when: "handling an asset CREATE event" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.CREATE, asset)) + + then: "hawkBit checks (404), creates, then uses the created target to update target info" + 1 * service.targets.get(CONTROLLER_ID) >> Response.status(Response.Status.NOT_FOUND).build() + 1 * service.targets.create({ TargetCreateRequest[] targets -> + targets.length == 1 && targets[0].securityToken() == null + }) >> Response.ok(new Target[]{createdTarget}).build() + } + + def "handleAssetChange with CREATE cause skips create when target already exists"() { + given: "a service with mocked targets client returning an existing target" + def service = new TestableHawkbitFirmwareService() + def existingTarget = new Target(CONTROLLER_ID, null, null, "token", null, null, null, null, null, null, null, null, null) + service.targets = Mock(HawkbitTargetsClient) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + asset.getAttributes() >> new AttributeMap() + + when: "handling an asset CREATE event for an already-existing target" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.CREATE, asset)) + + then: "hawkBit is queried but create is not called" + 1 * service.targets.get(CONTROLLER_ID) >> Response.ok(existingTarget).build() + 0 * service.targets.create(_) + } + + def "handleAssetChange with UPDATE cause skips create when target already exists"() { + given: "a service with mocked targets client returning an existing target" + def service = new TestableHawkbitFirmwareService() + def existingTarget = new Target(CONTROLLER_ID, null, null, "token", null, null, null, null, null, null, null, null, null) + service.targets = Mock(HawkbitTargetsClient) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + asset.getAttributes() >> new AttributeMap() + + when: "handling an asset UPDATE event" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.UPDATE, asset)) + + then: "hawkBit is queried but create is not called" + 1 * service.targets.get(CONTROLLER_ID) >> Response.ok(existingTarget).build() + 0 * service.targets.create(_) + } + + def "handleAssetChange with UPDATE cause creates target when it is missing"() { + given: "a service with mocked targets client returning 404 then creating the target" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + def createdTarget = new Target(CONTROLLER_ID, null, null, "token", null, null, null, null, null, null, null, null, null) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + asset.getAttributes() >> new AttributeMap() + + when: "handling an asset UPDATE event for a missing target" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.UPDATE, asset)) + + then: "hawkBit queries (404), creates, then uses the created target for info update" + 1 * service.targets.get(CONTROLLER_ID) >> Response.status(Response.Status.NOT_FOUND).build() + 1 * service.targets.create({ TargetCreateRequest[] targets -> + targets.length == 1 && targets[0].securityToken() == null + }) >> Response.ok(new Target[]{createdTarget}).build() + } + + def "handleAssetChange with UPDATE cause logs warning when getTarget throws exception"() { + given: "a service with mocked targets client that throws" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + + when: "handling an asset UPDATE event when hawkBit query fails" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.UPDATE, asset)) + + then: "hawkBit is queried but throws, no create is attempted" + 1 * service.targets.get(CONTROLLER_ID) >> { throw new RuntimeException("connection failed") } + 0 * service.targets.create(_) + 0 * service.targets.delete(_) + } + + def "createTarget with security token forwards token in create request"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + def createdTarget = new Target(CONTROLLER_ID, null, null, "custom-token", null, null, null, null, null, null, null, null, null) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getAssetType() >> "test:asset:type" + asset.getRealm() >> "test-realm" + + when: "creating a target with a custom security token" + def result = service.createTarget(asset, "custom-token") + + then: "the token is forwarded to hawkBit" + 1 * service.targets.create({ TargetCreateRequest[] targets -> + targets.length == 1 && + targets[0].controllerId() == CONTROLLER_ID && + targets[0].securityToken() == "custom-token" + }) >> Response.ok(new Target[]{createdTarget}).build() + result == createdTarget + } + + def "createUpdateTarget updates token when target exists"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + def existingTarget = new Target(CONTROLLER_ID, null, null, "old-token", null, null, null, null, null, null, null, null, null) + def updatedTarget = new Target(CONTROLLER_ID, null, null, "new-token", null, null, null, null, null, null, null, null, null) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getAssetType() >> "test:asset:type" + asset.getRealm() >> "test-realm" + + when: "creating or updating a target with a custom security token" + def result = service.createUpdateTarget(asset, "new-token") + + then: "the existing target is updated with the new token" + 1 * service.targets.get(CONTROLLER_ID) >> Response.ok(existingTarget).build() + 1 * service.targets.update(CONTROLLER_ID, { TargetUpdateRequest target -> + target.securityToken() == "new-token" + }) >> Response.ok(updatedTarget).build() + 0 * service.targets.create(_) + result == updatedTarget + } + + def "handleAssetChange with DELETE cause deletes target"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + + def asset = Mock(Asset) + asset.getId() >> CONTROLLER_ID + asset.getRealm() >> "test-realm" + + when: "handling an asset DELETE event" + service.handleAssetChange(new AssetEvent(AssetEvent.Cause.DELETE, asset)) + + then: "the delete endpoint is invoked" + 1 * service.targets.delete(CONTROLLER_ID) >> Response.ok().build() + } + + def "handleAttributeChange returns early when attribute has no firmware metadata"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) + + when: "handling an attribute event without firmware metadata" + service.handleAttributeChange(new AttributeEvent(CONTROLLER_ID, "temp", 25)) + + then: "no hawkBit calls are made" + 0 * service.targets.updateMetadata(_, _, _) + 0 * service.targets.deleteMetadata(_, _) + } + + def "handleAttributeChange deletes metadata when attribute event is marked deleted"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) { + deleteMetadata(_ as String, _ as String) >> Response.ok().build() + } + def meta = new MetaMap() + meta.put(new MetaItem<>(FirmwareMetaItemType.FIRMWARE_METADATA, true)) + + def event = new AttributeEvent(CONTROLLER_ID, "temp", 25) + event.setMeta(meta) + event.setDeleted(true) + + when: "handling a deleted attribute event with firmware metadata" + service.handleAttributeChange(event) + + then: "deleteMetadata is called and updateMetadata is not" + 1 * service.targets.deleteMetadata(CONTROLLER_ID, "temp") + 0 * service.targets.updateMetadata(_, _, _) + } + + def "handleAttributeChange updates metadata when attribute has firmware metadata"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) { + updateMetadata(_ as String, _ as String, _ as MetadataUpdateRequest) >> Response.ok().build() + } + def meta = new MetaMap() + meta.put(new MetaItem<>(FirmwareMetaItemType.FIRMWARE_METADATA, true)) + + def event = new AttributeEvent(CONTROLLER_ID, "temp", 25) + event.setMeta(meta) + + when: "handling an attribute event with a metadata value" + service.handleAttributeChange(event) + + then: "updateMetadata is called with the string coerced value" + 1 * service.targets.updateMetadata(CONTROLLER_ID, "temp", _) + } + + def "handleAttributeChange deletes metadata when attribute value is empty"() { + given: "a service with mocked targets client" + def service = new TestableHawkbitFirmwareService() + service.targets = Mock(HawkbitTargetsClient) { + deleteMetadata(_ as String, _ as String) >> Response.ok().build() + } + def meta = new MetaMap() + meta.put(new MetaItem<>(FirmwareMetaItemType.FIRMWARE_METADATA, true)) + + def event = new AttributeEvent(CONTROLLER_ID, "temp", "") + event.setMeta(meta) + + when: "handling an attribute event with an empty value" + service.handleAttributeChange(event) + + then: "deleteMetadata is called" + 1 * service.targets.deleteMetadata(CONTROLLER_ID, "temp") + } +} diff --git a/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitResponseProxyTest.groovy b/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitResponseProxyTest.groovy new file mode 100644 index 0000000..4dfadfb --- /dev/null +++ b/hawkbit/src/test/groovy/org/openremote/extension/hawkbit/manager/HawkbitResponseProxyTest.groovy @@ -0,0 +1,158 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.hawkbit.manager + +import com.fasterxml.jackson.databind.JsonNode +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.openremote.model.util.ValueUtil +import spock.lang.Specification + +class HawkbitResponseProxyTest extends Specification { + + def "preserves JSON response body and media type"() { + given: "a hawkBit HAL resource response" + def upstream = jsonResponse(''' + { + "id": 42, + "name": "target-a", + "nested": { + "enabled": true, + "_links": { + "self": {"href": "http://hawkbit/rest/v1/nested/7"} + } + }, + "_links": { + "self": {"href": "http://hawkbit/rest/v1/targets/42"} + }, + "_embedded": { + "ignored": [{"id": 1}] + } + } + ''') + + when: "the response is copied" + def formatted = HawkbitResponseProxy.proxy("Failed to call hawkBit", { upstream }) + def body = readJson(formatted) + + then: "the original JSON body and media type are preserved" + formatted.status == 200 + formatted.mediaType == MediaType.APPLICATION_JSON_TYPE + body.path("id").asInt() == 42 + body.path("name").asText() == "target-a" + body.path("nested").path("enabled").asBoolean() + body.path("_links").path("self").path("href").asText() == "http://hawkbit/rest/v1/targets/42" + body.path("_embedded").path("ignored").size() == 1 + body.path("nested").path("_links").path("self").path("href").asText() == "http://hawkbit/rest/v1/nested/7" + } + + def "preserves embedded HAL collection response"() { + given: "a hawkBit HAL collection response" + def upstream = jsonResponse(''' + { + "_embedded": { + "targets": [ + {"controllerId": "target-a", "_links": {"self": {"href": "http://hawkbit/rest/v1/targets/target-a"}}}, + {"controllerId": "target-b", "_embedded": {"ignored": []}} + ] + }, + "page": { + "totalElements": 10, + "size": 2 + } + } + ''') + + when: "the response is copied" + def body = readJson(HawkbitResponseProxy.proxy("Failed to call hawkBit", { upstream })) + + then: "embedded items and page metadata stay in hawkBit's shape" + body.path("_embedded").path("targets").size() == 2 + body.path("_embedded").path("targets").get(0).path("controllerId").asText() == "target-a" + body.path("_embedded").path("targets").get(0).path("_links").path("self").path("href").asText() == "http://hawkbit/rest/v1/targets/target-a" + body.path("page").path("totalElements").asInt() == 10 + body.path("page").path("size").asInt() == 2 + } + + def "preserves non-JSON body and media type"() { + given: "an upstream response with a non-JSON body" + def upstream = Response.status(Response.Status.BAD_GATEWAY) + .type(MediaType.TEXT_PLAIN_TYPE) + .entity("upstream unavailable") + .build() + + when: "the response is copied" + def formatted = HawkbitResponseProxy.proxy("Failed to call hawkBit", { upstream }) + + then: "the original body and media type are preserved" + formatted.status == Response.Status.BAD_GATEWAY.statusCode + formatted.mediaType == MediaType.TEXT_PLAIN_TYPE + formatted.readEntity(String) == "upstream unavailable" + } + + def "returns empty response when no entity"() { + given: "an upstream response without an entity" + def upstream = Response.noContent().build() + + when: "the response is copied" + def formatted = HawkbitResponseProxy.proxy("Failed to call hawkBit", { upstream }) + + then: "the empty status is preserved" + formatted.status == Response.Status.NO_CONTENT.statusCode + !formatted.hasEntity() + } + + def "wraps checked exceptions as bad gateway"() { + when: "a hawkBit call throws an unexpected exception" + HawkbitResponseProxy.proxy("Failed to call hawkBit", { + throw new IOException("connection failed") + }) + + then: "the exception is wrapped as a bad gateway response" + def e = thrown(WebApplicationException) + e.response.status == Response.Status.BAD_GATEWAY.statusCode + e.message == "Failed to call hawkBit" + e.cause instanceof IOException + } + + def "rethrows web application exceptions unchanged"() { + given: "an existing web application exception" + def original = new WebApplicationException("not found", Response.Status.NOT_FOUND) + + when: "a hawkBit call throws it" + HawkbitResponseProxy.proxy("Failed to call hawkBit", { + throw original + }) + + then: "the original exception is rethrown" + def e = thrown(WebApplicationException) + e.is(original) + e.response.status == Response.Status.NOT_FOUND.statusCode + } + + private static Response jsonResponse(String body) { + Response.ok(body, MediaType.APPLICATION_JSON_TYPE).build() + } + + private static JsonNode readJson(Response response) { + ValueUtil.JSON.readTree(response.readEntity(String)) + } +}