diff --git a/dotCMS/src/main/java/com/dotcms/health/checks/cdi/VelocityHealthCheck.java b/dotCMS/src/main/java/com/dotcms/health/checks/cdi/VelocityHealthCheck.java new file mode 100644 index 000000000000..1a2ba0533c8c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/health/checks/cdi/VelocityHealthCheck.java @@ -0,0 +1,94 @@ +package com.dotcms.health.checks.cdi; + +import com.dotcms.health.util.HealthCheckBase; +import com.dotcms.rendering.velocity.util.VelocityUtil; +import com.dotmarketing.util.Logger; + +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.VelocityEngine; + +import javax.enterprise.context.ApplicationScoped; +import java.io.StringWriter; + +/** + * CDI-based health check that verifies the Velocity engine's global macro + * library is loaded and resolvable. + * + *

Background: {@code VelocimacroFactory} has historically been able to silently + * swallow a {@code ResourceNotFoundException} at engine init, leaving + * {@code #renderMarks} and {@code #editContentlet} unregistered while + * {@code engine.init()} still returns successfully. In that state every public + * page renders the macro source as literal text. See spike #35329 and the + * fail-loud companion fix (#35601). + * + *

The probe evaluates {@value #PROBE_TEMPLATE} through + * {@link VelocityUtil#getEngine()}. When the macro is registered the rendered + * output is whatever the macro body produces; when it is not, the engine + * renders the directive source verbatim, which we detect via the literal + * {@value #LITERAL_MARKER} marker. + * + *

Excluded from liveness probes: a missing macro library is a one-time + * startup concern that should remove the pod from the load balancer, not + * trigger a restart loop. + * + *

Configuration: + *

+ */ +@ApplicationScoped +public class VelocityHealthCheck extends HealthCheckBase { + + private static final String PROBE_TEMPLATE = "#renderMarks($null)"; + private static final String LITERAL_MARKER = "#renderMarks("; + private static final String PROBE_LOG_TAG = "VelocityHealthCheck:probe"; + + @Override + public String getName() { + return "velocity"; + } + + @Override + public int getOrder() { + // Runs after database (default 100), cache (30), and elasticsearch (40). + return 110; + } + + @Override + public boolean isLivenessCheck() { + return false; + } + + @Override + public boolean isReadinessCheck() { + return true; + } + + @Override + public String getDescription() { + return "Verifies the Velocity global macro library is registered " + + "(probes #renderMarks resolution)"; + } + + @Override + protected CheckResult performCheck() throws Exception { + if (isShutdownInProgress()) { + Logger.debug(this, "Skipping Velocity probe during shutdown"); + return new CheckResult(false, 0L, + "Velocity health check skipped during shutdown"); + } + + return measureExecution(() -> { + final VelocityEngine engine = VelocityUtil.getEngine(); + final StringWriter writer = new StringWriter(); + engine.evaluate(new VelocityContext(), writer, PROBE_LOG_TAG, PROBE_TEMPLATE); + final String rendered = writer.toString(); + if (rendered.contains(LITERAL_MARKER)) { + throw new IllegalStateException( + "Velocity global macro library not registered: " + + "probe template rendered as literal text. See issues #35329 / #35601."); + } + return "Velocity macro library registered (#renderMarks resolved)"; + }); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/health/providers/CoreHealthCheckProvider.java b/dotCMS/src/main/java/com/dotcms/health/providers/CoreHealthCheckProvider.java index 3e2b56b8e303..1ad6080490af 100644 --- a/dotCMS/src/main/java/com/dotcms/health/providers/CoreHealthCheckProvider.java +++ b/dotCMS/src/main/java/com/dotcms/health/providers/CoreHealthCheckProvider.java @@ -5,6 +5,7 @@ import com.dotcms.health.checks.cdi.CacheHealthCheck; import com.dotcms.health.checks.cdi.DatabaseHealthCheck; import com.dotcms.health.checks.cdi.ElasticsearchHealthCheck; +import com.dotcms.health.checks.cdi.VelocityHealthCheck; import java.util.Arrays; import java.util.List; import javax.enterprise.context.ApplicationScoped; @@ -24,7 +25,8 @@ public List getHealthChecks() { return Arrays.asList( new DatabaseHealthCheck(), new CacheHealthCheck(), - new ElasticsearchHealthCheck() + new ElasticsearchHealthCheck(), + new VelocityHealthCheck() // Additional dependency health checks can be added here ); } diff --git a/dotCMS/src/test/java/com/dotcms/health/checks/cdi/VelocityHealthCheckTest.java b/dotCMS/src/test/java/com/dotcms/health/checks/cdi/VelocityHealthCheckTest.java new file mode 100644 index 000000000000..4fd6f3aa48ee --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/health/checks/cdi/VelocityHealthCheckTest.java @@ -0,0 +1,84 @@ +package com.dotcms.health.checks.cdi; + +import com.dotcms.health.model.HealthCheckResult; +import com.dotcms.health.model.HealthStatus; +import com.dotcms.rendering.velocity.util.VelocityUtil; +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.context.Context; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.Writer; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +/** + * Unit tests for {@link VelocityHealthCheck}. Exercises the probe via a mocked + * {@link VelocityEngine}; the engine's {@code evaluate} call writes a controlled + * output to the supplied Writer to simulate either a registered macro + * (any non-literal output) or an unregistered macro (literal directive text). + */ +public class VelocityHealthCheckTest { + + private MockedStatic velocityUtilMock; + + @Before + public void setUp() { + velocityUtilMock = mockStatic(VelocityUtil.class); + } + + @After + public void tearDown() { + velocityUtilMock.close(); + } + + @Test + public void returnsUpWhenRenderMarksResolves() { + stubEngineToWrite(""); + + final HealthCheckResult result = new VelocityHealthCheck().check(); + + assertEquals(HealthStatus.UP, result.status()); + } + + @Test + public void returnsDownWhenRenderMarksRendersLiterally() { + // When the global macro library failed to load, Velocity renders the + // directive source verbatim — the failure signature this check detects. + stubEngineToWrite("#renderMarks($null)"); + + final HealthCheckResult result = new VelocityHealthCheck().check(); + + assertEquals(HealthStatus.DOWN, result.status()); + } + + @Test + public void returnsDownWhenEngineThrows() { + // Defense-in-depth: with #35601's fail-loud flag enabled, engine init + // throws and VelocityUtil.getEngine() propagates a DotRuntimeException. + // The health check should report DOWN, not blow up the probe endpoint. + velocityUtilMock.when(VelocityUtil::getEngine) + .thenThrow(new RuntimeException("engine init failed")); + + final HealthCheckResult result = new VelocityHealthCheck().check(); + + assertEquals(HealthStatus.DOWN, result.status()); + } + + private void stubEngineToWrite(final String output) { + final VelocityEngine engine = mock(VelocityEngine.class); + doAnswer(invocation -> { + final Writer writer = invocation.getArgument(1); + writer.write(output); + return true; + }).when(engine).evaluate(any(Context.class), any(Writer.class), anyString(), anyString()); + velocityUtilMock.when(VelocityUtil::getEngine).thenReturn(engine); + } +}