Hardcode the Processor flag instead of a runtime property

This commit is contained in:
Anthony Stirling
2026-08-28 01:26:47 +01:00
parent b57eb98bd7
commit bf1ba5c1ed
23 changed files with 113 additions and 234 deletions
+1
View File
@@ -25,6 +25,7 @@ watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/editor/src/proprietary/components/watchedFolders/
!frontend/editor/src/editoronly/components/watchedFolders/
clientWebUI/
policy-webhook-spool/
# Scratch dir used by local fixture-regeneration runs (see
+2 -14
View File
@@ -26,7 +26,6 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
PROCESSOR_ENABLED: '{{.PROCESSOR_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -41,25 +40,14 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
# Empty leaves the Processor on, matching a stock install.
PROCESSOR_ENABLED: '{{.PROCESSOR_ENABLED | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .PROCESSOR_ENABLED}}PROCESSOR_ENABLED={{.PROCESSOR_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:editoronly:
desc: "Start backend dev server with the Processor off (editor-only deployment)"
cmds:
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
PROCESSOR_ENABLED: "false"
dev:bundled:
desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)"
ignore_error: true
+1 -1
View File
@@ -221,7 +221,7 @@ tasks:
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
build:editoronly:
desc: "Build for editor-only mode (no Processor; pair with PROCESSOR_ENABLED=false)"
desc: "Build for editor-only mode (pair with ProcessorFeature.ENABLED=false)"
deps: [prepare]
cmds:
- npx vite build editor --mode editoronly
+4 -5
View File
@@ -81,20 +81,19 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
# One task, because the two halves are one deployment shape: a backend with the
# Processor off served by an editor build that has no Processor code in it.
# The backend half is compiled in, so this only picks the editor bundle; flip
# ProcessorFeature.ENABLED and rebuild to move the backend with it.
dev:editoronly:
desc: "Start an editor-only deployment: backend with PROCESSOR_ENABLED=false + editoronly editor"
desc: "Start the editoronly editor build (backend follows ProcessorFeature.ENABLED)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev:editoronly
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
# Inherited from settings.yml unless you pass SECURITY_ENABLELOGIN=true.
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
- task: frontend:dev:editoronly
vars:
@@ -6,18 +6,17 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Conditional;
import stirling.software.common.configuration.ProcessorFeature;
/**
* Matches unless {@code processor.enabled=false}, which yields an editor-only deployment. Absent
* the property the Processor is on, so existing installs are unaffected.
*
* <p>Applied to the Processor's controllers and its background/boot-work beans. Types that
* non-Processor code injects (stores, services, JPA entities, repositories) stay ungated so the
* context still starts with the Processor off.
* Matches while {@link ProcessorFeature#ENABLED} is true. Applied to the Processor's controllers
* and its background/boot-work beans; types that non-Processor code injects (stores, services, JPA
* entities, repositories) stay ungated so the context still starts with the Processor off.
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ConditionalOnProperty(name = "processor.enabled", havingValue = "true", matchIfMissing = true)
@Conditional(ProcessorFeature.class)
public @interface ConditionalOnProcessor {}
@@ -80,7 +80,6 @@ public class ConfigInitializer {
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
warnOnStrayProcessorFlag(settingsFile);
boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
@@ -103,22 +102,6 @@ public class ConfigInitializer {
}
}
/**
* The merge below can only rewrite keys the template already defines, so a hand-added
* processor.enabled is erased on this very boot. Nothing can carry it forward - say so loudly
* rather than starting the Processor the admin asked to turn off.
*/
private void warnOnStrayProcessorFlag(YamlHelper yaml) {
Object stray = yaml.getValueByExactKeyPath("processor", "enabled");
if (stray != null) {
log.warn(
"Ignoring processor.enabled={} in settings.yml - the template merge removes"
+ " keys it does not define. Use PROCESSOR_ENABLED or"
+ " custom_settings.yml instead.",
stray);
}
}
// TODO: Remove post migration
private void migrateEnterpriseEditionToPremium(YamlHelper yaml, YamlHelper template) {
if (yaml.getValueByExactKeyPath("enterpriseEdition", "enabled") != null) {
@@ -0,0 +1,26 @@
package stirling.software.common.configuration;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
/**
* The single source of truth for whether this build ships the Processor - policies, document
* sources, classification, pipelines, triggers, integrations and the {@code /processor} portal.
*
* <p>Deliberately a constant rather than a property: an editor-only server is a decision made in
* source and shipped, not something a deployment can flip. Flip {@link #ENABLED} to {@code false}
* and rebuild to produce one.
*
* <p>Doubles as the Spring {@link Condition} behind {@code @ConditionalOnProcessor}, so the beans
* and the plain-Java readers cannot disagree.
*/
public class ProcessorFeature implements Condition {
public static final boolean ENABLED = true;
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return ENABLED;
}
}
@@ -47,7 +47,6 @@ import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.model.oauth2.Provider;
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.common.util.ValidationUtils;
@Data
@@ -82,7 +81,6 @@ public class ApplicationProperties {
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
private Processor processor = new Processor();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -117,15 +115,6 @@ public class ApplicationProperties {
return propertySource;
}
/**
* RequestUriUtils is static (called per-request from filters), so it can't be injected. Publish
* the flag here, at bean init - long before any request can reach those filters.
*/
@PostConstruct
public void publishProcessorFlag() {
RequestUriUtils.setProcessorEnabled(processor.isEnabled());
}
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
@@ -215,26 +204,6 @@ public class ApplicationProperties {
}
}
@Data
public static class Processor {
/**
* Whether the Processor - policies, document sources, classification, pipelines, triggers
* and integrations - is available on this server. On by default wherever the proprietary
* module is present.
*
* <p>Turning this off yields an editor-only deployment: the Processor's beans are never
* created, its endpoints stop being mapped, the {@code /processor} portal is unreachable,
* and the editor hides every Processor affordance. Everything outside the Processor
* (accounts, storage, premium, audit) is untouched, which is what separates this from
* building the {@code core} flavour.
*
* <p>Deliberately absent from {@code settings.yml.template}: this is a deployment shape
* chosen once, not a setting to browse. Set it with {@code PROCESSOR_ENABLED=false} or in
* {@code custom_settings.yml}, which the template merge never rewrites.
*/
private boolean enabled = true;
}
@Data
public static class Policies {
/**
@@ -4,18 +4,6 @@ import java.util.regex.Pattern;
public class RequestUriUtils {
/**
* Mirror of {@code processor.enabled}, published by ApplicationProperties at bean init. Static
* because every method here is, and every caller invokes them per-request from a filter, long
* after the context has refreshed. Defaults to on so a context that never publishes (tests,
* standalone use) behaves as it always did.
*/
private static volatile boolean processorEnabled = true;
public static void setProcessorEnabled(boolean enabled) {
processorEnabled = enabled;
}
// Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly
private static final Pattern SHARE_LINK_PATTERN =
Pattern.compile(
@@ -88,10 +76,7 @@ public class RequestUriUtils {
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
// Not on an editor-only server: there is no portal to bootstrap.
if (processorEnabled
&& ("/processor".equals(normalizedUri)
|| normalizedUri.startsWith("/processor/"))) {
if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
return true;
}
@@ -151,13 +136,6 @@ public class RequestUriUtils {
}
}
// Editor-only server: the portal route-set isn't mounted, so don't serve its shell.
if (!processorEnabled
&& ("/processor".equals(normalizedUri)
|| normalizedUri.startsWith("/processor/"))) {
return false;
}
if (normalizedUri.isBlank()) {
return false;
}
@@ -233,8 +211,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
// Policy webhook receiver; the controller only exists with the Processor on.
|| (processorEnabled && trimmedUri.startsWith("/api/v1/webhooks/"))
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
@@ -88,34 +88,6 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor"));
}
@Test
void testProcessorOff_portalShellIsNotServed() {
// Editor-only server: /processor must not be a permitAll static resource, must
// not fall back to the SPA shell, and its webhook receiver must not be public -
// none of those beans exist. Restored in a finally so the flag can't leak.
try {
RequestUriUtils.setProcessorEnabled(false);
assertFalse(RequestUriUtils.isStaticResource("/processor"));
assertFalse(RequestUriUtils.isStaticResource("/processor/users"));
assertFalse(RequestUriUtils.isStaticResource("/app", "/app/processor"));
assertFalse(RequestUriUtils.isFrontendRoute("", "/processor"));
assertFalse(RequestUriUtils.isFrontendRoute("", "/processor/policies"));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/abc", ""));
// Editor routes are untouched.
assertTrue(RequestUriUtils.isFrontendRoute("", "/merge"));
assertTrue(RequestUriUtils.isStaticResource("/css/style.css"));
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/auth/login", ""));
} finally {
RequestUriUtils.setProcessorEnabled(true);
}
}
@Test
void testProcessorOn_webhookReceiverIsPublic() {
// Signature-verified at the controller, so it must bypass login when mounted.
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/abc", ""));
}
// --- isFrontendRoute tests ---
@Test
+2 -2
View File
@@ -176,7 +176,7 @@ def frontendBuildTask = "frontend:build:${frontendMode}"
// editoronly strips the Processor from the bundle; the portal IS the Processor's UI.
if (frontendMode == 'editoronly' && buildWithPortal) {
throw new GradleException("-PfrontendMode=editoronly cannot be combined with -PbuildWithPortal=true. " +
"An editor-only build ships no Processor UI; run the JAR with PROCESSOR_ENABLED=false to match.")
"An editor-only build ships no Processor UI; set ProcessorFeature.ENABLED=false to match.")
}
// Workspace root holds package.json and node_modules (shared across editor /
@@ -287,7 +287,7 @@ tasks.register('npmBuild', Exec) {
doFirst {
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
if (frontendMode == 'editoronly') {
println " editor-only bundle: run this JAR with PROCESSOR_ENABLED=false, or the server keeps a Processor its UI cannot reach."
println " editor-only bundle: build this JAR with ProcessorFeature.ENABLED=false, or the server keeps a Processor its UI cannot reach."
}
}
}
@@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.controller.api.security.TimestampController;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.ProcessorFeature;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
@@ -338,8 +339,7 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Processor (policies, sources, classification). Off = editor-only server.
configData.put("processorEnabled", applicationProperties.getProcessor().isEnabled());
configData.put("processorEnabled", ProcessorFeature.ENABLED);
// AI Engine settings
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
@@ -406,10 +406,6 @@ aiEngine:
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
# To run editor-only (no policies, sources, classification, routing, integrations or webhooks) set
# PROCESSOR_ENABLED=false, or processor.enabled: false in custom_settings.yml. Do NOT put it in this
# file - every boot rewrites settings.yml from the template and drops keys the template lacks.
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs,
@@ -2,6 +2,7 @@ package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.IOException;
import java.lang.reflect.Method;
@@ -27,9 +28,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.common.annotations.ConditionalOnProcessor;
/**
* Guards the URL surface of {@code processor.enabled=false}: every endpoint whose path belongs to
* the Processor must sit on a class carrying {@link ConditionalOnProcessor}, so an editor-only
* server never maps it.
* Guards the URL surface of an editor-only build: every endpoint whose path belongs to the
* Processor must sit on a class carrying {@link ConditionalOnProcessor}, so flipping {@code
* ProcessorFeature.ENABLED} unmaps it.
*
* <p>Complements {@code ProcessorConditionalTest}, which scans the Processor's two packages. That
* test cannot see a Processor endpoint declared elsewhere — {@code PipelineController} lives in
@@ -83,7 +84,7 @@ class ProcessorEndpointSurfaceTest {
assertTrue(
offenders.isEmpty(),
() ->
"These endpoints stay mapped with processor.enabled=false. Add"
"These endpoints stay mapped on an editor-only build. Add"
+ " @ConditionalOnProcessor to the controller, or - if the endpoint"
+ " is genuinely not part of the Processor - narrow the path lists"
+ " in this test:\n - "
@@ -106,6 +107,7 @@ class ProcessorEndpointSurfaceTest {
@Test
void nonProcessorEndpointsInSharedNamespacesStayMapped() throws Exception {
assumeTrue(proprietaryOnClasspath(), SKIP_REASON);
// If a prefix ever swallowed one of these namespaces, editor features would vanish from an
// editor-only server - the exact opposite of what the flag promises.
Set<String> ungated = new TreeSet<>();
@@ -134,6 +136,7 @@ class ProcessorEndpointSurfaceTest {
@Test
void everyDeclaredProcessorPathIsActuallyClaimedBySomeController() throws Exception {
assumeTrue(proprietaryOnClasspath(), SKIP_REASON);
// A prefix nobody serves means the list has drifted from the code, and the guard above
// would pass vacuously for that entry.
Set<String> allPaths = new LinkedHashSet<>();
@@ -158,6 +161,19 @@ class ProcessorEndpointSurfaceTest {
() -> "declared Processor paths that no controller maps any more: " + unclaimed);
}
private static final String SKIP_REASON =
"core flavour builds without :proprietary, so it maps none of these paths";
/** app/core/build.gradle only puts :proprietary on the classpath outside the core flavour. */
private static boolean proprietaryOnClasspath() {
try {
Class.forName("stirling.software.proprietary.policy.controller.PolicyController");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
private static boolean isProcessorPath(String path) {
return PROCESSOR_EXACT_PATHS.contains(path)
|| PROCESSOR_PATH_PREFIXES.stream().anyMatch(path::startsWith);
@@ -227,8 +243,9 @@ class ProcessorEndpointSurfaceTest {
controllers.add(type);
}
}
int floor = proprietaryOnClasspath() ? 40 : 20;
assertTrue(
controllers.size() > 40,
controllers.size() > floor,
"scan found only " + controllers.size() + " controllers - is it wired?");
return controllers;
}
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.ProcessorFeature;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
@@ -36,17 +37,12 @@ public class ResourceAccessService {
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
private DefaultAccessPolicy portalDefaultPolicy;
// Initialised on: @Value lands after field init, so a directly-constructed instance must not
// fall to Java's false and lock everyone out of the portal.
@Value("${processor.enabled:true}")
private boolean processorEnabled = true;
// ---- public checks ----
/** Whether the user may use the portal / processor. */
public boolean canAccessPortal(User user) {
// Editor-only deployment: nobody reaches the portal, not even an admin.
if (!processorEnabled) {
if (!ProcessorFeature.ENABLED) {
return false;
}
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
@@ -59,7 +55,7 @@ public class ResourceAccessService {
*/
public Set<Long> usersWithPortalAccess(
Collection<User> users, Set<Long> activeTeamLeaderUserIds) {
if (!processorEnabled) {
if (!ProcessorFeature.ENABLED) {
return Set.of();
}
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
@@ -24,7 +24,6 @@ import stirling.software.proprietary.security.service.ApiKeyManagementService;
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
* keys are a core auth feature available on every self-hosted instance.
*/
// Serves the portal only, and an editor-only server has no portal to serve.
@ProprietaryUiDataApi
@ConditionalOnProcessor
@RequiredArgsConstructor
@@ -25,7 +25,6 @@ import stirling.software.proprietary.service.PortalDocumentsService;
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
* team (see {@link PortalDocumentsScopeResolver}).
*/
// Serves the portal only, and an editor-only server has no portal to serve.
@ProprietaryUiDataApi
@ConditionalOnProcessor
@RequiredArgsConstructor
@@ -18,7 +18,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.service.PortalInfraAuditService;
/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */
// Serves the portal only, and an editor-only server has no portal to serve.
@ProprietaryUiDataApi
@ConditionalOnProcessor
@RequiredArgsConstructor
@@ -18,17 +18,18 @@ import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.type.filter.AssignableTypeFilter;
import org.springframework.data.repository.Repository;
import stirling.software.common.annotations.ConditionalOnProcessor;
import stirling.software.common.configuration.ProcessorFeature;
/**
* Guards {@code processor.enabled}: every component under the Processor's two packages must carry
* {@link ConditionalOnProcessor}, so an editor-only server starts none of them.
* Guards {@link ProcessorFeature#ENABLED}: every component under the Processor's two packages must
* carry {@link ConditionalOnProcessor}, so an editor-only server starts none of them.
*
* <p>This scans rather than hardcoding a list, so a component added later fails here instead of
* silently shipping on a server that asked for no Processor. Anything that genuinely must survive
@@ -76,30 +77,31 @@ class ProcessorConditionalTest {
}
@Test
void theGateReadsProcessorEnabled() {
void theGateReadsTheCompileTimeConstant() {
// The annotation is the single definition of the flag - assert its wiring directly,
// since every other test here only asserts the annotation is present.
ConditionalOnProperty conditional =
ConditionalOnProcessor.class.getAnnotation(ConditionalOnProperty.class);
assertNotNull(conditional, "@ConditionalOnProcessor must be a @ConditionalOnProperty");
assertTrue(
Arrays.asList(conditional.name()).contains("processor.enabled")
|| Arrays.asList(conditional.value()).contains("processor.enabled"),
"@ConditionalOnProcessor must gate on processor.enabled");
assertEquals("true", conditional.havingValue(), "must require processor.enabled=true");
assertTrue(conditional.matchIfMissing(), "the Processor must stay on by default");
Conditional conditional = ConditionalOnProcessor.class.getAnnotation(Conditional.class);
assertNotNull(conditional, "@ConditionalOnProcessor must be a @Conditional");
assertEquals(
List.of(ProcessorFeature.class),
Arrays.asList(conditional.value()),
"@ConditionalOnProcessor must be driven by ProcessorFeature");
assertEquals(
ProcessorFeature.ENABLED,
new ProcessorFeature().matches(null, null),
"the condition must report exactly what the constant says");
}
@Test
void stacksWithAnotherConditionalOnProperty() {
void stacksWithAnotherCondition() {
// TelegramPipelineBot carries both this gate and its own @ConditionalOnProperty. Two
// @ConditionalOnProperty sets on one class is subtle enough to prove rather than assume:
// both must have to pass, not just the last one read.
assertTrue(hasBean("feature.enabled=true", "processor.enabled=true"), "both on -> present");
assertTrue(
!hasBean("feature.enabled=true", "processor.enabled=false"),
"processor off must veto even with the feature on");
assertTrue(!hasBean("processor.enabled=true"), "the other condition must still apply");
// conditions on one class is subtle enough to prove rather than assume: both must pass.
assertEquals(
ProcessorFeature.ENABLED,
hasBean("feature.enabled=true"),
"with the feature on, presence must track the constant");
assertTrue(!hasBean("feature.enabled=false"), "the other condition must still apply");
assertTrue(!hasBean(), "an absent property must still veto");
}
/** By type, not name: a nested @Configuration gets an outer-class-qualified bean name. */
@@ -182,13 +184,9 @@ class ProcessorConditionalTest {
return source.getLocation().getPath().replace('\\', '/').contains("/classes/java/test");
}
/** processor.enabled=true, else the scanner's condition evaluator hides the gated classes. */
/** The scanner evaluates conditions; ProcessorFeature answers from the constant. */
private static StandardEnvironment environmentWithProcessorOn() {
StandardEnvironment environment = new StandardEnvironment();
environment
.getPropertySources()
.addFirst(new MapPropertySource("test", Map.of("processor.enabled", "true")));
return environment;
return new StandardEnvironment();
}
private static Class<?> loadClass(String name) {
+2 -2
View File
@@ -47,8 +47,8 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
# portal or AI layers change; defaults false so normal builds skip the extra app.
ARG BUILD_PORTAL=false
# Vite build mode. Empty keeps the flavour-derived default; "editoronly" builds an
# editor with no Processor code, which must be run with PROCESSOR_ENABLED=false.
# Vite build mode. Empty keeps the flavour-derived default; "editoronly" drops the
# Processor from the bundle and pairs with ProcessorFeature.ENABLED=false.
ARG FRONTEND_MODE=
# Bundle only the JPDFium native for this image's target arch.
+3 -1
View File
@@ -9,7 +9,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true };
/** Earliest point the Processor flag is known — feed the non-hook snapshot. */
function publishConfig(config: AppConfig): AppConfig {
setProcessorEnabled(config.processorEnabled === true);
// Only an explicit false turns it off: the 401 fallback carries no flag, and
// reading that as "off" would silently stop policy export enforcement.
setProcessorEnabled(config.processorEnabled !== false);
return config;
}
@@ -0,0 +1,4 @@
// Watched folders are driven by the Processor; this build ships none.
export default function WatchedFoldersRegistration() {
return null;
}
+6 -51
View File
@@ -1,16 +1,14 @@
# Editor-only deployment: no Processor, no portal.
#
# The three controls have to agree or the result is incoherent, so they are set
# together here and nowhere else:
# PROCESSOR_ENABLED=false backend - the Processor's beans and endpoints
# FRONTEND_MODE=editoronly build - the editor's Processor modules
# BUILD_PORTAL=false build - the /processor portal chunk
# Requires ProcessorFeature.ENABLED=false in source - the backend half is compiled
# in, not configured. The two build args below drop the matching front-end code:
# FRONTEND_MODE=editoronly the editor's Processor modules
# BUILD_PORTAL=false the /processor portal chunk
#
# docker compose -f testing/compose/docker-compose-editor-only.yml up --build
#
# Add `--profile control` to also bring up a stock Processor-on server on :8080.
# Absence proves nothing on its own - the control is what makes the editor-only
# server's missing UI and 404s mean something. It is a second full image build.
# There is no companion Processor-on service here: the flag is compiled in, so one
# source tree yields one shape. Compare against a normal build of main instead.
services:
stirling-editor-only:
build:
@@ -37,8 +35,6 @@ services:
- ../../stirling/editor-only/config:/configs:rw
- ../../stirling/editor-only/logs:/logs:rw
environment:
# The flag under test. Everything else here is ordinary setup.
PROCESSOR_ENABLED: "false"
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
@@ -53,47 +49,6 @@ services:
networks:
- stirling-network
# Positive control: the same image build with nothing turned off, so the
# editor-only server above can be diffed against a server that has it all.
stirling-processor-control:
profiles: ["control"]
build:
context: ../..
dockerfile: docker/embedded/Dockerfile
args:
BUILD_PORTAL: "true"
container_name: Stirling-PDF-Processor-Control
restart: unless-stopped
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ../../stirling/processor-control/data:/usr/share/tessdata:rw
- ../../stirling/processor-control/config:/configs:rw
- ../../stirling/processor-control/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "admin"
SECURITY_INITIALLOGIN_PASSWORD: "stirling"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Processor-on control for the editor-only comparison
UI_APPNAMENAVBAR: Stirling-PDF Processor
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
networks:
- stirling-network
networks:
stirling-network:
driver: bridge