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:
+ *
+ * - {@code health.check.velocity.mode} — PRODUCTION (default), MONITOR_MODE, DISABLED
+ *
+ */
+@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);
+ }
+}