feat(ft546): add isolated shadow process
This commit is contained in:
1
pom.xml
1
pom.xml
@@ -265,6 +265,7 @@
|
||||
<module>ruoyi-admin</module>
|
||||
<module>ruoyi-ft546-protocol</module>
|
||||
<module>ruoyi-wcs</module>
|
||||
<module>ruoyi-wcs-shadow</module>
|
||||
<module>ruoyi-framework</module>
|
||||
<module>ruoyi-system</module>
|
||||
<module>ruoyi-quartz</module>
|
||||
|
||||
68
ruoyi-wcs-shadow/pom.xml
Normal file
68
ruoyi-wcs-shadow/pom.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<version>3.8.9</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ruoyi-wcs-shadow</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<description>Independent read-only FT546 shadow process</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-wcs</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-ft546-protocol</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.2.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<configuration>
|
||||
<mainClass>com.ruoyi.shadow.Ft546ShadowApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.shadow;
|
||||
|
||||
import com.ruoyi.shadow.config.ShadowKernelConfiguration;
|
||||
import com.ruoyi.shadow.config.ShadowRuntimeConfiguration;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = {
|
||||
DataSourceAutoConfiguration.class,
|
||||
SecurityAutoConfiguration.class,
|
||||
UserDetailsServiceAutoConfiguration.class
|
||||
})
|
||||
@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class})
|
||||
@EnableScheduling
|
||||
public class Ft546ShadowApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Ft546ShadowApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
|
||||
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.port.out.TrafficStateRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ShadowKernelConfiguration {
|
||||
@Bean
|
||||
public RoutePlanner shadowRoutePlanner() {
|
||||
return new AStarRoutePlanner();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public VehicleAssignmentPolicy shadowVehicleAssignmentPolicy(
|
||||
RoutePlanner routePlanner) {
|
||||
return new DefaultVehicleAssignmentPolicy(routePlanner,
|
||||
DispatchPolicy.NONE);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DeadlockGuard shadowDeadlockGuard(RoutePlanner routePlanner) {
|
||||
return new DeadlockGuard(new WaitForGraph(), routePlanner);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IdGenerator shadowIdGenerator() {
|
||||
AtomicLong sequence = new AtomicLong();
|
||||
return () -> "SHADOW-" + sequence.incrementAndGet();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TrafficStateRepository disabledShadowTrafficStateRepository() {
|
||||
return commit -> {
|
||||
throw new IllegalStateException(
|
||||
"SHADOW_TRAFFIC_STATE_REPOSITORY_NOT_CONFIGURED");
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DispatchCoordinator shadowDispatchCoordinator(
|
||||
VehicleAssignmentPolicy assignmentPolicy,
|
||||
DeadlockGuard deadlockGuard,
|
||||
TrafficStateRepository trafficStateRepository,
|
||||
IdGenerator idGenerator) {
|
||||
return new DispatchCoordinator(assignmentPolicy, deadlockGuard,
|
||||
trafficStateRepository, idGenerator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "shadow")
|
||||
public class ShadowProperties {
|
||||
private boolean enabled;
|
||||
private final JdbcEndpoint source = new JdbcEndpoint();
|
||||
private final JdbcEndpoint evidence = new JdbcEndpoint();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public JdbcEndpoint getSource() { return source; }
|
||||
public JdbcEndpoint getEvidence() { return evidence; }
|
||||
|
||||
public static final class JdbcEndpoint {
|
||||
private String jdbcUrl;
|
||||
|
||||
public String getJdbcUrl() { return jdbcUrl; }
|
||||
public void setJdbcUrl(String jdbcUrl) { this.jdbcUrl = jdbcUrl; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import com.ruoyi.shadow.startup.ShadowStartupGate;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.shadow.web.ShadowStatusController;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ShadowProperties.class)
|
||||
public class ShadowRuntimeConfiguration {
|
||||
@Bean
|
||||
public ShadowStartupGate shadowStartupGate() {
|
||||
return new ShadowStartupGate();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowStartupReport shadowStartupReport(
|
||||
ShadowStartupGate gate, ApplicationContext context,
|
||||
Environment environment, ShadowProperties properties) {
|
||||
return gate.verify(context, environment, properties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowStatusController shadowStatusController(
|
||||
ShadowStartupReport report) {
|
||||
return new ShadowStatusController(report);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.ruoyi.shadow.startup;
|
||||
|
||||
import com.ruoyi.shadow.config.ShadowProperties;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
|
||||
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
|
||||
import org.springframework.beans.factory.BeanIsNotAFactoryException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ShadowStartupGate {
|
||||
public ShadowStartupReport verify(ApplicationContext context,
|
||||
Environment environment,
|
||||
ShadowProperties properties) {
|
||||
if (!properties.isEnabled()) {
|
||||
throw failure("SHADOW_MUST_BE_EXPLICITLY_ENABLED");
|
||||
}
|
||||
if (!Arrays.asList(environment.getActiveProfiles())
|
||||
.contains("shadow")) {
|
||||
throw failure("SHADOW_PROFILE_MUST_BE_ACTIVE");
|
||||
}
|
||||
requireText(properties.getSource().getJdbcUrl(),
|
||||
"SHADOW_SOURCE_JDBC_URL_REQUIRED");
|
||||
requireText(properties.getEvidence().getJdbcUrl(),
|
||||
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
|
||||
requireLoopback(environment.getProperty("server.address",
|
||||
"127.0.0.1"));
|
||||
requireNoBeans(context, DeviceCommandGateway.class,
|
||||
"DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW");
|
||||
requireNoBeans(context, WmsNotificationGateway.class,
|
||||
"WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW");
|
||||
requireNoForbiddenBeanPackage(context);
|
||||
Map<String, String> checks = new LinkedHashMap<String, String>();
|
||||
checks.put("SHADOW_ENABLED", "PASS");
|
||||
checks.put("SHADOW_PROFILE_ACTIVE", "PASS");
|
||||
checks.put("SOURCE_JDBC_CONFIGURED", "PASS");
|
||||
checks.put("EVIDENCE_JDBC_CONFIGURED", "PASS");
|
||||
checks.put("STATUS_ADDRESS_LOOPBACK", "PASS");
|
||||
checks.put("SIDE_EFFECT_BEANS_ABSENT", "PASS");
|
||||
checks.put("FORBIDDEN_BEAN_PACKAGES_ABSENT", "PASS");
|
||||
checks.put("SOURCE_PERMISSIONS_VERIFIED", "PENDING");
|
||||
checks.put("EVIDENCE_DATABASE_VERIFIED", "PENDING");
|
||||
return ShadowStartupReport.starting(Instant.now(), checks);
|
||||
}
|
||||
|
||||
private static void requireLoopback(String address) {
|
||||
String normalized = address == null ? "" : address.trim();
|
||||
if (!"127.0.0.1".equals(normalized)
|
||||
&& !"localhost".equalsIgnoreCase(normalized)
|
||||
&& !"::1".equals(normalized)) {
|
||||
throw failure("SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void requireNoBeans(ApplicationContext context,
|
||||
Class<T> type,
|
||||
String reasonCode) {
|
||||
if (!context.getBeansOfType(type).isEmpty()) {
|
||||
throw failure(reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireNoForbiddenBeanPackage(
|
||||
ApplicationContext context) {
|
||||
for (String beanName : context.getBeanDefinitionNames()) {
|
||||
requireAllowedBeanType(context.getType(beanName, false));
|
||||
requireAllowedBeanType(factoryBeanType(context, beanName));
|
||||
}
|
||||
}
|
||||
|
||||
private static Class<?> factoryBeanType(ApplicationContext context,
|
||||
String beanName) {
|
||||
try {
|
||||
return context.getType("&" + beanName, false);
|
||||
} catch (BeanIsNotAFactoryException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAllowedBeanType(Class<?> beanType) {
|
||||
String typeName = beanType == null ? null : beanType.getName();
|
||||
if (typeName != null
|
||||
&& (typeName.startsWith("com.ruoyi.schedule.")
|
||||
|| typeName.startsWith("com.ruoyi.project.")
|
||||
|| typeName.startsWith("com.ruoyi.api."))) {
|
||||
throw failure("FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:"
|
||||
+ typeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String reasonCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw failure(reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalStateException failure(String reasonCode) {
|
||||
return new IllegalStateException(reasonCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ruoyi.shadow.startup;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ShadowStartupReport {
|
||||
public enum Status { STARTING, READY, DEGRADED, STOPPED }
|
||||
|
||||
private final Status status;
|
||||
private final Instant checkedAt;
|
||||
private final Instant lastCompletedCycle;
|
||||
private final Long sourceLagMillis;
|
||||
private final long evidenceBacklog;
|
||||
private final Map<String, String> startupChecks;
|
||||
|
||||
private ShadowStartupReport(Status status, Instant checkedAt,
|
||||
Instant lastCompletedCycle,
|
||||
Long sourceLagMillis, long evidenceBacklog,
|
||||
Map<String, String> startupChecks) {
|
||||
this.status = status;
|
||||
this.checkedAt = checkedAt;
|
||||
this.lastCompletedCycle = lastCompletedCycle;
|
||||
this.sourceLagMillis = sourceLagMillis;
|
||||
this.evidenceBacklog = evidenceBacklog;
|
||||
this.startupChecks = Collections.unmodifiableMap(
|
||||
new LinkedHashMap<String, String>(startupChecks));
|
||||
}
|
||||
|
||||
public static ShadowStartupReport starting(Instant checkedAt,
|
||||
Map<String, String> checks) {
|
||||
return new ShadowStartupReport(Status.STARTING, checkedAt, null, null,
|
||||
0L, checks);
|
||||
}
|
||||
|
||||
public Status getStatus() { return status; }
|
||||
public Instant getCheckedAt() { return checkedAt; }
|
||||
public Instant getLastCompletedCycle() { return lastCompletedCycle; }
|
||||
public Long getSourceLagMillis() { return sourceLagMillis; }
|
||||
public long getEvidenceBacklog() { return evidenceBacklog; }
|
||||
public Map<String, String> getStartupChecks() { return startupChecks; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.shadow.web;
|
||||
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/shadow")
|
||||
public final class ShadowStatusController {
|
||||
private final ShadowStartupReport report;
|
||||
|
||||
public ShadowStatusController(ShadowStartupReport report) {
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
public ShadowStartupReport status() {
|
||||
return report;
|
||||
}
|
||||
}
|
||||
12
ruoyi-wcs-shadow/src/main/resources/application-shadow.yml
Normal file
12
ruoyi-wcs-shadow/src/main/resources/application-shadow.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
server:
|
||||
address: 127.0.0.1
|
||||
port: 18091
|
||||
spring:
|
||||
main:
|
||||
banner-mode: "off"
|
||||
shadow:
|
||||
enabled: false
|
||||
source:
|
||||
jdbc-url:
|
||||
evidence:
|
||||
jdbc-url:
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.project.shadowtest;
|
||||
|
||||
import org.springframework.beans.factory.FactoryBean;
|
||||
|
||||
public final class ForbiddenFactoryBean implements FactoryBean<Object> {
|
||||
@Override
|
||||
public Object getObject() {
|
||||
throw new AssertionError("forbidden factory must never create a bean");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> getObjectType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSingleton() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.ruoyi.shadow;
|
||||
|
||||
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.shadow.web.ShadowStatusController;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
|
||||
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class Ft546ShadowApplicationTest {
|
||||
private final WebApplicationContextRunner contextRunner =
|
||||
new WebApplicationContextRunner()
|
||||
.withInitializer(
|
||||
new ConfigDataApplicationContextInitializer())
|
||||
.withUserConfiguration(Ft546ShadowApplication.class);
|
||||
|
||||
@Test
|
||||
void explicitReadOnlyShadowConfigurationStartsTheNarrowContext() {
|
||||
configured(contextRunner).run(context -> {
|
||||
assertFalse(context.getStartupFailure() != null);
|
||||
assertEquals("127.0.0.1",
|
||||
context.getEnvironment().getProperty("server.address"));
|
||||
assertEquals("18091",
|
||||
context.getEnvironment().getProperty("server.port"));
|
||||
assertEquals(ShadowStartupReport.Status.STARTING,
|
||||
context.getBean(ShadowStartupReport.class).getStatus());
|
||||
assertNotNull(context.getBean(ShadowStartupReport.class)
|
||||
.getCheckedAt());
|
||||
assertNull(context.getBean(ShadowStartupReport.class)
|
||||
.getLastCompletedCycle());
|
||||
assertNull(context.getBean(ShadowStartupReport.class)
|
||||
.getSourceLagMillis());
|
||||
assertEquals(0L, context.getBean(ShadowStartupReport.class)
|
||||
.getEvidenceBacklog());
|
||||
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("SHADOW_ENABLED"));
|
||||
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("SIDE_EFFECT_BEANS_ABSENT"));
|
||||
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED"));
|
||||
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
|
||||
assertNotNull(context.getBean(DispatchCoordinator.class));
|
||||
assertNotNull(context.getBean(ShadowStatusController.class));
|
||||
assertTrue(context.getBeansOfType(
|
||||
DeviceCommandGateway.class).isEmpty());
|
||||
assertTrue(context.getBeansOfType(
|
||||
WmsNotificationGateway.class).isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusEndpointReturnsTheReadOnlyStartupState() {
|
||||
configured(contextRunner).run(context -> {
|
||||
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||
|
||||
mvc.perform(get("/shadow/status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("STARTING"))
|
||||
.andExpect(jsonPath("$.checkedAt").exists())
|
||||
.andExpect(jsonPath("$.evidenceBacklog").value(0))
|
||||
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
|
||||
.value("PASS"))
|
||||
.andExpect(jsonPath(
|
||||
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
|
||||
.value("PASS"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupFailsClosedUnlessEnabledProfileAndBothJdbcUrlsAreExplicit() {
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
|
||||
"SHADOW_MUST_BE_EXPLICITLY_ENABLED");
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"shadow.enabled=true",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
|
||||
"SHADOW_PROFILE_MUST_BE_ACTIVE");
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.enabled=true",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
|
||||
"SHADOW_SOURCE_JDBC_URL_REQUIRED");
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.enabled=true",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source"),
|
||||
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.enabled=true",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
|
||||
"server.address=0.0.0.0"),
|
||||
"SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupRejectsAnyRealCommandGatewayBean() {
|
||||
assertStartupFailure(configured(contextRunner)
|
||||
.withUserConfiguration(
|
||||
ForbiddenGatewayConfiguration.class),
|
||||
"DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupRejectsAnyRealWmsGatewayBean() {
|
||||
assertStartupFailure(configured(contextRunner)
|
||||
.withUserConfiguration(
|
||||
ForbiddenWmsGatewayConfiguration.class),
|
||||
"WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupRejectsAForbiddenFactoryBeanWithUnknownProductType() {
|
||||
assertStartupFailure(configured(contextRunner)
|
||||
.withUserConfiguration(
|
||||
ForbiddenFactoryBeanConfiguration.class),
|
||||
"FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:"
|
||||
+ ForbiddenFactoryBean.class.getName());
|
||||
}
|
||||
|
||||
private static WebApplicationContextRunner configured(
|
||||
WebApplicationContextRunner runner) {
|
||||
return runner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.enabled=true",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence");
|
||||
}
|
||||
|
||||
private static void assertStartupFailure(
|
||||
WebApplicationContextRunner runner, String reasonCode) {
|
||||
runner.run(context -> {
|
||||
Throwable failure = context.getStartupFailure();
|
||||
assertNotNull(failure);
|
||||
assertTrue(messages(failure).contains(reasonCode),
|
||||
messages(failure));
|
||||
});
|
||||
}
|
||||
|
||||
private static String messages(Throwable failure) {
|
||||
StringBuilder messages = new StringBuilder();
|
||||
Throwable current = failure;
|
||||
while (current != null) {
|
||||
if (current.getMessage() != null) {
|
||||
messages.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ForbiddenGatewayConfiguration {
|
||||
@Bean
|
||||
DeviceCommandGateway forbiddenDeviceCommandGateway() {
|
||||
return envelope -> {
|
||||
throw new AssertionError("must never send in shadow test");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ForbiddenWmsGatewayConfiguration {
|
||||
@Bean
|
||||
WmsNotificationGateway forbiddenWmsNotificationGateway() {
|
||||
return notification -> {
|
||||
throw new AssertionError("must never notify in shadow test");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ForbiddenFactoryBeanConfiguration {
|
||||
@Bean
|
||||
ForbiddenFactoryBean forbiddenFactoryBean() {
|
||||
return new ForbiddenFactoryBean();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ruoyi.shadow;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class ShadowDependencyBoundaryTest {
|
||||
@Test
|
||||
void shadowCodeCannotDependOnAdminPlcOrLegacySchedulerPackages() {
|
||||
JavaClasses shadowClasses = new ClassFileImporter()
|
||||
.importUrl(Ft546ShadowApplication.class.getProtectionDomain()
|
||||
.getCodeSource().getLocation());
|
||||
ArchRule rule = noClasses()
|
||||
.that().resideInAPackage("com.ruoyi.shadow..")
|
||||
.should().dependOnClassesThat().resideInAnyPackage(
|
||||
"com.ruoyi.project..",
|
||||
"com.ruoyi.schedule..",
|
||||
"com.ruoyi.api..",
|
||||
"HslCommunication..",
|
||||
"org.apache.plc4x..");
|
||||
|
||||
rule.check(shadowClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionSideEffectLibrariesAreAbsentFromTheShadowClasspath() {
|
||||
ClassLoader loader = getClass().getClassLoader();
|
||||
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"com.ruoyi.schedule.ScheduleThread", loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"HslCommunication.Profinet.Siemens.SiemensS7Net", loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"org.apache.plc4x.java.api.PlcDriverManager", loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"org.springframework.data.redis.connection.RedisConnectionFactory",
|
||||
loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"org.springframework.security.web.SecurityFilterChain", loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"org.mybatis.spring.SqlSessionFactoryBean", loader));
|
||||
assertFalse(ClassUtils.isPresent(
|
||||
"cn.hutool.extra.spring.SpringUtil", loader));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: shadow
|
||||
shadow:
|
||||
enabled: true
|
||||
source:
|
||||
jdbc-url: jdbc:h2:mem:source
|
||||
evidence:
|
||||
jdbc-url: jdbc:h2:mem:evidence
|
||||
Reference in New Issue
Block a user