From 34bfd3b4a83817418c5694a00a932c2ce1a16e3a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 16 Apr 2026 12:25:08 +0300 Subject: [PATCH] feat(solutions): add solutionId to install context, separate zip extraction error handling --- .../solutions/DefaultSolutionService.java | 49 ++++++++++++- .../data/SolutionInstallContext.java | 4 +- .../dao/device/DeviceConnectivityService.java | 2 + .../dao/device/DockerComposeParams.java | 21 ++++++ .../device/DeviceConnectivityServiceImpl.java | 8 +- .../dao/util/DeviceConnectivityUtil.java | 73 +++++++++++-------- 6 files changed, 122 insertions(+), 35 deletions(-) create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java index 8000692f04..1835ddbfde 100644 --- a/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java +++ b/application/src/main/java/org/thingsboard/server/service/solutions/DefaultSolutionService.java @@ -75,6 +75,8 @@ import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceConnectivityService; +import org.thingsboard.server.dao.device.DockerComposeParams; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; @@ -126,8 +128,12 @@ import org.thingsboard.server.service.solutions.data.solution.SolutionInstallRes import org.thingsboard.server.service.solutions.data.solution.TenantSolutionTemplateInstructions; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -191,6 +197,7 @@ public class DefaultSolutionService implements SolutionService { private final TbServiceInfoProvider serviceInfoProvider; private final TelemetrySubscriptionService tsSubService; private final BCryptPasswordEncoder passwordEncoder; + private final DeviceConnectivityService deviceConnectivityService; private final ExecutorService emulatorExecutor = ThingsBoardExecutors.newWorkStealingPool(10, "solution-emulators-executor"); @@ -202,10 +209,17 @@ public class DefaultSolutionService implements SolutionService { @Override public SolutionInstallResponse installSolution(SecurityUser user, TenantId tenantId, byte[] zipData, HttpServletRequest request) throws Exception { Path tempDir = Files.createTempDirectory("iot-hub-solution-"); - SolutionInstallContext ctx = new SolutionInstallContext(tenantId, tempDir, user); try { extractZip(zipData, tempDir); - + } catch (Throwable e) { + log.error("[{}] Failed to extract solution template zip", tenantId, e); + deleteDirectory(tempDir); + TenantSolutionTemplateInstructions instructions = new TenantSolutionTemplateInstructions(); + instructions.setDetails(e.getMessage()); + return new SolutionInstallResponse(instructions, false, List.of()); + } + SolutionInstallContext ctx = new SolutionInstallContext(tenantId, loadSolutionId(tempDir), tempDir, user); + try { registerEmulatorsAndComputeOldestTelemetryTs(ctx); provisionTenantDetails(ctx); @@ -370,6 +384,18 @@ public class DefaultSolutionService implements SolutionService { } } + private String loadSolutionId(Path tempDir) { + Path solutionJson = tempDir.resolve("solution.json"); + if (Files.exists(solutionJson)) { + JsonNode node = JacksonUtil.toJsonNode(solutionJson); + if (node != null && node.has("title")) { + String title = node.get("title").asText(""); + return title.trim().toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); + } + } + return null; + } + private long loadInstallTimeoutMs(Path tempDir) { Path solutionJson = tempDir.resolve("solution.json"); if (Files.exists(solutionJson)) { @@ -1048,6 +1074,11 @@ public class DefaultSolutionService implements SolutionService { devList.append(System.lineSeparator()); template = template.replace("${" + credentialsInfo.getName() + "ACCESS_TOKEN}", credentialsInfo.getCredentials().getCredentialsId()); + + if (credentialsInfo.isGateway()) { + template = template.replace("${DOCKER_CONFIG}", + prepareDockerComposeFile(ctx.getTenantId(), ctx.getSolutionId(), baseUrl, credentialsInfo.getCredentials().getDeviceId())); + } } template = template.replace("${device_list_and_credentials}", devList.toString()); @@ -1298,6 +1329,20 @@ public class DefaultSolutionService implements SolutionService { return "/dashboards/" + dashboardId.getId(); } + private String prepareDockerComposeFile(TenantId tenantId, String solutionId, String baseUrl, DeviceId deviceId) { + Device device = new Device(deviceId); + device.setTenantId(tenantId); + String containerName = "tb-gateway-" + solutionId.replace('_', '-'); + DockerComposeParams params = new DockerComposeParams(false, containerName, false, true, false, false); + try (InputStream inputStream = deviceConnectivityService.createGatewayDockerComposeFile(baseUrl, device, params).getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)) + ) { + return reader.lines().collect(Collectors.joining("\n")); + } catch (Exception e) { + throw new RuntimeException("Failed to read or process the docker-compose.yml file.", e); + } + } + private void rollback(TenantId tenantId, SolutionInstallContext ctx, Throwable e) { List createdEntities = ctx.getCreatedEntitiesList(); Collections.reverse(createdEntities); diff --git a/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java index 6ba950571c..9c4afc8b8c 100644 --- a/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java +++ b/application/src/main/java/org/thingsboard/server/service/solutions/data/SolutionInstallContext.java @@ -72,13 +72,15 @@ public class SolutionInstallContext { private final Map createdEntities = new LinkedHashMap<>(); private final Map createdAlarmRules = new LinkedHashMap<>(); private final Map createdCalculatedFields = new LinkedHashMap<>(); + private final String solutionId; private final long installTs; private long oldestTelemetryTs; private Map deviceEmulators; private Map assetEmulators; - public SolutionInstallContext(TenantId tenantId, Path tempDir, SecurityUser user) { + public SolutionInstallContext(TenantId tenantId, String solutionId, Path tempDir, SecurityUser user) { this.tenantId = tenantId; + this.solutionId = solutionId; this.tempDir = tempDir; this.user = user; this.solutionInstructions = new TenantSolutionTemplateInstructions(); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java index d452b7ddd3..597dbd6293 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityService.java @@ -30,4 +30,6 @@ public interface DeviceConnectivityService { Resource getPemCertFile(String protocol); Resource createGatewayDockerComposeFile(String baseUrl, Device device) throws URISyntaxException; + + Resource createGatewayDockerComposeFile(String baseUrl, Device device, DockerComposeParams params) throws URISyntaxException; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java new file mode 100644 index 0000000000..86708910d3 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DockerComposeParams.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2026 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.dao.device; + +public record DockerComposeParams(boolean includeVersion, String containerName, boolean includePortBindings, + boolean includeExtraHosts, boolean includeVolumesBind, + boolean includeVolumesDeclaration) { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 01c3fa3d34..dd46b2806a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -156,11 +156,17 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService @Override public Resource createGatewayDockerComposeFile(String baseUrl, Device device) throws URISyntaxException { + DockerComposeParams params = new DockerComposeParams(true, "tb-gateway", true, true, true, true); + return createGatewayDockerComposeFile(baseUrl, device, params); + } + + @Override + public Resource createGatewayDockerComposeFile(String baseUrl, Device device, DockerComposeParams params) throws URISyntaxException { String mqttType = isEnabled(MQTTS) ? MQTTS : MQTT; DeviceConnectivityInfo properties = getConnectivity(mqttType); DeviceCredentials creds = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); String host = getHost(baseUrl, properties, mqttType); - return DeviceConnectivityUtil.getGatewayDockerComposeFile(host, gatewayImageVersion, creds); + return DeviceConnectivityUtil.getGatewayDockerComposeFile(host, gatewayImageVersion, creds, params); } private DeviceConnectivityInfo getConnectivity(String protocol) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index 5b50d062a2..a7f56390f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.dao.device.DeviceConnectivityInfo; +import org.thingsboard.server.dao.device.DockerComposeParams; import java.net.Inet6Address; import java.net.InetAddress; @@ -92,28 +93,34 @@ public class DeviceConnectivityUtil { return command.toString(); } - public static Resource getGatewayDockerComposeFile(String host, String gatewayImageVersion, DeviceCredentials deviceCredentials) { + public static Resource getGatewayDockerComposeFile(String host, String gatewayImageVersion, DeviceCredentials deviceCredentials, DockerComposeParams params) { StringBuilder dockerComposeBuilder = new StringBuilder(); - dockerComposeBuilder.append("version: '3.4'\n"); + if (params.includeVersion()) { + dockerComposeBuilder.append("version: '3.4'\n"); + } dockerComposeBuilder.append("services:\n"); dockerComposeBuilder.append(" # ThingsBoard IoT Gateway Service Configuration\n"); dockerComposeBuilder.append(" tb-gateway:\n"); dockerComposeBuilder.append(" image: thingsboard/tb-gateway:").append(gatewayImageVersion).append("\n"); - dockerComposeBuilder.append(" container_name: tb-gateway\n"); + dockerComposeBuilder.append(" container_name: ").append(params.containerName()).append("\n"); dockerComposeBuilder.append(" restart: always\n"); - dockerComposeBuilder.append("\n"); - dockerComposeBuilder.append(" # Ports bindings - required by some connectors\n"); - dockerComposeBuilder.append(" ports:\n"); - dockerComposeBuilder.append(" - \"5000:5000\" # Comment if you don't use REST connector and change if you use another port\n"); - dockerComposeBuilder.append(" # Uncomment and modify the following ports based on connector usage:\n"); - dockerComposeBuilder.append("# - \"1052:1052\" # BACnet connector\n"); - dockerComposeBuilder.append("# - \"5026:5026\" # Modbus TCP connector (Modbus Slave)\n"); - dockerComposeBuilder.append("# - \"50000:50000/tcp\" # Socket connector with type TCP\n"); - dockerComposeBuilder.append("# - \"50000:50000/udp\" # Socket connector with type UDP\n"); - dockerComposeBuilder.append("\n"); - dockerComposeBuilder.append(" # Necessary mapping for Linux\n"); - dockerComposeBuilder.append(" extra_hosts:\n"); - dockerComposeBuilder.append(" - \"host.docker.internal:host-gateway\"\n"); + if (params.includePortBindings()) { + dockerComposeBuilder.append("\n"); + dockerComposeBuilder.append(" # Ports bindings - required by some connectors\n"); + dockerComposeBuilder.append(" ports:\n"); + dockerComposeBuilder.append(" - \"5000:5000\" # Comment if you don't use REST connector and change if you use another port\n"); + dockerComposeBuilder.append(" # Uncomment and modify the following ports based on connector usage:\n"); + dockerComposeBuilder.append("# - \"1052:1052\" # BACnet connector\n"); + dockerComposeBuilder.append("# - \"5026:5026\" # Modbus TCP connector (Modbus Slave)\n"); + dockerComposeBuilder.append("# - \"50000:50000/tcp\" # Socket connector with type TCP\n"); + dockerComposeBuilder.append("# - \"50000:50000/udp\" # Socket connector with type UDP\n"); + } + if (params.includeExtraHosts()) { + dockerComposeBuilder.append("\n"); + dockerComposeBuilder.append(" # Necessary mapping for Linux\n"); + dockerComposeBuilder.append(" extra_hosts:\n"); + dockerComposeBuilder.append(" - \"host.docker.internal:host-gateway\"\n"); + } dockerComposeBuilder.append("\n"); dockerComposeBuilder.append(" # Environment variables\n"); dockerComposeBuilder.append(" environment:\n"); @@ -139,21 +146,25 @@ public class DeviceConnectivityUtil { } break; } - dockerComposeBuilder.append("\n"); - dockerComposeBuilder.append(" # Volumes bind\n"); - dockerComposeBuilder.append(" volumes:\n"); - dockerComposeBuilder.append(" - tb-gw-config:/thingsboard_gateway/config\n"); - dockerComposeBuilder.append(" - tb-gw-logs:/thingsboard_gateway/logs\n"); - dockerComposeBuilder.append(" - tb-gw-extensions:/thingsboard_gateway/extensions\n"); - dockerComposeBuilder.append("\n"); - dockerComposeBuilder.append("# Volumes declaration for configurations, extensions and configuration\n"); - dockerComposeBuilder.append("volumes:\n"); - dockerComposeBuilder.append(" tb-gw-config:\n"); - dockerComposeBuilder.append(" name: tb-gw-config\n"); - dockerComposeBuilder.append(" tb-gw-logs:\n"); - dockerComposeBuilder.append(" name: tb-gw-logs\n"); - dockerComposeBuilder.append(" tb-gw-extensions:\n"); - dockerComposeBuilder.append(" name: tb-gw-extensions\n"); + if (params.includeVolumesBind()) { + dockerComposeBuilder.append("\n"); + dockerComposeBuilder.append(" # Volumes bind\n"); + dockerComposeBuilder.append(" volumes:\n"); + dockerComposeBuilder.append(" - tb-gw-config:/thingsboard_gateway/config\n"); + dockerComposeBuilder.append(" - tb-gw-logs:/thingsboard_gateway/logs\n"); + dockerComposeBuilder.append(" - tb-gw-extensions:/thingsboard_gateway/extensions\n"); + } + if (params.includeVolumesDeclaration()) { + dockerComposeBuilder.append("\n"); + dockerComposeBuilder.append("# Volumes declaration for configurations, extensions and configuration\n"); + dockerComposeBuilder.append("volumes:\n"); + dockerComposeBuilder.append(" tb-gw-config:\n"); + dockerComposeBuilder.append(" name: tb-gw-config\n"); + dockerComposeBuilder.append(" tb-gw-logs:\n"); + dockerComposeBuilder.append(" name: tb-gw-logs\n"); + dockerComposeBuilder.append(" tb-gw-extensions:\n"); + dockerComposeBuilder.append(" name: tb-gw-extensions\n"); + } return new ByteArrayResource(dockerComposeBuilder.toString().getBytes(StandardCharsets.UTF_8)); }