From 57181492a0369196415b38a422920826c12139bd Mon Sep 17 00:00:00 2001 From: vasilchev Date: Wed, 12 Aug 2026 20:03:20 +0300 Subject: [PATCH] Feature Target Attributes Updated at. 2 columns added to target table: 1. 'last_controller_attributes_update' timestamp of last target attributes update; 2. 'last_controller_attributes_update_requested' boolean whether the update was requested from server after update finished(true) or initiated purely from device(false). Added configurable duration period for Devices that are able to update their attributes without server asked - i.e. initiated updates of attributes possible once each 12h Signed-off-by: vasilchev --- .../security/HawkbitSecurityProperties.java | 38 ++++++ .../HawkbitSecurityPropertiesTest.java | 44 +++++++ .../mgmt/json/model/target/MgmtTarget.java | 4 + .../resource/mapper/MgmtTargetMapper.java | 4 + .../rest/resource/MgmtTargetResourceTest.java | 17 +++ .../hawkbit/repository/model/Target.java | 5 + ...arget_controller_attributes_update__H2.sql | 2 + ...et_controller_attributes_update__MYSQL.sql | 3 + ...ntroller_attributes_update__POSTGRESQL.sql | 3 + .../management/JpaControllerManagement.java | 21 ++++ .../repository/jpa/model/JpaTarget.java | 10 ++ .../management/ControllerManagementTest.java | 108 ++++++++++++++++++ 12 files changed, 259 insertions(+) create mode 100644 hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java create mode 100644 hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java index 6fb7cc4f9e..7d912213be 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java @@ -9,9 +9,12 @@ */ package org.eclipse.hawkbit.security; +import java.time.Duration; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -124,6 +127,7 @@ public static class Dos { private final Filter filter = new Filter(); private final Filter uiFilter = new Filter(); + private final ControllerAttributes controllerAttributes = new ControllerAttributes(); /** * Maximum number of status updates that the controller can report for * an action (0 to disable). @@ -224,5 +228,39 @@ public static class Filter { */ private int maxWrite = 50; } + + /** + * Throttling of not-requested (device-initiated without actual update) controller attribute updates. + */ + @Data + public static class ControllerAttributes { + + /** + * Default minimum interval between accepted device-initiated attribute + * updates. ZERO (default) disables throttling for all tenants. + */ + private Duration minUpdateInterval = Duration.ZERO; + + /** + * Per-tenant overrides of {@link #minUpdateInterval}, keyed by tenant name + * (case-insensitive). Tenants without an entry use {@link #minUpdateInterval}. + */ + private Map perTenant = new HashMap<>(); + + /** + * @param tenant current tenant (may be {@code null}) + * @return the configured minimum interval for the tenant, or the default + */ + public Duration intervalFor(final String tenant) { + if (tenant != null) { + for (final Map.Entry entry : perTenant.entrySet()) { + if (entry.getKey().equalsIgnoreCase(tenant)) { + return entry.getValue(); + } + } + } + return minUpdateInterval; + } + } } } \ No newline at end of file diff --git a/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java b/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java new file mode 100644 index 0000000000..280cf4f09e --- /dev/null +++ b/hawkbit-core/src/test/java/org/eclipse/hawkbit/security/HawkbitSecurityPropertiesTest.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.eclipse.hawkbit.security.HawkbitSecurityProperties.Dos.ControllerAttributes; +import org.junit.jupiter.api.Test; + +class HawkbitSecurityPropertiesTest { + + @Test + void intervalForDefaultsToZeroWhenNothingConfigured() { + final ControllerAttributes props = new ControllerAttributes(); + assertThat(props.intervalFor("TENANT")).isEqualTo(Duration.ZERO); + assertThat(props.intervalFor(null)).isEqualTo(Duration.ZERO); + } + + @Test + void intervalForFallsBackToDefaultWhenTenantNotListed() { + final ControllerAttributes props = new ControllerAttributes(); + props.setMinUpdateInterval(Duration.ofMinutes(2)); + assertThat(props.intervalFor("UNLISTED")).isEqualTo(Duration.ofMinutes(2)); + } + + @Test + void intervalForResolvesPerTenantOverrideCaseInsensitively() { + final ControllerAttributes props = new ControllerAttributes(); + props.setMinUpdateInterval(Duration.ofMinutes(2)); + props.getPerTenant().put("Abusive", Duration.ofMinutes(5)); + assertThat(props.intervalFor("ABUSIVE")).isEqualTo(Duration.ofMinutes(5)); + assertThat(props.intervalFor("abusive")).isEqualTo(Duration.ofMinutes(5)); + assertThat(props.intervalFor("OTHER")).isEqualTo(Duration.ofMinutes(2)); + } +} diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java index 6d4d3c5177..17ab374d49 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java @@ -44,6 +44,7 @@ "controllerId" : "137", "updateStatus" : "in_sync", "lastControllerRequestAt" : 1682408577978, + "lastControllerAttributesUpdate" : 1682408577998, "installedAt" : 1682408577987, "ipAddress" : "192.168.0.1", "address" : "http://192.168.0.1", @@ -115,6 +116,9 @@ public class MgmtTarget extends MgmtNamedEntity { @Schema(description = "Timestamp of the last controller request", example = "1691065941102") private Long lastControllerRequestAt; + @Schema(description = "Timestamp of the last controller attributes (config data) update", example = "1691065941102") + private Long lastControllerAttributesUpdate; + @Schema(description = "Install timestamp", example = "1691065941155") private Long installedAt; diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java index 972950fd66..72bb853858 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java @@ -168,6 +168,10 @@ public static MgmtTarget toResponse(final Target target, final Function confirmationOptions() { return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true), Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null)); diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java index 85f7e02575..b09fa9eda8 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java @@ -78,6 +78,11 @@ public interface Target extends NamedEntity, Identifiable { */ Long getLastTargetQuery(); + /** + * @return timestamp (epoch millis) of the last accepted controller attributes update, or {@code null} if never updated + */ + Long getLastControllerAttributesUpdate(); + /** * @return time in {@link TimeUnit#MILLISECONDS} GMT when {@link #getInstalledDistributionSet()} was applied. */ diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql new file mode 100644 index 0000000000..62747faecb --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_20_5__target_controller_attributes_update__H2.sql @@ -0,0 +1,2 @@ +ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update BIGINT; +ALTER TABLE sp_target ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql new file mode 100644 index 0000000000..8b731385ae --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_20_5__target_controller_attributes_update__MYSQL.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_target + ADD COLUMN last_controller_attributes_update BIGINT, + ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql new file mode 100644 index 0000000000..8b731385ae --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_20_5__target_controller_attributes_update__POSTGRESQL.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_target + ADD COLUMN last_controller_attributes_update BIGINT, + ADD COLUMN last_controller_attributes_update_requested BOOLEAN; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java index d6b57f14c7..3c8c77587f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java @@ -48,6 +48,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.ListUtils; import org.eclipse.hawkbit.context.AccessContext; +import org.eclipse.hawkbit.security.HawkbitSecurityProperties; import org.eclipse.hawkbit.ql.jpa.QLSupport; import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; @@ -146,6 +147,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont private final ControllerPollProperties controllerPollProperties; private final PlatformTransactionManager txManager; private final EntityManager entityManager; + private final HawkbitSecurityProperties securityProperties; private final Duration minPollingTime; private final Duration maxPollingTime; @@ -162,6 +164,7 @@ protected JpaControllerManagement( final DistributionSetManagement distributionSetManagement, final ControllerPollProperties controllerPollProperties, final PlatformTransactionManager txManager, final EntityManager entityManager, + final HawkbitSecurityProperties securityProperties, final ScheduledExecutorService executorService) { super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties); @@ -175,6 +178,7 @@ protected JpaControllerManagement( this.controllerPollProperties = controllerPollProperties; this.txManager = txManager; this.entityManager = entityManager; + this.securityProperties = securityProperties; minPollingTime = controllerPollProperties.getMinPollingTime() == null ? Duration.of(0, ChronoUnit.SECONDS) @@ -442,6 +446,19 @@ public Target updateControllerAttributes(final String controllerId, final Map writeAttributes(controllerId, 1, "a", "v1")); + + // force it requested again (admin context) and update once more, still accepted + JpaTarget target = (JpaTarget) targetManagement.getByControllerId(controllerId); + target.setRequestControllerAttributes(true); + targetRepository.save(target); + runAs(controller, () -> writeAttributes(controllerId, 1, "b", "v2")); + + target = (JpaTarget) targetManagement.getByControllerId(controllerId); + assertThat(targetManagement.getControllerAttributes(controllerId)).containsKeys("a0", "b0"); + assertThat(target.getLastControllerAttributesUpdateRequested()).isTrue(); + assertThat(target.getLastControllerAttributesUpdate()).isNotNull(); + } + + /** + * Two consecutive device-initiated updates within the interval: the second is dropped + * (no write) and returns silently. + */ + @Test + void deviceInitiatedUpdateWithinIntervalIsDropped() { + final String controllerId = "throttled"; + testdataFactory.createTarget(controllerId); + securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1)); + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + // accepted - initially requestAttribute is true, internally requestAttribute is set to false, lastControllerAttributesUpdateRequested is set to true, lastControllerAttributesUpdate is set to now + writeAttributes(controllerId, 1, "initial", "v"); + // accepted - requestAttribute is false, but lastControllerAttributesUpdateRequested is true - i.e. this update is first to be initated from device -> lastControllerAttributesUpdateRequested is set to false, lastControllerAttributesUpdate is ste to now + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); + // rejected - requestAttribute is false and lastControllerAttributesUpdateRequested is false and timeout has not passed + writeAttributes(controllerId, 1, "second-device-initiated", "v2"); + }); + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0") + .doesNotContainKey("second-device-initiated0"); + } + + /** + * A device-initiated update after the interval has elapsed is accepted. + */ + @Test + void deviceInitiatedUpdateAfterIntervalIsAccepted() { + final String controllerId = "elapsed"; + testdataFactory.createTarget(controllerId); + securityProperties.getDos().getControllerAttributes().setMinUpdateInterval(Duration.ofHours(1)); + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + writeAttributes(controllerId, 1, "initial", "v"); // accepted - initialize requestAttribute is true for new devices + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); // device-initiated, accepted, stamps now + }); + + // backdate the stamp beyond the interval + final JpaTarget backdated = (JpaTarget) targetManagement.getByControllerId(controllerId); + backdated.setLastControllerAttributesUpdate(System.currentTimeMillis() - Duration.ofHours(2).toMillis()); + targetRepository.save(backdated); + + runAs(controller, () -> writeAttributes(controllerId, 1, "second-device-initiated0", "v2")); // device-initiated, elapsed -> accepted + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0", "second-device-initiated0"); + } + + /** + * With throttling disabled (default 0), rapid device-initiated updates are all accepted. + */ + @Test + void deviceInitiatedUpdatesNotThrottledWhenDisabled() { + final String controllerId = "disabled"; + testdataFactory.createTarget(controllerId); + // minUpdateInterval left at ZERO (default) + + final WithUser controller = SecurityContextSwitch.withController("controller"); + runAs(controller, () -> { + writeAttributes(controllerId, 1, "initial", "v"); + writeAttributes(controllerId, 1, "first-device-initiated", "v1"); + writeAttributes(controllerId, 1, "second-device-initiated", "v2"); + }); + + assertThat(targetManagement.getControllerAttributes(controllerId)) + .containsKeys("initial0", "first-device-initiated0", "second-device-initiated0"); + } }