From e6c087e36c7f2d6cd8d89b0038c277404650c1b7 Mon Sep 17 00:00:00 2001 From: "vivek.name: \"Vivek Reddy" Date: Fri, 24 Jul 2026 18:35:34 -0700 Subject: [PATCH 1/4] feat: add idempotent Splunk shutdown contract --- Makefile | 7 +- docs/SHUTDOWN_LIFECYCLE.md | 58 +++++++++ splunk/common-files/Dockerfile | 5 +- splunk/common-files/entrypoint.sh | 9 +- splunk/common-files/splunk-shutdown | 125 ++++++++++++++++++ tests/test_splunk_shutdown.py | 188 ++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 docs/SHUTDOWN_LIFECYCLE.md create mode 100755 splunk/common-files/splunk-shutdown create mode 100644 tests/test_splunk_shutdown.py diff --git a/Makefile b/Makefile index ea7a5391..5b57ddd3 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ else endif -.PHONY: tests interactive_tutorials +.PHONY: tests interactive_tutorials test_shutdown all: splunk uf splunk-py23 uf-py23 @@ -388,7 +388,10 @@ run_large_tests_redhat8: @echo 'Running the super awesome large tests; RedHat 8' pytest -n 2 --reruns 1 -sv tests/test_distributed_splunk_image.py --platform redhat-8 --junitxml test-results/redhat8-result/testresults_large_redhat8.xml -test_setup: +test_shutdown: + python3 -m unittest -v tests/test_splunk_shutdown.py + +test_setup: test_shutdown @echo 'Install test requirements' pip install --upgrade pip pip install -r $(shell pwd)/tests/requirements.txt --upgrade diff --git a/docs/SHUTDOWN_LIFECYCLE.md b/docs/SHUTDOWN_LIFECYCLE.md new file mode 100644 index 00000000..a3cd6e47 --- /dev/null +++ b/docs/SHUTDOWN_LIFECYCLE.md @@ -0,0 +1,58 @@ +# Splunk Container Shutdown Contract + +The full Splunk image exposes `/sbin/splunk-shutdown` as the single supported +local container-stop operation. PID 1 invokes it for TERM. Kubernetes `preStop` +hooks may invoke the same executable when their image compatibility check +confirms it is present. + +This contract is local to one container. It does not perform Search Head +detention, search draining, captain transfer, cluster membership changes, or +Kubernetes rollout orchestration. Those operations must finish before a +controller authorizes planned Pod replacement. Forced deletion, process crash, +OOM, and node loss may skip `preStop`; TERM therefore remains a first-class +caller. + +## Interface + +```text +/sbin/splunk-shutdown --source=term +/sbin/splunk-shutdown --source=prestop +``` + +`--source=manual` is available for diagnostics and direct qualification. +Unsupported arguments or source values return `2`. + +`SPLUNK_SHUTDOWN_TIMEOUT_SECONDS` controls the local stop deadline and defaults +to 600 seconds. It must be a positive integer and must fit inside the +Kubernetes termination grace period with time remaining for signal delivery +and forced cleanup. GNU `timeout` bounds the stop command and returns `124` +when the deadline expires. + +## State and ownership + +The operation stores bounded, non-secret evidence under +`$CONTAINER_ARTIFACT_DIR`: + +- `splunk-container.state` changes atomically to `stopping` before the stop + command starts; +- `splunk-shutdown.lock/owner` records the owner PID and caller source; and +- `splunk-shutdown.lock/result` records the stop exit status. + +Creating `splunk-shutdown.lock` is the single-owner decision. A concurrent +caller does not issue another stop. A later caller returns the recorded result, +so stop failure is not silently converted into success. The entrypoint TERM +trap logs that evidence but retains its existing best-effort container-exit +behavior. + +The lock is intentionally retained for the remaining life of the container. +The `restart` entrypoint action is a different operation: it stops and starts +Splunk without terminating the container and does not use this terminal +shutdown contract. + +## Qualification expectations + +Runtime qualification must cover direct TERM, preStop followed by TERM, +concurrent callers, repeated calls, stop failure, timeout, missing tooling, and +the stopping-state transition. Kubernetes qualification must additionally +measure Service and EndpointSlice withdrawal, actual stop duration, grace +expiration, force deletion, and node-loss recovery. diff --git a/splunk/common-files/Dockerfile b/splunk/common-files/Dockerfile index 809c09c3..50cdeb9e 100644 --- a/splunk/common-files/Dockerfile +++ b/splunk/common-files/Dockerfile @@ -94,7 +94,7 @@ ENV SPLUNK_ROLE=splunk_standalone \ USER root -COPY [ "splunk/common-files/entrypoint.sh", "splunk/common-files/createdefaults.py", "splunk/common-files/checkstate.sh", "/sbin/" ] +COPY [ "splunk/common-files/entrypoint.sh", "splunk/common-files/createdefaults.py", "splunk/common-files/checkstate.sh", "splunk/common-files/splunk-shutdown", "/sbin/" ] COPY splunk-ansible ${SPLUNK_ANSIBLE_HOME} # Set sudo rights @@ -113,7 +113,8 @@ RUN sed -i -e 's/%sudo\s\+ALL=(ALL\(:ALL\)\?)\s\+ALL/%sudo ALL=NOPASSWD:ALL\nans && chmod 775 ${SPLUNK_ANSIBLE_HOME} \ && chmod 664 ${SPLUNK_ANSIBLE_HOME}/ansible.cfg \ && sed -i '/^\[defaults\]/a\interpreter_python = /usr/bin/python3' ${SPLUNK_ANSIBLE_HOME}/ansible.cfg \ - && chmod 755 /sbin/entrypoint.sh /sbin/createdefaults.py /sbin/checkstate.sh + && chmod 755 /sbin/entrypoint.sh /sbin/createdefaults.py /sbin/checkstate.sh /sbin/splunk-shutdown \ + && command -v timeout USER ${ANSIBLE_USER} HEALTHCHECK --interval=30s --timeout=30s --start-period=3m --retries=5 CMD /sbin/checkstate.sh || exit 1 diff --git a/splunk/common-files/entrypoint.sh b/splunk/common-files/entrypoint.sh index 750bb8ec..2b63600a 100755 --- a/splunk/common-files/entrypoint.sh +++ b/splunk/common-files/entrypoint.sh @@ -31,11 +31,8 @@ setup() { } teardown() { - # Always run the stop command on termination - if [ `whoami` != "${SPLUNK_USER}" ]; then - RUN_AS_SPLUNK="sudo -u ${SPLUNK_USER}" - fi - ${RUN_AS_SPLUNK} ${SPLUNK_HOME}/bin/splunk stop || true + # TERM and preStop share one idempotent, bounded local shutdown operation. + /sbin/splunk-shutdown --source=term || true } trap teardown SIGINT SIGTERM @@ -147,6 +144,7 @@ Environment Variables: * SPLUNK_USER - user under which to run Splunk (default: splunk) * SPLUNK_GROUP - group under which to run Splunk (default: splunk) * SPLUNK_HOME - home directory where Splunk gets installed (default: /opt/splunk) + * SPLUNK_SHUTDOWN_TIMEOUT_SECONDS - maximum time allowed for a local Splunk stop before it is terminated (default: 600) * SPLUNK_START_ARGS - arguments to pass into the Splunk start command; you must include '--accept-license' to start Splunk (default: none) * SPLUNK_GENERAL_TERMS - with the value '--accept-sgt-current-at-splunk-com', indicates acceptance of the latest Splunk General Terms: https://www.splunk.com/en_us/legal/splunk-general-terms.html (default: none) * SPLUNK_PASSWORD - password to log into this Splunk instance, you must include a password (default: none) @@ -217,4 +215,3 @@ case "$1" in ;; esac - diff --git a/splunk/common-files/splunk-shutdown b/splunk/common-files/splunk-shutdown new file mode 100755 index 00000000..f9c78dc7 --- /dev/null +++ b/splunk/common-files/splunk-shutdown @@ -0,0 +1,125 @@ +#!/bin/sh + +# Copyright 2026 Splunk +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -u + +source_name="unknown" +case "${1:-}" in + --source=*) + source_name="${1#--source=}" + ;; + "") + ;; + *) + echo "splunk-shutdown: unsupported argument: $1" >&2 + exit 2 + ;; +esac + +case "${source_name}" in + term|prestop|manual|unknown) + ;; + *) + echo "splunk-shutdown: unsupported source: ${source_name}" >&2 + exit 2 + ;; +esac + +timeout_seconds="${SPLUNK_SHUTDOWN_TIMEOUT_SECONDS:-600}" +case "${timeout_seconds}" in + ""|*[!0-9]*|0) + echo "splunk-shutdown: SPLUNK_SHUTDOWN_TIMEOUT_SECONDS must be a positive integer" >&2 + exit 2 + ;; +esac + +artifact_dir="${CONTAINER_ARTIFACT_DIR:-/opt/container_artifact}" +state_file="${artifact_dir}/splunk-container.state" +lock_dir="${artifact_dir}/splunk-shutdown.lock" +owner_file="${lock_dir}/owner" +result_file="${lock_dir}/result" + +write_atomic() { + target_file="$1" + value="$2" + temporary_file="${target_file}.tmp.$$" + if ! printf '%s\n' "${value}" > "${temporary_file}"; then + return 1 + fi + if ! mv -f "${temporary_file}" "${target_file}"; then + rm -f "${temporary_file}" + return 1 + fi +} + +if ! mkdir -p "${artifact_dir}"; then + echo "splunk-shutdown: cannot create artifact directory ${artifact_dir}" >&2 + exit 1 +fi + +if ! mkdir "${lock_dir}" 2>/dev/null; then + if [ -r "${result_file}" ]; then + IFS= read -r existing_result < "${result_file}" + echo "splunk-shutdown: shutdown already completed result=${existing_result} source=${source_name}" + exit "${existing_result}" + fi + echo "splunk-shutdown: shutdown already in progress source=${source_name}" + exit 0 +fi + +owner_record="pid=$$ source=${source_name}" +if ! write_atomic "${owner_file}" "${owner_record}"; then + echo "splunk-shutdown: cannot record shutdown owner" >&2 + exit 1 +fi + +if ! write_atomic "${state_file}" "stopping"; then + echo "splunk-shutdown: cannot record stopping state" >&2 + write_atomic "${result_file}" "1" || true + exit 1 +fi + +if ! command -v timeout >/dev/null 2>&1; then + echo "splunk-shutdown: timeout command is unavailable" >&2 + write_atomic "${result_file}" "127" || true + exit 127 +fi + +splunk_home="${SPLUNK_HOME:-/opt/splunk}" +splunk_user="${SPLUNK_USER:-splunk}" +echo "splunk-shutdown: stop started source=${source_name} timeout_seconds=${timeout_seconds}" + +if [ "$(id -un)" = "${splunk_user}" ]; then + timeout --signal=TERM --kill-after=10s "${timeout_seconds}s" \ + "${splunk_home}/bin/splunk" stop + stop_result=$? +else + timeout --signal=TERM --kill-after=10s "${timeout_seconds}s" \ + sudo -u "${splunk_user}" "${splunk_home}/bin/splunk" stop + stop_result=$? +fi + +if ! write_atomic "${result_file}" "${stop_result}"; then + echo "splunk-shutdown: cannot record stop result=${stop_result}" >&2 + exit 1 +fi + +if [ "${stop_result}" -eq 0 ]; then + echo "splunk-shutdown: stop completed source=${source_name} result=0" +else + echo "splunk-shutdown: stop failed source=${source_name} result=${stop_result}" >&2 +fi +exit "${stop_result}" diff --git a/tests/test_splunk_shutdown.py b/tests/test_splunk_shutdown.py new file mode 100644 index 00000000..1e2b219f --- /dev/null +++ b/tests/test_splunk_shutdown.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +# encoding: utf-8 + +import os +import pwd +import shutil +import subprocess +import tempfile +import time +import unittest + + +REPOSITORY_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SHUTDOWN_SCRIPT = os.path.join( + REPOSITORY_ROOT, "splunk", "common-files", "splunk-shutdown" +) + + +def make_runtime(stop_exit_code=0, stop_delay_seconds=0): + runtime_dir = tempfile.mkdtemp(prefix="splunk-shutdown-test-") + artifact_dir = os.path.join(runtime_dir, "artifacts") + splunk_home = os.path.join(runtime_dir, "splunk") + splunk_bin = os.path.join(splunk_home, "bin") + os.makedirs(artifact_dir) + os.makedirs(splunk_bin) + + call_log = os.path.join(runtime_dir, "stop-calls") + state_at_stop = os.path.join(runtime_dir, "state-at-stop") + fake_splunk = os.path.join(splunk_bin, "splunk") + with open(fake_splunk, "w") as script: + script.write( + "#!/bin/sh\n" + "cat \"${CONTAINER_ARTIFACT_DIR}/splunk-container.state\" " + "> \"${SPLUNK_TEST_STATE_AT_STOP}\"\n" + "printf '%s\\n' \"$*\" >> \"${SPLUNK_TEST_CALL_LOG}\"\n" + "sleep \"${SPLUNK_TEST_STOP_DELAY_SECONDS}\"\n" + "exit \"${SPLUNK_TEST_STOP_EXIT_CODE}\"\n" + ) + os.chmod(fake_splunk, 0o755) + + environment = os.environ.copy() + environment.update( + { + "CONTAINER_ARTIFACT_DIR": artifact_dir, + "SPLUNK_HOME": splunk_home, + "SPLUNK_USER": pwd.getpwuid(os.getuid()).pw_name, + "SPLUNK_SHUTDOWN_TIMEOUT_SECONDS": "10", + "SPLUNK_TEST_CALL_LOG": call_log, + "SPLUNK_TEST_STATE_AT_STOP": state_at_stop, + "SPLUNK_TEST_STOP_DELAY_SECONDS": str(stop_delay_seconds), + "SPLUNK_TEST_STOP_EXIT_CODE": str(stop_exit_code), + } + ) + return runtime_dir, artifact_dir, call_log, environment + + +def run_shutdown(environment, source="manual"): + return subprocess.run( + [SHUTDOWN_SCRIPT, "--source={}".format(source)], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + + +def read_text(path): + with open(path, "r") as stream: + return stream.read() + + +class SplunkShutdownTest(unittest.TestCase): + + def runtime(self, stop_exit_code=0, stop_delay_seconds=0): + runtime = make_runtime(stop_exit_code, stop_delay_seconds) + self.addCleanup(shutil.rmtree, runtime[0]) + return runtime + + def test_shutdown_records_stopping_and_runs_stop_once(self): + _, artifact_dir, call_log, environment = self.runtime() + + first = run_shutdown(environment, "term") + second = run_shutdown(environment, "prestop") + + self.assertEqual(first.returncode, 0) + self.assertEqual(second.returncode, 0) + self.assertEqual( + read_text(os.path.join(artifact_dir, "splunk-container.state")), + "stopping\n", + ) + self.assertEqual( + read_text(os.path.join(artifact_dir, "splunk-shutdown.lock", "result")), + "0\n", + ) + self.assertEqual(read_text(call_log).splitlines(), ["stop"]) + self.assertEqual( + read_text(environment["SPLUNK_TEST_STATE_AT_STOP"]), "stopping\n" + ) + self.assertIn( + "source=term", + read_text(os.path.join(artifact_dir, "splunk-shutdown.lock", "owner")), + ) + + def test_concurrent_callers_have_one_stop_owner(self): + _, artifact_dir, call_log, environment = self.runtime(stop_delay_seconds=1) + + owner = subprocess.Popen( + [SHUTDOWN_SCRIPT, "--source=prestop"], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + owner_file = os.path.join(artifact_dir, "splunk-shutdown.lock", "owner") + deadline = time.time() + 5 + while not os.path.exists(owner_file) and time.time() < deadline: + time.sleep(0.01) + + follower = run_shutdown(environment, "term") + owner_stdout, owner_stderr = owner.communicate(timeout=5) + + self.assertEqual(owner.returncode, 0, (owner_stdout, owner_stderr)) + self.assertEqual(follower.returncode, 0) + self.assertIn("already in progress", follower.stdout) + self.assertEqual(read_text(call_log).splitlines(), ["stop"]) + + def test_stop_failure_is_preserved_without_second_stop(self): + _, artifact_dir, call_log, environment = self.runtime(stop_exit_code=7) + + first = run_shutdown(environment, "prestop") + second = run_shutdown(environment, "term") + + self.assertEqual(first.returncode, 7) + self.assertEqual(second.returncode, 7) + self.assertEqual( + read_text(os.path.join(artifact_dir, "splunk-shutdown.lock", "result")), + "7\n", + ) + self.assertEqual(read_text(call_log).splitlines(), ["stop"]) + + def test_stop_is_bounded_by_timeout(self): + _, artifact_dir, call_log, environment = self.runtime(stop_delay_seconds=5) + environment["SPLUNK_SHUTDOWN_TIMEOUT_SECONDS"] = "1" + + result = run_shutdown(environment, "term") + + self.assertEqual(result.returncode, 124) + self.assertEqual( + read_text(os.path.join(artifact_dir, "splunk-shutdown.lock", "result")), + "124\n", + ) + self.assertEqual(read_text(call_log).splitlines(), ["stop"]) + + def test_invalid_timeout_is_rejected_before_ownership(self): + _, artifact_dir, call_log, environment = self.runtime() + environment["SPLUNK_SHUTDOWN_TIMEOUT_SECONDS"] = "not-a-number" + + result = run_shutdown(environment) + + self.assertEqual(result.returncode, 2) + self.assertIn("positive integer", result.stderr) + self.assertFalse( + os.path.exists(os.path.join(artifact_dir, "splunk-shutdown.lock")) + ) + self.assertFalse(os.path.exists(call_log)) + + def test_image_and_term_handler_use_stable_shutdown_contract(self): + entrypoint = read_text( + os.path.join( + REPOSITORY_ROOT, "splunk", "common-files", "entrypoint.sh" + ) + ) + dockerfile = read_text( + os.path.join( + REPOSITORY_ROOT, "splunk", "common-files", "Dockerfile" + ) + ) + + self.assertIn("/sbin/splunk-shutdown --source=term", entrypoint) + self.assertNotIn("${SPLUNK_HOME}/bin/splunk stop || true", entrypoint) + self.assertIn("SPLUNK_SHUTDOWN_TIMEOUT_SECONDS", entrypoint) + self.assertIn('"splunk/common-files/splunk-shutdown"', dockerfile) + self.assertIn("/sbin/splunk-shutdown", dockerfile) + self.assertIn("command -v timeout", dockerfile) + + +if __name__ == "__main__": + unittest.main() From 90b11f56ef36d75982d2fab7a9f34abd92e0e128 Mon Sep 17 00:00:00 2001 From: "vivek.name: \"Vivek Reddy" Date: Sat, 25 Jul 2026 13:58:38 -0700 Subject: [PATCH 2/4] build: pin Splunk Ansible source ref --- Makefile | 16 ++++++- tests/test_ansible_ref.py | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/test_ansible_ref.py diff --git a/Makefile b/Makefile index 5b57ddd3..25b4fde7 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ NONQUOTE_IMAGE_VERSION := $(patsubst "%",%,$(IMAGE_VERSION)) DOCKER_BUILD_FLAGS ?= SPLUNK_ANSIBLE_REPO ?= https://github.com/splunk/splunk-ansible.git SPLUNK_ANSIBLE_BRANCH ?= develop +SPLUNK_ANSIBLE_REF ?= $(SPLUNK_ANSIBLE_BRANCH) SPLUNK_COMPOSE ?= cluster_absolute_unit.yaml # Set Splunk version/build parameters here to define downstream URLs and file names SPLUNK_PRODUCT := splunk @@ -49,10 +50,21 @@ all: splunk uf splunk-py23 uf-py23 ansible: @if [ -d "splunk-ansible" ]; then \ - echo "Ansible directory exists - skipping clone"; \ + echo "Ansible directory exists - verifying requested ref"; \ else \ - git clone ${SPLUNK_ANSIBLE_REPO} --branch ${SPLUNK_ANSIBLE_BRANCH}; \ + git clone "$(SPLUNK_ANSIBLE_REPO)" splunk-ansible; \ fi + @cd splunk-ansible && \ + if [ -n "$$(git status --porcelain --untracked-files=all | grep -v ' version.txt$$')" ]; then \ + echo "splunk-ansible contains local changes; refusing to replace it"; \ + exit 1; \ + fi && \ + if git cat-file -e '$(SPLUNK_ANSIBLE_REF)^{commit}' 2>/dev/null; then \ + git checkout --detach "$(SPLUNK_ANSIBLE_REF)"; \ + else \ + git fetch --depth 1 origin "$(SPLUNK_ANSIBLE_REF)" && \ + git checkout --detach FETCH_HEAD; \ + fi @cd splunk-ansible && git rev-parse HEAD > version.txt @cat splunk-ansible/version.txt diff --git a/tests/test_ansible_ref.py b/tests/test_ansible_ref.py new file mode 100644 index 00000000..898f892f --- /dev/null +++ b/tests/test_ansible_ref.py @@ -0,0 +1,92 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def run(command, cwd, check=True): + result = subprocess.run( + command, + cwd=cwd, + check=False, + capture_output=True, + text=True, + ) + if check and result.returncode: + raise AssertionError( + f"command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +class TestAnsibleRef(unittest.TestCase): + def setUp(self): + self.tempdir = Path(tempfile.mkdtemp(prefix="docker-splunk-ansible-ref-")) + self.source = self.tempdir / "source" + self.build = self.tempdir / "build" + self.source.mkdir() + self.build.mkdir() + shutil.copy(REPOSITORY_ROOT / "Makefile", self.build / "Makefile") + + run(["git", "init", "--quiet"], self.source) + run(["git", "config", "user.name", "Docker Splunk Test"], self.source) + run(["git", "config", "user.email", "docker-splunk-test@example.invalid"], self.source) + + (self.source / "content.txt").write_text("first\n", encoding="utf-8") + run(["git", "add", "content.txt"], self.source) + run(["git", "commit", "--quiet", "-m", "first"], self.source) + self.first_commit = run(["git", "rev-parse", "HEAD"], self.source).stdout.strip() + + (self.source / "content.txt").write_text("second\n", encoding="utf-8") + run(["git", "commit", "--quiet", "-am", "second"], self.source) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def make_ansible(self, expected_commit, check=True): + return run( + [ + "make", + "ansible", + f"SPLUNK_ANSIBLE_REPO={self.source}", + f"SPLUNK_ANSIBLE_REF={expected_commit}", + ], + self.build, + check=check, + ) + + def test_checks_out_and_records_exact_commit(self): + self.make_ansible(self.first_commit) + + checkout = self.build / "splunk-ansible" + actual_commit = run(["git", "rev-parse", "HEAD"], checkout).stdout.strip() + recorded_commit = (checkout / "version.txt").read_text(encoding="utf-8").strip() + + self.assertEqual(self.first_commit, actual_commit) + self.assertEqual(self.first_commit, recorded_commit) + + def test_refuses_to_replace_local_changes(self): + self.make_ansible(self.first_commit) + checkout = self.build / "splunk-ansible" + (checkout / "content.txt").write_text("local change\n", encoding="utf-8") + + result = self.make_ansible(self.first_commit, check=False) + + self.assertNotEqual(0, result.returncode) + self.assertIn("contains local changes", result.stdout) + + def test_rejects_unreachable_ref(self): + result = self.make_ansible("0" * 40, check=False) + + self.assertNotEqual(0, result.returncode) + checkout = self.build / "splunk-ansible" + self.assertFalse((checkout / "version.txt").exists()) + + +if __name__ == "__main__": + unittest.main() From 6376b01116da5bb68ac1e4534cc60ea422bf94c7 Mon Sep 17 00:00:00 2001 From: "vivek.name: \"Vivek Reddy" Date: Mon, 27 Jul 2026 14:03:15 -0700 Subject: [PATCH 3/4] build: fetch Python signing key over HTTPS --- base/redhat-9/Dockerfile | 2 +- base/redhat-9/install.sh | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/base/redhat-9/Dockerfile b/base/redhat-9/Dockerfile index 37e8d5c5..472db8ae 100644 --- a/base/redhat-9/Dockerfile +++ b/base/redhat-9/Dockerfile @@ -29,7 +29,7 @@ ARG BUSYBOX_URL ENV BUSYBOX_URL=${BUSYBOX_URL} \ PYTHON_VERSION=3.13.12 \ - PYTHON_GPG_KEY_ID=A821E680E5FA6305 + PYTHON_GPG_KEY_FINGERPRINT=7169605F62C751356D054A26A821E680E5FA6305 COPY install.sh /install.sh diff --git a/base/redhat-9/install.sh b/base/redhat-9/install.sh index 7062c641..c5fb43d6 100755 --- a/base/redhat-9/install.sh +++ b/base/redhat-9/install.sh @@ -44,10 +44,10 @@ microdnf -y --nodocs update gnutls kernel-headers libdnf librepo libnghttp2 nett PY_SHORT=${PYTHON_VERSION%.*} wget -O /tmp/python.tgz https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz wget -O /tmp/Python-gpg-sig-${PYTHON_VERSION}.tgz.asc https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz.asc -gpg --keyserver keys.openpgp.org --recv-keys $PYTHON_GPG_KEY_ID \ - || gpg --keyserver pool.sks-keyservers.net --recv-keys $PYTHON_GPG_KEY_ID \ - || gpg --keyserver pgp.mit.edu --recv-keys $PYTHON_GPG_KEY_ID \ - || gpg --keyserver keyserver.pgp.com --recv-keys $PYTHON_GPG_KEY_ID +wget -O /tmp/python-release-key.asc \ + https://keys.openpgp.org/vks/v1/by-fingerprint/${PYTHON_GPG_KEY_FINGERPRINT} +gpg --batch --import /tmp/python-release-key.asc +rm /tmp/python-release-key.asc gpg --verify /tmp/Python-gpg-sig-${PYTHON_VERSION}.tgz.asc /tmp/python.tgz rm /tmp/Python-gpg-sig-${PYTHON_VERSION}.tgz.asc mkdir -p /tmp/pyinstall From 7951d69f82b28d92b118432bea4a513a90a76749 Mon Sep 17 00:00:00 2001 From: Vivek Reddy Date: Tue, 28 Jul 2026 02:03:53 +0000 Subject: [PATCH 4/4] fix: exit container after TERM shutdown --- splunk/common-files/entrypoint.sh | 10 ++++-- tests/test_splunk_shutdown.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/splunk/common-files/entrypoint.sh b/splunk/common-files/entrypoint.sh index 2b63600a..87a517a0 100755 --- a/splunk/common-files/entrypoint.sh +++ b/splunk/common-files/entrypoint.sh @@ -32,7 +32,14 @@ setup() { teardown() { # TERM and preStop share one idempotent, bounded local shutdown operation. - /sbin/splunk-shutdown --source=term || true + # Reset the traps before running the bounded stop so a second signal cannot + # enter the handler recursively. PID 1 must exit after the stop completes; + # otherwise Kubernetes waits for the entire termination grace period even + # though splunkd is already stopped. + trap - SIGINT SIGTERM + local shutdown_result=0 + /sbin/splunk-shutdown --source=term || shutdown_result=$? + exit "${shutdown_result}" } trap teardown SIGINT SIGTERM @@ -214,4 +221,3 @@ case "$1" in help $@ ;; esac - diff --git a/tests/test_splunk_shutdown.py b/tests/test_splunk_shutdown.py index 1e2b219f..6d4a9142 100644 --- a/tests/test_splunk_shutdown.py +++ b/tests/test_splunk_shutdown.py @@ -3,6 +3,7 @@ import os import pwd +import signal import shutil import subprocess import tempfile @@ -183,6 +184,60 @@ def test_image_and_term_handler_use_stable_shutdown_contract(self): self.assertIn("/sbin/splunk-shutdown", dockerfile) self.assertIn("command -v timeout", dockerfile) + def test_term_handler_exits_entrypoint_after_shutdown(self): + runtime_dir = tempfile.mkdtemp(prefix="splunk-entrypoint-term-test-") + self.addCleanup(shutil.rmtree, runtime_dir) + shutdown_log = os.path.join(runtime_dir, "shutdown-calls") + fake_shutdown = os.path.join(runtime_dir, "splunk-shutdown") + with open(fake_shutdown, "w") as script: + script.write( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"{}\"\n" + "exit 0\n".format(shutdown_log) + ) + os.chmod(fake_shutdown, 0o755) + + entrypoint_copy = os.path.join(runtime_dir, "entrypoint.sh") + entrypoint_source = read_text( + os.path.join( + REPOSITORY_ROOT, "splunk", "common-files", "entrypoint.sh" + ) + ) + with open(entrypoint_copy, "w") as script: + script.write( + entrypoint_source.replace( + "/sbin/splunk-shutdown", + fake_shutdown, + ) + ) + os.chmod(entrypoint_copy, 0o755) + + process = subprocess.Popen( + [entrypoint_copy, "no-provision"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + start_new_session=True, + ) + try: + # Wait until the no-provision path is blocked in its long-running + # wait, which matches the steady-state PID 1 behavior in the + # container. + time.sleep(1) + process.send_signal(signal.SIGTERM) + return_code = process.wait(timeout=5) + self.assertEqual(return_code, 0) + self.assertEqual( + read_text(shutdown_log).splitlines(), + ["--source=term"], + ) + finally: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.communicate(timeout=5) + if __name__ == "__main__": unittest.main()