301 changed files with 16986 additions and 12150 deletions
@ -1,15 +1,15 @@ |
|||||
#### Upgrading to ${TB_EDGE_VERSION}EDGE |
#### Upgrading to ${TB_EDGE_VERSION}EDGE |
||||
|
|
||||
**ThingsBoard Edge package download:** |
ThingsBoard Edge package download: |
||||
```bash |
```bash |
||||
wget https://github.com/thingsboard/thingsboard-edge/releases/download/v${TB_EDGE_TAG}/tb-edge-${TB_EDGE_TAG}.rpm |
wget https://github.com/thingsboard/thingsboard-edge/releases/download/v${TB_EDGE_TAG}/tb-edge-${TB_EDGE_TAG}.rpm |
||||
{:copy-code} |
{:copy-code} |
||||
``` |
``` |
||||
##### ThingsBoard Edge service upgrade |
|
||||
|
|
||||
Install package: |
Install package: |
||||
```bash |
```bash |
||||
sudo rpm -Uvh tb-edge-${TB_EDGE_TAG}.rpm |
sudo rpm -Uvh tb-edge-${TB_EDGE_TAG}.rpm |
||||
{:copy-code} |
{:copy-code} |
||||
``` |
``` |
||||
|
|
||||
${UPGRADE_DB} |
${UPGRADE_DB} |
||||
|
|||||
@ -1,10 +1,3 @@ |
|||||
#### Upgrading to ${TB_EDGE_VERSION} |
#### Upgrading to ${TB_EDGE_VERSION} |
||||
|
|
||||
Execute the following command to pull **${TB_EDGE_VERSION}** image: |
|
||||
|
|
||||
```bash |
|
||||
docker pull thingsboard/tb-edge:${TB_EDGE_VERSION} |
|
||||
{:copy-code} |
|
||||
``` |
|
||||
|
|
||||
${UPGRADE_DB} |
${UPGRADE_DB} |
||||
|
|||||
@ -1,15 +1,14 @@ |
|||||
#### Upgrading to ${TB_EDGE_VERSION}EDGE |
#### Upgrading to ${TB_EDGE_VERSION}EDGE |
||||
|
|
||||
**ThingsBoard Edge package download:** |
ThingsBoard Edge package download: |
||||
```bash |
```bash |
||||
wget https://github.com/thingsboard/thingsboard-edge/releases/download/v${TB_EDGE_TAG}/tb-edge-${TB_EDGE_TAG}.deb |
wget https://github.com/thingsboard/thingsboard-edge/releases/download/v${TB_EDGE_TAG}/tb-edge-${TB_EDGE_TAG}.deb |
||||
{:copy-code} |
{:copy-code} |
||||
``` |
``` |
||||
##### ThingsBoard Edge service upgrade |
|
||||
|
|
||||
Install package: |
Install package: |
||||
```bash |
```bash |
||||
sudo dpkg -i tb-edge-${TB_EDGE_TAG}.deb |
sudo dpkg -i tb-edge-${TB_EDGE_TAG}.deb |
||||
{:copy-code} |
{:copy-code} |
||||
``` |
``` |
||||
|
|
||||
${UPGRADE_DB} |
${UPGRADE_DB} |
||||
|
|||||
@ -0,0 +1,290 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.system; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.google.common.base.Charsets; |
||||
|
import com.google.common.hash.Hashing; |
||||
|
import com.google.common.io.Resources; |
||||
|
import jakarta.annotation.PostConstruct; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.common.util.ThingsBoardThreadFactory; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; |
||||
|
import org.thingsboard.server.dao.widget.WidgetTypeService; |
||||
|
import org.thingsboard.server.queue.util.TbCoreComponent; |
||||
|
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; |
||||
|
import org.thingsboard.server.service.install.InstallScripts; |
||||
|
import org.thingsboard.server.service.install.update.DefaultDataUpdateService; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
import java.io.UncheckedIOException; |
||||
|
import java.net.URL; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.NoSuchFileException; |
||||
|
import java.nio.file.Path; |
||||
|
import java.util.Objects; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
/** |
||||
|
* Runs at application startup and applies no-downtime data updates |
||||
|
* when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Component |
||||
|
@TbCoreComponent |
||||
|
@RequiredArgsConstructor |
||||
|
public class SystemPatchApplier { |
||||
|
|
||||
|
private static final String SCHEMA_VIEWS_SQL = "sql/schema-views.sql"; |
||||
|
|
||||
|
private static final long ADVISORY_LOCK_ID = 7536891047216478431L; |
||||
|
|
||||
|
private final JdbcTemplate jdbcTemplate; |
||||
|
private final InstallScripts installScripts; |
||||
|
private final DatabaseSchemaSettingsService schemaSettingsService; |
||||
|
private final WidgetTypeService widgetTypeService; |
||||
|
|
||||
|
@PostConstruct |
||||
|
private void init() { |
||||
|
ExecutorService executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("system-patch-applier")); |
||||
|
executor.submit(() -> { |
||||
|
try { |
||||
|
applyPatchIfNeeded(); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to apply system data patch updates", e); |
||||
|
} finally { |
||||
|
executor.shutdown(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private void applyPatchIfNeeded() { |
||||
|
boolean skipVersionCheck = DefaultDataUpdateService.getEnv("SKIP_PATCH_VERSION_CHECK", false); |
||||
|
if (!skipVersionCheck && !isVersionChanged()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!acquireAdvisoryLock()) { |
||||
|
log.trace("Could not acquire advisory lock. Another node is processing patch updates."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
updateSqlViews(); |
||||
|
log.info("Updated sql database views"); |
||||
|
|
||||
|
int updated = updateWidgetTypes(); |
||||
|
log.info("Updated {} widget types", updated); |
||||
|
|
||||
|
schemaSettingsService.updateSchemaVersion(); |
||||
|
log.info("System data patch update completed successfully"); |
||||
|
|
||||
|
} finally { |
||||
|
releaseAdvisoryLock(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private boolean isVersionChanged() { |
||||
|
String packageVersion = schemaSettingsService.getPackageSchemaVersion(); |
||||
|
String dbVersion = schemaSettingsService.getDbSchemaVersion(); |
||||
|
|
||||
|
log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion); |
||||
|
|
||||
|
VersionInfo packageVersionInfo = parseVersion(packageVersion); |
||||
|
VersionInfo dbVersionInfo = parseVersion(dbVersion); |
||||
|
|
||||
|
if (packageVersionInfo == null || dbVersionInfo == null) { |
||||
|
log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { |
||||
|
return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor |
||||
|
&& packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; |
||||
|
} |
||||
|
|
||||
|
private void updateSqlViews() { |
||||
|
try { |
||||
|
URL schemaViewsUrl = Resources.getResource(SCHEMA_VIEWS_SQL); |
||||
|
String sql = Resources.toString(schemaViewsUrl, Charsets.UTF_8); |
||||
|
jdbcTemplate.execute(sql); |
||||
|
} catch (IOException e) { |
||||
|
throw new RuntimeException("Unable to update database views from schema-views.sql", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private int updateWidgetTypes() { |
||||
|
AtomicInteger updated = new AtomicInteger(); |
||||
|
Path widgetTypesDir = installScripts.getWidgetTypesDir(); |
||||
|
|
||||
|
if (!Files.exists(widgetTypesDir)) { |
||||
|
log.trace("Widget types directory does not exist: {}", widgetTypesDir); |
||||
|
return 0; |
||||
|
} |
||||
|
|
||||
|
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { |
||||
|
dirStream.forEach( |
||||
|
path -> { |
||||
|
try { |
||||
|
if (updateWidgetTypeFromFile(path)) { |
||||
|
updated.incrementAndGet(); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
log.error("Unable to update widget type from json: [{}]", path.toString()); |
||||
|
throw new RuntimeException("Unable to update widget type from json", e); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return updated.get(); |
||||
|
} |
||||
|
|
||||
|
private boolean updateWidgetTypeFromFile(Path filePath) { |
||||
|
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); |
||||
|
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); |
||||
|
String fqn = fileWidgetType.getFqn(); |
||||
|
|
||||
|
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); |
||||
|
if (existingWidgetType == null) { |
||||
|
// We expect only update here, so it's probably never happening, but for test purpose leave it like this:
|
||||
|
throw new RuntimeException("Widget type not found: " + fqn); |
||||
|
} |
||||
|
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { |
||||
|
existingWidgetType.setDescription(fileWidgetType.getDescription()); |
||||
|
existingWidgetType.setName(fileWidgetType.getName()); |
||||
|
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); |
||||
|
widgetTypeService.saveWidgetType(existingWidgetType); |
||||
|
log.trace("Updated widget type: {}", fqn); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
log.trace("Widget type unchanged: {}", fqn); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { |
||||
|
if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (!Objects.equals(existing.getName(), file.getName())) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
return !Objects.equals(existing.getDescription(), file.getDescription()); |
||||
|
} |
||||
|
|
||||
|
private boolean isDescriptorEqual(JsonNode desc1, JsonNode desc2) { |
||||
|
if (desc1 == null && desc2 == null) { |
||||
|
return true; |
||||
|
} |
||||
|
if (desc1 == null || desc2 == null) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
String hash1 = computeChecksum(desc1); |
||||
|
String hash2 = computeChecksum(desc2); |
||||
|
return Objects.equals(hash1, hash2); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("Failed to compare descriptors using checksum, falling back to equals", e); |
||||
|
return desc1.equals(desc2); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String computeChecksum(JsonNode node) { |
||||
|
String canonicalString = JacksonUtil.toCanonicalString(node); |
||||
|
if (canonicalString == null) { |
||||
|
return null; |
||||
|
} |
||||
|
return Hashing.sha256().hashBytes(canonicalString.getBytes()).toString(); |
||||
|
} |
||||
|
|
||||
|
private boolean acquireAdvisoryLock() { |
||||
|
try { |
||||
|
Boolean acquired = jdbcTemplate.queryForObject( |
||||
|
"SELECT pg_try_advisory_lock(?)", |
||||
|
Boolean.class, |
||||
|
ADVISORY_LOCK_ID |
||||
|
); |
||||
|
if (Boolean.TRUE.equals(acquired)) { |
||||
|
log.trace("Acquired advisory lock"); |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to acquire advisory lock", e); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void releaseAdvisoryLock() { |
||||
|
try { |
||||
|
jdbcTemplate.queryForObject( |
||||
|
"SELECT pg_advisory_unlock(?)", |
||||
|
Boolean.class, |
||||
|
ADVISORY_LOCK_ID |
||||
|
); |
||||
|
log.debug("Released advisory lock"); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to release advisory lock", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private VersionInfo parseVersion(String version) { |
||||
|
try { |
||||
|
String[] parts = version.split("\\."); |
||||
|
int major = Integer.parseInt(parts[0]); |
||||
|
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; |
||||
|
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; |
||||
|
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0; |
||||
|
return new VersionInfo(major, minor, maintenance, patch); |
||||
|
} catch (Exception e) { |
||||
|
log.error("Failed to parse version: {}", version, e); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Stream<Path> listDir(Path dir) { |
||||
|
try { |
||||
|
return Files.list(dir); |
||||
|
} catch (NoSuchFileException e) { |
||||
|
return Stream.empty(); |
||||
|
} catch (IOException e) { |
||||
|
throw new UncheckedIOException(e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public record VersionInfo(int major, int minor, int maintenance, int patch) {} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,75 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.utils; |
||||
|
|
||||
|
import lombok.NonNull; |
||||
|
import org.apache.commons.lang3.math.NumberUtils; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.Argument; |
||||
|
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.BooleanDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.KvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.StringDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.TsKvEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
||||
|
|
||||
|
import static org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry.DEFAULT_VERSION; |
||||
|
|
||||
|
public class CalculatedFieldArgumentUtils { |
||||
|
|
||||
|
public static ArgumentEntry transformSingleValueArgument(@NonNull KvEntry kvEntry) { |
||||
|
return kvEntry.getValue() != null ? ArgumentEntry.createSingleValueArgument(kvEntry) : new SingleValueArgumentEntry(); |
||||
|
} |
||||
|
|
||||
|
public static TsKvEntry createDefaultTsKvEntry(Argument argument, long ts) { |
||||
|
return new BasicTsKvEntry(ts, createDefaultKvEntry(argument), DEFAULT_VERSION); |
||||
|
} |
||||
|
|
||||
|
public static AttributeKvEntry createDefaultAttributeEntry(Argument argument, long ts) { |
||||
|
return new BaseAttributeKvEntry(createDefaultKvEntry(argument), ts, DEFAULT_VERSION); |
||||
|
} |
||||
|
|
||||
|
private static KvEntry createDefaultKvEntry(Argument argument) { |
||||
|
String key = argument.getRefEntityKey().getKey(); |
||||
|
String defaultValue = argument.getDefaultValue(); |
||||
|
if (StringUtils.isBlank(defaultValue)) { |
||||
|
return new StringDataEntry(key, null); |
||||
|
} |
||||
|
if (NumberUtils.isParsable(defaultValue)) { |
||||
|
return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); |
||||
|
} |
||||
|
if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { |
||||
|
return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); |
||||
|
} |
||||
|
return new StringDataEntry(key, defaultValue); |
||||
|
} |
||||
|
|
||||
|
public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { |
||||
|
return switch (ctx.getCfType()) { |
||||
|
case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); |
||||
|
case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,410 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.system; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.junit.jupiter.api.io.TempDir; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.Arguments; |
||||
|
import org.junit.jupiter.params.provider.CsvSource; |
||||
|
import org.junit.jupiter.params.provider.MethodSource; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.id.WidgetTypeId; |
||||
|
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; |
||||
|
import org.thingsboard.server.dao.widget.WidgetTypeService; |
||||
|
import org.thingsboard.server.service.install.InstallScripts; |
||||
|
import org.thingsboard.server.service.system.SystemPatchApplier; |
||||
|
|
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.util.UUID; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.atomic.AtomicBoolean; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertFalse; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNull; |
||||
|
import static org.junit.jupiter.api.Assertions.assertThrows; |
||||
|
import static org.junit.jupiter.api.Assertions.assertTrue; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.anyLong; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.ArgumentMatchers.argThat; |
||||
|
import static org.mockito.ArgumentMatchers.contains; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class SystemPatchApplierTest { |
||||
|
|
||||
|
@Mock |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@Mock |
||||
|
private InstallScripts installScripts; |
||||
|
|
||||
|
@Mock |
||||
|
private WidgetTypeService widgetTypeService; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private SystemPatchApplier reconciler; |
||||
|
|
||||
|
@TempDir |
||||
|
Path tempDir; |
||||
|
|
||||
|
@ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}") |
||||
|
@CsvSource({ |
||||
|
"4.2.1, 4, 2, 1, 0", |
||||
|
"4.2.0, 4, 2, 0, 0", |
||||
|
"4.2, 4, 2, 0, 0", |
||||
|
"4.0.1.2, 4, 0, 1, 2", |
||||
|
"4, 4, 0, 0, 0", |
||||
|
"1.0.5.7, 1, 0, 5, 7", |
||||
|
"10.20.30.40, 10, 20, 30, 40", |
||||
|
"0.0.1, 0, 0, 1, 0" |
||||
|
}) |
||||
|
void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) { |
||||
|
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString); |
||||
|
|
||||
|
assertNotNull(version, "Version should not be null for: " + versionString); |
||||
|
assertEquals(expectedMajor, version.major(), "Major version mismatch"); |
||||
|
assertEquals(expectedMinor, version.minor(), "Minor version mismatch"); |
||||
|
assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch"); |
||||
|
assertEquals(expectedPatch, version.patch(), "Patch version mismatch"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest(name = "Parse invalid version: {0}") |
||||
|
@CsvSource({ |
||||
|
"invalid", |
||||
|
"a.b.c", |
||||
|
"1.2.y.x", |
||||
|
"''", |
||||
|
"1.x.3" |
||||
|
}) |
||||
|
void testParseInvalidVersion(String invalidVersion) { |
||||
|
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion); |
||||
|
assertNull(version, "Version should be null for invalid input: " + invalidVersion); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenLockIsNotAcquired_thenAcquiredIsSuccess() { |
||||
|
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true); |
||||
|
|
||||
|
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); |
||||
|
|
||||
|
assertEquals(Boolean.TRUE, acquired); |
||||
|
verify(jdbcTemplate).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenLockIsAlreadyAcquired_thenAcquiredIsFailed() { |
||||
|
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(false); |
||||
|
|
||||
|
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); |
||||
|
|
||||
|
assertNotEquals(Boolean.TRUE, acquired); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testReleaseAdvisoryLock() { |
||||
|
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())) |
||||
|
.thenReturn(true); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); |
||||
|
|
||||
|
verify(jdbcTemplate).queryForObject( |
||||
|
contains("pg_advisory_unlock"), eq(Boolean.class), anyLong()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenWidgetNotFound_thenThrowException() throws Exception { |
||||
|
Path widgetTypesDir = tempDir.resolve("widget_types"); |
||||
|
Files.createDirectories(widgetTypesDir); |
||||
|
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); |
||||
|
|
||||
|
WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
String json = JacksonUtil.toString(testWidget); |
||||
|
assertNotNull(json); |
||||
|
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); |
||||
|
|
||||
|
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); |
||||
|
|
||||
|
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenDescriptorChanged_thenUpdateTheExistingWidget() throws Exception { |
||||
|
Path widgetTypesDir = tempDir.resolve("widget_types"); |
||||
|
Files.createDirectories(widgetTypesDir); |
||||
|
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); |
||||
|
|
||||
|
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); |
||||
|
String json = JacksonUtil.toString(fileWidget); |
||||
|
assertNotNull(json); |
||||
|
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); |
||||
|
|
||||
|
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); |
||||
|
existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); |
||||
|
|
||||
|
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) |
||||
|
.thenReturn(existingWidget); |
||||
|
|
||||
|
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); |
||||
|
|
||||
|
assertEquals(1, updated); |
||||
|
verify(widgetTypeService).saveWidgetType(argThat(w -> |
||||
|
w.getDescriptor().get("version").asInt() == 2 |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenNameChanged_thenUpdateTheExistingWidget() throws Exception { |
||||
|
Path widgetTypesDir = tempDir.resolve("widget_types"); |
||||
|
Files.createDirectories(widgetTypesDir); |
||||
|
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); |
||||
|
|
||||
|
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "New Name"); |
||||
|
String json = JacksonUtil.toString(fileWidget); |
||||
|
assertNotNull(json); |
||||
|
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); |
||||
|
|
||||
|
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Old Name"); |
||||
|
existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); |
||||
|
|
||||
|
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) |
||||
|
.thenReturn(existingWidget); |
||||
|
|
||||
|
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); |
||||
|
|
||||
|
assertEquals(1, updated); |
||||
|
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenNothingChanged_thenSkipTheUpdateOfTheExistingWidget() throws Exception { |
||||
|
Path widgetTypesDir = tempDir.resolve("widget_types"); |
||||
|
Files.createDirectories(widgetTypesDir); |
||||
|
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); |
||||
|
|
||||
|
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
String json = JacksonUtil.toString(fileWidget); |
||||
|
assertNotNull(json); |
||||
|
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); |
||||
|
|
||||
|
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); |
||||
|
|
||||
|
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) |
||||
|
.thenReturn(existingWidget); |
||||
|
|
||||
|
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); |
||||
|
|
||||
|
assertEquals(0, updated); |
||||
|
verify(widgetTypeService, never()).saveWidgetType(any()); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest(name = "{0}") |
||||
|
@MethodSource("provideDescriptorComparisonTestCases") |
||||
|
void testIfDescriptorsAreEqual(String testName, JsonNode desc1, JsonNode desc2, boolean expectedEqual) { |
||||
|
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isDescriptorEqual", desc1, desc2); |
||||
|
assertEquals(expectedEqual, result, testName); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenDescriptorChanged_thenReturnWidgetTypeChanged() { |
||||
|
WidgetTypeDetails existing = createTestWidgetType("test", "Test"); |
||||
|
existing.setDescriptor(JacksonUtil.toJsonNode("{\"version\":1}")); |
||||
|
|
||||
|
WidgetTypeDetails file = createTestWidgetType("test", "Test"); |
||||
|
file.setDescriptor(JacksonUtil.toJsonNode("{\"version\":2}")); |
||||
|
|
||||
|
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); |
||||
|
assertTrue(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenNameChanged_thenReturnWidgetTypeChanged() { |
||||
|
WidgetTypeDetails existing = createTestWidgetType("test", "Old Name"); |
||||
|
WidgetTypeDetails file = createTestWidgetType("test", "New Name"); |
||||
|
|
||||
|
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); |
||||
|
assertTrue(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenDescriptionChanged_thenReturnWidgetTypeChanged() { |
||||
|
WidgetTypeDetails existing = createTestWidgetType("test", "Test"); |
||||
|
existing.setDescription("Old description"); |
||||
|
|
||||
|
WidgetTypeDetails file = createTestWidgetType("test", "Test"); |
||||
|
file.setDescription("New description"); |
||||
|
|
||||
|
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); |
||||
|
assertTrue(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenWidgetTypeAreIdentical_thenNoUpdateIsPerformed() { |
||||
|
WidgetTypeDetails existing = createTestWidgetType("test", "Test"); |
||||
|
WidgetTypeDetails file = createTestWidgetType("test", "Test"); |
||||
|
|
||||
|
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); |
||||
|
assertFalse(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenLockIsHeldByOneThread_thenSecondThreadCannotAcquireLock() throws Exception { |
||||
|
CountDownLatch lockAcquired = new CountDownLatch(1); |
||||
|
CountDownLatch startSecondThread = new CountDownLatch(1); |
||||
|
CountDownLatch testComplete = new CountDownLatch(1); |
||||
|
|
||||
|
AtomicBoolean firstThreadAcquiredLock = new AtomicBoolean(false); |
||||
|
AtomicBoolean secondThreadAcquiredLock = new AtomicBoolean(false); |
||||
|
AtomicBoolean firstThreadSavedWidget = new AtomicBoolean(false); |
||||
|
AtomicBoolean secondThreadSavedWidget = new AtomicBoolean(false); |
||||
|
|
||||
|
Path widgetTypesDir = tempDir.resolve("widget_types"); |
||||
|
Files.createDirectories(widgetTypesDir); |
||||
|
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); |
||||
|
|
||||
|
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); |
||||
|
String toString = JacksonUtil.toCanonicalString(fileWidget); |
||||
|
assertNotNull(toString); |
||||
|
Files.writeString(widgetTypesDir.resolve("test_widget.json"), toString); |
||||
|
|
||||
|
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); |
||||
|
existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); |
||||
|
existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); |
||||
|
|
||||
|
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(existingWidget); |
||||
|
|
||||
|
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())) |
||||
|
.thenReturn(true) |
||||
|
.thenReturn(false); |
||||
|
|
||||
|
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())) |
||||
|
.thenReturn(true); |
||||
|
|
||||
|
// The first thread-acquires lock and performs update
|
||||
|
Thread firstThread = new Thread(() -> { |
||||
|
try { |
||||
|
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); |
||||
|
firstThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); |
||||
|
|
||||
|
if (firstThreadAcquiredLock.get()) { |
||||
|
lockAcquired.countDown(); |
||||
|
startSecondThread.await(5, TimeUnit.SECONDS); |
||||
|
|
||||
|
// Simulate work while holding lock
|
||||
|
Thread.sleep(100); |
||||
|
|
||||
|
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); |
||||
|
firstThreadSavedWidget.set(updated != null && updated > 0); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); |
||||
|
} |
||||
|
} catch (Exception ignored) { |
||||
|
} finally { |
||||
|
testComplete.countDown(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Second thread - attempts to acquire lock but fails
|
||||
|
Thread secondThread = new Thread(() -> { |
||||
|
try { |
||||
|
lockAcquired.await(5, TimeUnit.SECONDS); |
||||
|
startSecondThread.countDown(); |
||||
|
|
||||
|
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); |
||||
|
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); |
||||
|
|
||||
|
if (secondThreadAcquiredLock.get()) { |
||||
|
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); |
||||
|
secondThreadSavedWidget.set(updated != null && updated > 0); |
||||
|
|
||||
|
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); |
||||
|
} |
||||
|
} catch (Exception ignored) {} |
||||
|
}); |
||||
|
|
||||
|
firstThread.start(); |
||||
|
secondThread.start(); |
||||
|
|
||||
|
assertTrue(testComplete.await(10, TimeUnit.SECONDS), "Test should complete within timeout"); |
||||
|
firstThread.join(1000); |
||||
|
secondThread.join(1000); |
||||
|
|
||||
|
assertTrue(firstThreadAcquiredLock.get(), "First thread should acquire lock"); |
||||
|
assertFalse(secondThreadAcquiredLock.get(), "Second thread should NOT acquire lock"); |
||||
|
assertTrue(firstThreadSavedWidget.get(), "First thread should save widget"); |
||||
|
assertFalse(secondThreadSavedWidget.get(), "Second thread should NOT save widget"); |
||||
|
|
||||
|
verify(widgetTypeService, times(1)).saveWidgetType(any()); |
||||
|
} |
||||
|
|
||||
|
private static Stream<Arguments> provideDescriptorComparisonTestCases() { |
||||
|
return Stream.of( |
||||
|
Arguments.of("Both null", null, null, true), |
||||
|
Arguments.of("First null", null, JacksonUtil.newObjectNode(), false), |
||||
|
Arguments.of("Second null", JacksonUtil.newObjectNode(), null, false), |
||||
|
Arguments.of("Same content", |
||||
|
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), |
||||
|
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), |
||||
|
true), |
||||
|
Arguments.of("Different content", |
||||
|
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), |
||||
|
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"), |
||||
|
false), |
||||
|
Arguments.of("Different key order but same content", |
||||
|
JacksonUtil.toJsonNode("{\"version\":1,\"type\":\"latest\"}"), |
||||
|
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), |
||||
|
true), |
||||
|
Arguments.of("Empty objects", |
||||
|
JacksonUtil.toJsonNode("{}"), |
||||
|
JacksonUtil.toJsonNode("{}"), |
||||
|
true) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private WidgetTypeDetails createTestWidgetType(String fqn, String name) { |
||||
|
WidgetTypeDetails widget = new WidgetTypeDetails(); |
||||
|
widget.setFqn(fqn); |
||||
|
widget.setName(name); |
||||
|
widget.setDescription("Test description"); |
||||
|
widget.setTenantId(TenantId.SYS_TENANT_ID); |
||||
|
widget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\"}")); |
||||
|
return widget; |
||||
|
} |
||||
|
|
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue