Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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).
*
* <p>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.
*
* <p>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.
*
* <p>Configuration:
* <ul>
* <li>{@code health.check.velocity.mode} — PRODUCTION (default), MONITOR_MODE, DISABLED</li>
* </ul>
*/
@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)";
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +25,8 @@ public List<HealthCheck> getHealthChecks() {
return Arrays.asList(
new DatabaseHealthCheck(),
new CacheHealthCheck(),
new ElasticsearchHealthCheck()
new ElasticsearchHealthCheck(),
new VelocityHealthCheck()
// Additional dependency health checks can be added here
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<VelocityUtil> velocityUtilMock;

@Before
public void setUp() {
velocityUtilMock = mockStatic(VelocityUtil.class);
}

@After
public void tearDown() {
velocityUtilMock.close();
}

@Test
public void returnsUpWhenRenderMarksResolves() {
stubEngineToWrite("<span class=\"editor-marks\"></span>");

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);
}
}
Loading