diff --git a/handler/build.gradle b/handler/build.gradle index ea984fc..1d614eb 100644 --- a/handler/build.gradle +++ b/handler/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'io.jeyong' -version = '1.2.0' +version = '1.2.1' ext { artifactName = 'k8s-sigterm-handler' diff --git a/handler/src/main/java/io/jeyong/handler/ApplicationTerminator.java b/handler/src/main/java/io/jeyong/handler/ApplicationTerminator.java index 05c9f71..1dc168a 100644 --- a/handler/src/main/java/io/jeyong/handler/ApplicationTerminator.java +++ b/handler/src/main/java/io/jeyong/handler/ApplicationTerminator.java @@ -4,8 +4,19 @@ public abstract class ApplicationTerminator { + private final String terminationMessagePath; + private final String terminationMessage; + + protected ApplicationTerminator(final String terminationMessagePath, final String terminationMessage) { + this.terminationMessagePath = terminationMessagePath; + this.terminationMessage = terminationMessage; + } + public SignalHandler handleTermination() { - return signal -> System.exit(getExitCode()); + return signal -> { + FileUtils.writeToFile(terminationMessagePath, terminationMessage); + System.exit(getExitCode()); + }; } protected abstract int getExitCode(); diff --git a/handler/src/main/java/io/jeyong/handler/FileUtils.java b/handler/src/main/java/io/jeyong/handler/FileUtils.java new file mode 100644 index 0000000..8d7eda3 --- /dev/null +++ b/handler/src/main/java/io/jeyong/handler/FileUtils.java @@ -0,0 +1,38 @@ +package io.jeyong.handler; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FileUtils { + + private static final Logger logger = LoggerFactory.getLogger(FileUtils.class); + + private FileUtils() { + } + + public static void writeToFile(final String filePath, final String message) { + if (filePath == null || filePath.isBlank()) { + return; + } + + final File file = new File(filePath); + try { + createParentDirectories(file); + try (FileWriter writer = new FileWriter(file)) { + writer.write(message); + } + } catch (IOException e) { + logger.error("Failed to write to file {}: {}", filePath, e.getMessage()); + } + } + + private static void createParentDirectories(final File file) throws IOException { + final File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create parent directories for " + file.getAbsolutePath()); + } + } +} diff --git a/handler/src/main/java/io/jeyong/handler/SigtermHandlerConfiguration.java b/handler/src/main/java/io/jeyong/handler/SigtermHandlerConfiguration.java index ef36017..009ec34 100644 --- a/handler/src/main/java/io/jeyong/handler/SigtermHandlerConfiguration.java +++ b/handler/src/main/java/io/jeyong/handler/SigtermHandlerConfiguration.java @@ -8,7 +8,7 @@ @Configuration @ConditionalOnProperty( - prefix = "kubernetes.handler", + prefix = "kubernetes.sigterm-handler", name = "enabled", havingValue = "true", matchIfMissing = true @@ -26,7 +26,8 @@ public SigtermHandlerConfiguration(final SigtermHandlerProperties sigtermHandler @Bean public ApplicationTerminator applicationTerminator(final ApplicationContext applicationContext) { - return new SpringContextTerminator(applicationContext, sigtermHandlerProperties.getExitCode()); + return new SpringContextTerminator(applicationContext, sigtermHandlerProperties.getExitCode(), + sigtermHandlerProperties.getTerminationMessagePath(), sigtermHandlerProperties.getTerminationMessage()); } @Bean diff --git a/handler/src/main/java/io/jeyong/handler/SigtermHandlerProperties.java b/handler/src/main/java/io/jeyong/handler/SigtermHandlerProperties.java index ef8210b..5fa0bbd 100644 --- a/handler/src/main/java/io/jeyong/handler/SigtermHandlerProperties.java +++ b/handler/src/main/java/io/jeyong/handler/SigtermHandlerProperties.java @@ -1,53 +1,62 @@ package io.jeyong.handler; +import org.springframework.boot.context.properties.ConfigurationProperties; + // @formatter:off -import org.springframework.boot.context.properties.ConfigurationProperties; /** +/** * Configuration properties for Kubernetes SIGTERM handling. * *

- * Allows customization of the SIGTERM handler's behavior, including whether it is enabled and the exit code to use - * during graceful termination. + * Allows customization of the Sigterm Handler's behavior, including whether it is enabled, + * the exit code to use during graceful termination, and optional termination message settings. *

* * * *
  * Example configuration (YAML):
  * {@code
  * kubernetes:
- *   handler:
+ *   sigterm-handler:
  *     enabled: true
- *     exit-code: 1
+ *     exit-code: 0
+ *     termination-message-path: /dev/termination-log
+ *     termination-message: SIGTERM signal received...
  * }
  * 
* *
  * Example configuration (Properties):
  * {@code
- * kubernetes.handler.enabled=true
- * kubernetes.handler.exit-code=1
+ * kubernetes.sigterm-handler.enabled=true
+ * kubernetes.sigterm-handler.exit-code=0
+ * kubernetes.sigterm-handler.termination-message-path=/dev/termination-log
+ * kubernetes.sigterm-handler.termination-message=SIGTERM signal received...
  * }
  * 
* *

- * By default, the handler is enabled, and the application terminates with an exit code of 0, - * marking the Kubernetes Pod as "Completed." + * The Sigterm Handler is primarily designed for Kubernetes + * but can also be utilized in Docker or other environments requiring signal handling functionality. *

* * @author jeyong - * @since 1.0 + * @since 1.2 * @see SigtermHandlerConfiguration */ // @formatter:on -@ConfigurationProperties(prefix = "kubernetes.handler") +@ConfigurationProperties(prefix = "kubernetes.sigterm-handler") public class SigtermHandlerProperties { private boolean enabled = true; - private int exitCode = 0; + private String terminationMessagePath; + private String terminationMessage = "SIGTERM signal received. Application has been terminated successfully."; public boolean isEnabled() { return enabled; @@ -64,4 +73,20 @@ public int getExitCode() { public void setExitCode(final int exitCode) { this.exitCode = exitCode; } + + public String getTerminationMessagePath() { + return terminationMessagePath; + } + + public void setTerminationMessagePath(final String terminationMessagePath) { + this.terminationMessagePath = terminationMessagePath; + } + + public String getTerminationMessage() { + return terminationMessage; + } + + public void setTerminationMessage(final String terminationMessage) { + this.terminationMessage = terminationMessage; + } } diff --git a/handler/src/main/java/io/jeyong/handler/SpringContextTerminator.java b/handler/src/main/java/io/jeyong/handler/SpringContextTerminator.java index 3ba3d1d..3053305 100644 --- a/handler/src/main/java/io/jeyong/handler/SpringContextTerminator.java +++ b/handler/src/main/java/io/jeyong/handler/SpringContextTerminator.java @@ -8,7 +8,9 @@ public class SpringContextTerminator extends ApplicationTerminator { private final ApplicationContext applicationContext; private final int exitCode; - public SpringContextTerminator(final ApplicationContext applicationContext, final int exitCode) { + public SpringContextTerminator(final ApplicationContext applicationContext, final int exitCode, + final String terminationMessagePath, final String terminationMessage) { + super(terminationMessagePath, terminationMessage); this.applicationContext = applicationContext; this.exitCode = exitCode; } diff --git a/test/src/test/java/io/jeyong/test/integration/SigtermHandlerTest.java b/test/src/test/java/io/jeyong/test/integration/SigtermHandlerTest.java index 0681edc..9ea290d 100644 --- a/test/src/test/java/io/jeyong/test/integration/SigtermHandlerTest.java +++ b/test/src/test/java/io/jeyong/test/integration/SigtermHandlerTest.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,15 +23,26 @@ @DisplayName("SigtermHandler Integration Test") public class SigtermHandlerTest { + private static final int EXPECTED_EXIT_CODE = 10; + private static final String TERMINATION_MESSAGE_PATH = "/app/termination-message.message"; + private static final String TERMINATION_MESSAGE = "Test termination message"; + + private static Path codeDir; private static ImageFromDockerfile dockerImage; @BeforeAll - static void setUp() { - dockerImage = buildImage(); + static void setUp() throws Exception { + codeDir = createCodeDirectory(); + dockerImage = buildImage(codeDir); + } + + @AfterAll + static void tearDown() throws Exception { + FileUtils.deleteDirectory(codeDir.toFile()); } @Test - @DisplayName("Container should exit with code 0 on SIGTERM") + @DisplayName("Container should exit with expected code on SIGTERM") void testExitCode() throws Exception { // given GenericContainer container = new GenericContainer<>(dockerImage) @@ -42,7 +54,7 @@ void testExitCode() throws Exception { // then Long exitCode = container.getCurrentContainerInfo().getState().getExitCodeLong(); - assertThat(exitCode).isEqualTo(0); + assertThat(exitCode).isEqualTo(EXPECTED_EXIT_CODE); } @Test @@ -65,10 +77,52 @@ void testCleanUp() throws Exception { }); } - private static ImageFromDockerfile buildImage() { - Path tempDir = createTempDirectory(); + @Test + @DisplayName("Check termination message file inside container") + void testTerminationMessageFile() throws Exception { + // given + GenericContainer container = new GenericContainer<>(dockerImage) + .waitingFor(forLogMessage(".*Started TestApplication.*\\n", 1)); + container.start(); + + // when + sendSigtermToContainer(container); + + // then + String fileContent = container.copyFileFromContainer( + TERMINATION_MESSAGE_PATH, + content -> new String(content.readAllBytes()) + ); + assertThat(fileContent).isEqualTo(TERMINATION_MESSAGE); + } + + private static Path createCodeDirectory() throws Exception { + Path codeDir = Files.createTempDirectory("docker-context"); + copyFile("../gradlew", codeDir.resolve("gradlew")); + copyDirectory("../gradle", codeDir.resolve("gradle")); + copyFile("../settings.gradle", codeDir.resolve("settings.gradle")); + copyDirectory("../handler", codeDir.resolve("handler")); + copyDirectory("../test", codeDir.resolve("test")); + createApplicationYaml(codeDir.resolve("test/src/main/resources")); + return codeDir; + } + + private static void createApplicationYaml(Path resourcesDir) throws Exception { + Files.createDirectories(resourcesDir); + Path applicationYaml = resourcesDir.resolve("application.yml"); + String yamlContent = String.format(""" + kubernetes: + sigterm-handler: + exit-code: %d + termination-message-path: %s + termination-message: %s + """, EXPECTED_EXIT_CODE, TERMINATION_MESSAGE_PATH, TERMINATION_MESSAGE); + Files.writeString(applicationYaml, yamlContent); + } + + private static ImageFromDockerfile buildImage(Path sourceCodeDir) { return new ImageFromDockerfile() - .withFileFromPath(".", tempDir) + .withFileFromPath(".", sourceCodeDir) .withDockerfileFromBuilder(builder -> { builder.from("eclipse-temurin:17") .workDir("/app") @@ -84,20 +138,6 @@ private static ImageFromDockerfile buildImage() { }); } - private static Path createTempDirectory() { - try { - Path tempDir = Files.createTempDirectory("docker-context"); - copyFile("../gradlew", tempDir.resolve("gradlew")); - copyDirectory("../gradle", tempDir.resolve("gradle")); - copyFile("../settings.gradle", tempDir.resolve("settings.gradle")); - copyDirectory("../handler", tempDir.resolve("handler")); - copyDirectory("../test", tempDir.resolve("test")); - return tempDir; - } catch (Exception e) { - throw new RuntimeException("Failed to create temp directory for Docker context", e); - } - } - private static void copyFile(String source, Path destination) throws Exception { FileUtils.copyFile(new File(source), destination.toFile()); } diff --git a/test/src/test/java/io/jeyong/test/unit/ApplicationTerminatorTest.java b/test/src/test/java/io/jeyong/test/unit/ApplicationTerminatorTest.java index f341a5d..0b15005 100644 --- a/test/src/test/java/io/jeyong/test/unit/ApplicationTerminatorTest.java +++ b/test/src/test/java/io/jeyong/test/unit/ApplicationTerminatorTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat; import io.jeyong.handler.ApplicationTerminator; +import java.io.File; +import java.nio.file.Files; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import sun.misc.SignalHandler; @@ -11,13 +13,39 @@ @DisplayName("ApplicationTerminator Unit Test") public class ApplicationTerminatorTest { + @Test + @DisplayName("FileUtils.writeToFile should create a file with correct content") + void testFileCreationAndContent() throws Exception { + // given + File tempFile = Files.createTempFile("termination-", ".txt").toFile(); + String terminationMessagePath = tempFile.getAbsolutePath(); + String expectedMessage = "Test termination message"; + + ApplicationTerminator terminator = new ApplicationTerminator(terminationMessagePath, expectedMessage) { + + @Override + protected int getExitCode() { + return 0; + } + }; + + // when + catchSystemExit(() -> terminator.handleTermination().handle(null)); + + // then + assertThat(Files.readString(tempFile.toPath())).isEqualTo(expectedMessage); + + // Clean up + Files.deleteIfExists(tempFile.toPath()); + } + @Test @DisplayName("System.exit should be called with the expected exit code") void testHandleTerminationCallsSystemExit() throws Exception { // given int expectedExitCode = 0; - ApplicationTerminator terminator = new ApplicationTerminator() { - + ApplicationTerminator terminator = new ApplicationTerminator(null, null) { + @Override protected int getExitCode() { return expectedExitCode; diff --git a/test/src/test/java/io/jeyong/test/unit/SignalHandlerRegistrarTest.java b/test/src/test/java/io/jeyong/test/unit/SignalHandlerRegistrarTest.java index f95bf8b..9aa3d48 100644 --- a/test/src/test/java/io/jeyong/test/unit/SignalHandlerRegistrarTest.java +++ b/test/src/test/java/io/jeyong/test/unit/SignalHandlerRegistrarTest.java @@ -19,7 +19,7 @@ void testSignalHandlerRegistration() { String signalType = "TERM"; int exitCode = 0; SignalHandler expectedHandler = signal -> System.exit(exitCode); - ApplicationTerminator terminator = new ApplicationTerminator() { + ApplicationTerminator terminator = new ApplicationTerminator(null, null) { @Override public SignalHandler handleTermination() { diff --git a/test/src/test/java/io/jeyong/test/unit/SigtermHandlerPropertiesTest.java b/test/src/test/java/io/jeyong/test/unit/SigtermHandlerPropertiesTest.java index 8fc7a6e..e1b0102 100644 --- a/test/src/test/java/io/jeyong/test/unit/SigtermHandlerPropertiesTest.java +++ b/test/src/test/java/io/jeyong/test/unit/SigtermHandlerPropertiesTest.java @@ -15,12 +15,18 @@ @DisplayName("SigtermHandlerProperties Unit Test") class SigtermHandlerPropertiesTest { + private static final int EXPECTED_EXIT_CODE = 10; + private static final String TERMINATION_MESSAGE_PATH = "/termination-message.message"; + private static final String TERMINATION_MESSAGE = "Test termination message"; + @Nested @SpringBootTest( classes = TestApplication.class, properties = { - "kubernetes.handler.enabled=true", - "kubernetes.handler.exit-code=1000" + "kubernetes.sigterm-handler.enabled=true", + "kubernetes.sigterm-handler.exit-code=" + EXPECTED_EXIT_CODE, + "kubernetes.sigterm-handler.termination-message-path=" + TERMINATION_MESSAGE_PATH, + "kubernetes.sigterm-handler.termination-message=" + TERMINATION_MESSAGE, } ) @DisplayName("handler enabled") @@ -30,11 +36,11 @@ class EnabledTests { private ApplicationContext applicationContext; @Autowired - private SigtermHandlerProperties sigtermHandlerProperties; + private SigtermHandlerProperties properties; @Test - @DisplayName("Register Configuration with the configured exit code") - void registerConfiguration() { + @DisplayName("Register Configuration with the configured properties") + void testRegisterConfiguration() { // given & when boolean beanExists = applicationContext.containsBeanDefinition( "io.jeyong.handler.SigtermHandlerConfiguration"); @@ -42,7 +48,9 @@ void registerConfiguration() { // then assertSoftly(softly -> { softly.assertThat(beanExists).isTrue(); - softly.assertThat(sigtermHandlerProperties.getExitCode()).isEqualTo(1000); + softly.assertThat(properties.getExitCode()).isEqualTo(EXPECTED_EXIT_CODE); + softly.assertThat(properties.getTerminationMessagePath()).isEqualTo(TERMINATION_MESSAGE_PATH); + softly.assertThat(properties.getTerminationMessage()).isEqualTo(TERMINATION_MESSAGE); }); } } @@ -51,7 +59,7 @@ void registerConfiguration() { @SpringBootTest( classes = TestApplication.class, properties = { - "kubernetes.handler.enabled=false", + "kubernetes.sigterm-handler.enabled=false", } ) @DisplayName("handler disabled") @@ -62,7 +70,7 @@ class DisabledTests { @Test @DisplayName("Does not register Configuration") - void doesNotRegisterConfiguration() { + void testDoesNotRegisterConfiguration() { // given & when boolean beanExists = applicationContext.containsBeanDefinition( "io.jeyong.handler.SigtermHandlerConfiguration");