#!/usr/bin/env python3
"""Real p4d/systemd/SSH integration, only inside a marked disposable container.
JSON on stdout records each scenario and its exact behavioral scope. Handled
failures release owned checkpoint locks; recovery data remains for investigation.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import pwd
import shutil
import socket
import subprocess
import tempfile
import time
from fixture_inventory import FixtureInventory
BIN = Path("/p4/common/bin")
class RuntimeIntegration:
def __init__(self, args):
self.args = args
self.records = []
self.env = self.validate()
self.home = Path(self.env["P4HOME"])
self.logs = Path(self.env["LOGS"])
self.counter = "sdp.disposable.runtime." + str(int(time.time()))
self.fixture = FixtureInventory(self.env, "sdp-fixture-runtime-" + str(time.time_ns()))
def validate(self):
if not self.args.confirm_disposable or not Path("/run/sdp-container-test").is_file():
raise RuntimeError("Explicit confirmation and container marker are required")
if pwd.getpwuid(os.getuid()).pw_name != self.args.os_user:
raise RuntimeError("Run as the marked disposable OS account")
env = dict(os.environ, TEST_ROOT=self.args.root, INSTANCE=self.args.instance,
TEST_PORT=str(self.args.port), TEST_OSUSER=self.args.os_user)
shell = '''source /p4/sdp/test/disposable_test_guard.sh || exit 2
sdp_test_validate_marker || exit 2
source /p4/common/bin/p4_vars "$INSTANCE" || exit 2
[[ "$P4PORT" == "127.0.0.1:$TEST_PORT" && "$OSUSER" == "$TEST_OSUSER" &&
"$SERVERID" == "sdp-test-$INSTANCE" && "$SDPMAIL" == /bin/true ]] || exit 2
env -0'''
result = subprocess.run(["bash", "-c", shell], env=env, capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError("Disposable configuration guard failed: " + result.stderr.decode(errors="replace"))
loaded = dict(item.decode().split("=", 1) for item in result.stdout.split(b"\0") if item)
root = Path(loaded["P4ROOT"]).resolve()
if not root.is_relative_to(Path(self.args.root)):
raise RuntimeError("P4ROOT escapes the disposable deployment")
info = subprocess.check_output([loaded["P4BIN"], "-p", loaded["P4PORT"], "-ztag", "info"], env=loaded, timeout=30).decode()
info_tags = {fields[1].lower(): fields[2] for line in info.splitlines()
if len(fields := line.split(None, 2)) == 3 and fields[0] == "..."}
if info_tags.get("serverid") != "sdp-test-" + self.args.instance:
raise RuntimeError("Unexpected running server identity")
return loaded
def command(self, argv, *, check=True, timeout=900, env=None):
result = subprocess.run([str(x) for x in argv], env=env or self.env, cwd=self.home,
capture_output=True, text=True, timeout=timeout)
if check and result.returncode:
raise RuntimeError("Command failed (%s): %s\n%s" % (result.returncode, argv[0], (result.stdout + result.stderr)[-5000:]))
return result
def script(self, name):
result = self.command(["bash", BIN / name, self.args.instance], check=False)
if result.returncode:
detail = (result.stdout + result.stderr)[-3000:]
for logfile in [self.logs / "checkpoint.log", self.logs / (name.removesuffix(".sh") + ".log")]:
if logfile.is_file():
detail += "\n" + logfile.read_text(errors="replace")[-5000:]
raise RuntimeError(name + " failed: " + detail)
if (self.logs / "ckp_running.txt").exists():
raise RuntimeError(name + " did not release its successful-run lock")
def scenario(self, name, scripts, detail, operation):
started = time.monotonic()
row = {"name": name, "scripts": scripts, "detail": detail, "kind": "live"}
try:
evidence = operation()
if evidence is not None:
row["evidence"] = evidence
row["status"] = "passed"
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
row.update(status="failed", error=str(error))
row["duration_seconds"] = round(time.monotonic() - started, 3)
self.records.append(row)
def checkpoint_cycle(self):
self.fixture.create()
self.command([self.env["P4BIN"], "counter", self.counter, "12345"])
self.script("live_checkpoint.sh")
self.script("daily_checkpoint.sh")
self.script("recreate_offline_db.sh")
marker = Path(self.env["P4HOME"]) / "offline_db/offline_db_usable.txt"
if not marker.is_file():
raise RuntimeError("Successful restore did not leave its usability marker")
value = self.command([self.env["P4DBIN"], "-r", marker.parent, "-jd", "-", "db.counters"]).stdout
if self.counter not in value or "12345" not in value:
raise RuntimeError("Real checkpoint restore lost the integration counter")
# Serve a copy of the actual restored offline DB, leaving its usability
# state intact, and inspect every fixture revision/spec/shelf.
clone = Path(tempfile.mkdtemp(prefix="sdp-restored-inventory-", dir=self.home / "tmp"))
probe_stopped = False
try:
for table in marker.parent.glob("db.*"):
shutil.copy2(table, clone / table.name)
(clone / "server.id").write_text("sdp-test-restored-inventory\n")
port = "127.0.0.1:20001"
self.command([self.env["P4DBIN"], "-r", clone, "-p", port, "-J", "off", "-L", clone / "log", "--daemonsafe", "--pid-file=" + str(clone / "pid")])
try:
password = Path(self.env["SDP_ADMIN_PASSWORD_FILE"]).read_bytes()
self.fixture.p4("login", "-a", data=password, port=port)
self.fixture.assert_unchanged(port)
finally:
pid = int((clone / "pid").read_text())
self.fixture.p4("admin", "stop", port=port)
deadline = time.monotonic() + 30
while Path("/proc/%s" % pid).exists():
if time.monotonic() >= deadline:
raise RuntimeError("Read-only restored probe did not stop before scratch cleanup")
time.sleep(0.05)
probe_stopped = True
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
if not probe_stopped:
raise RuntimeError("Restored probe scratch retained at %s: %s" % (clone, error)) from error
raise
finally:
if probe_stopped:
shutil.rmtree(clone)
(self.logs / "runtime-fixture-inventory.json").write_text(json.dumps(self.fixture.expected, indent=2) + "\n")
def rotation(self):
before = int(self.command([self.env["P4BIN"], "counter", "journal"]).stdout.strip())
prefix = self.env["P4CKP_PREFIX"] + ".jnl."
def raw_numbers():
return sorted(int(p.name.removeprefix(prefix)) for p in Path(self.env["JOURNALS"]).glob(prefix + "*")
if p.name.removeprefix(prefix).isdigit())
for _ in range(3):
number = int(self.command([self.env["P4BIN"], "counter", "journal"]).stdout.strip())
expected = raw_numbers() + [number]
# Preserve the existing policy: >2 enables archiving; with 3/4
# journals archive one, with >=5 keep the newest four.
if len(expected) > 2:
expected = expected[1:] if len(expected) < 5 else expected[-4:]
self.script("rotate_journal.sh")
if raw_numbers() != expected:
raise RuntimeError("Journal gzip policy kept %r, expected %r" % (raw_numbers(), expected))
after = int(self.command([self.env["P4BIN"], "counter", "journal"]).stdout.strip())
if after - before != 3:
raise RuntimeError("Three successful rotations did not advance the counter by three")
def client_config(self, name):
return {row["ServerName"]: row.get("Value", "")
for row in self.fixture.p4("configure", "show", "allservers")
if row.get("Name") == name and "ServerName" in row}
def replace_client_config(self, name, values):
# These tests are already restricted to a marked disposable deployment.
# Preserve every original scope, including deliberately empty values.
for scope in self.client_config(name):
self.fixture.p4("configure", "unset", scope + "#" + name)
for scope, value in values.items():
self.fixture.p4("configure", "set", scope + "#" + name + "=" + value)
if self.client_config(name) != values:
raise RuntimeError("Server did not retain exact fixture configuration for " + name)
@staticmethod
def seed_sendq_tree(target, outside):
target.mkdir(parents=True)
nested = target / "nested directory"
nested.mkdir()
outside.mkdir(parents=True)
sentinel = outside / "keep exact bytes.txt"
sentinel.write_bytes(b"unrelated symlink target\n")
files = [target / "queue.db", nested / "queue with spaces.db"]
for path in files:
path.write_bytes(b"disposable send queue\n")
links = [target / "external file", nested / "external directory", nested / "broken link"]
links[0].symlink_to(sentinel)
links[1].symlink_to(outside, target_is_directory=True)
links[2].symlink_to(outside / "absent")
fifo = nested / "preserve fifo"
os.mkfifo(fifo)
return {"root": target, "directories": [target, nested], "files": files,
"links": links, "link_targets": {path: os.readlink(path) for path in links},
"fifo": fifo, "sentinel": sentinel,
"directory_inodes": {path: path.stat().st_ino for path in (target, nested)}}
@staticmethod
def assert_sendq_tree(tree, cleaned):
if not all(path.is_dir() and not path.is_symlink() for path in tree["directories"]):
raise RuntimeError("Send-queue cleanup removed or replaced its root/directories")
if any(path.stat().st_ino != inode for path, inode in tree["directory_inodes"].items()):
raise RuntimeError("Send-queue cleanup replaced an existing directory")
for path in tree["files"] + tree["links"]:
if os.path.lexists(path) == cleaned:
raise RuntimeError("Unexpected send-queue entry state: " + str(path))
if not cleaned:
if any(path.read_bytes() != b"disposable send queue\n" for path in tree["files"]):
raise RuntimeError("Unselected/shared send-queue file bytes changed")
if any(os.readlink(path) != destination for path, destination in tree["link_targets"].items()):
raise RuntimeError("Unselected/shared send-queue symlink changed")
if not tree["fifo"].is_fifo():
raise RuntimeError("Send-queue cleanup removed a non-file special entry")
if tree["sentinel"].read_bytes() != b"unrelated symlink target\n":
raise RuntimeError("Send-queue cleanup followed a nested symlink")
def sendq_helper(self, *, identity=None, root_override=None):
shell = """source /p4/common/bin/p4_vars "$1" || exit 1
source /p4/common/bin/backup_functions.sh || exit 1
set_vars || exit 1
export LOGFILE="$2"
if [[ "$3" != inherit ]]; then export P4NAME="$3"; fi
if [[ "$4" != inherit ]]; then export P4D_FLAGS="$4"; fi
if [[ "$5" != inherit ]]; then export P4ROOT="$5"; fi
clean_client_sendq_dir
"""
identity = identity or {}
return self.command(["bash", "-c", shell, "sendq-live", self.args.instance,
self.logs / "sendq-cleanup-live.log", identity.get("P4NAME", "inherit"),
identity.get("P4D_FLAGS", "inherit"), root_override or "inherit"], check=False)
def shared_sendq_checks(self, token):
original_sendq = self.client_config("client.sendq.dir")
original_readonly = self.client_config("client.readonly.dir")
service = "p4d_" + self.args.instance + ".service"
root = Path(self.env["P4ROOT"]).resolve()
checks = []
for name, readonly_suffix, sendq_suffix in (
("shared_readonly_equal_preserved", "", ""),
("shared_readonly_child_preserved", "/persistent", ""),
("shared_readonly_parent_preserved", "", "/nested directory"),
("shared_readonly_nested_alias_preserved", "/nested directory/external directory/persistent", ""),
("shared_readonly_alias_chain_preserved", None, "")):
relative = token + "/" + name
target = root / relative
outside = self.home / "tmp" / (token + "-" + name)
tree = self.seed_sendq_tree(target, outside)
# A client named sendq.* creates genuinely persistent have data
# with a queue-looking name. Prefix matching must not remove it.
client = "sendq." + self.fixture.name + "-" + name
workspace = outside / "workspace"
workspace.mkdir()
if "alias" in name:
(outside / "persistent").mkdir()
if readonly_suffix is None:
alias = outside / "outer alias"
alias.symlink_to(target / "nested directory/external directory/persistent", target_is_directory=True)
readonly_value = str(alias)
else:
readonly_value = relative + readonly_suffix
created = False
try:
self.replace_client_config("client.readonly.dir", {self.env["SERVERID"]: readonly_value})
self.replace_client_config("client.sendq.dir", {self.env["SERVERID"]: relative + sendq_suffix})
# Older releases cache the external client-directory setting;
# restart after configuration before creating persistent state.
self.command(["sudo", "-n", "systemctl", "restart", service])
self.command([BIN / "p4login", self.args.instance])
self.fixture.form("client", {"Client": client, "Owner": self.env["P4USER"],
"Root": str(workspace), "Type": "partitioned", "LineEnd": "unix",
"View0": "//%s/... //%s/..." % (self.fixture.name, client)})
created = True
effective = self.fixture.p4("configure", "show", "client.readonly.dir")
form = self.fixture.p4("client", "-o", client)
(self.logs / "sendq-shared-fixture-debug.json").write_text(json.dumps({"effective_readonly": effective, "client": form, "target": str(target)}, indent=2) + "\n")
self.command([self.env["P4BIN"], "-c", client, "sync", "//" + self.fixture.name + "/..."])
before_have = self.command([self.env["P4BIN"], "-c", client, "-ztag", "have", "//" + self.fixture.name + "/..."]).stdout
if "... haveRev " not in before_have or str(workspace) not in before_have:
raise RuntimeError("Real partitioned client did not record synced revisions")
self.command(["sudo", "-n", "systemctl", "stop", service])
try:
readonly_root = (Path(readonly_value) if readonly_value.startswith("/") else root / readonly_value).resolve()
def hashes():
# Include externally stored readonly data reached through
# an alias; rglob deliberately does not follow queue links.
return {str(path.resolve()): hashlib.sha256(path.read_bytes()).hexdigest()
for folder in (target, readonly_root) for path in folder.rglob("*")
if path.is_file() and not path.is_symlink()}
before = hashes()
if not any(Path(path).name.startswith("db.sendq.") for path in before):
raise RuntimeError("Partitioned client did not create the queue-like persistent filename; observed=" + repr(sorted(before)))
result = self.sendq_helper()
if result.returncode:
raise RuntimeError("Shared readonly cleanup failed instead of preserving the directory")
self.assert_sendq_tree(tree, False)
if hashes() != before:
raise RuntimeError("Shared readonly cleanup changed persistent file bytes")
finally:
self.command(["sudo", "-n", "systemctl", "start", service])
self.command([BIN / "p4login", self.args.instance])
if self.command([self.env["P4BIN"], "-c", client, "-ztag", "have", "//" + self.fixture.name + "/..."]).stdout != before_have:
raise RuntimeError("Shared readonly cleanup changed real partitioned-client have state")
self.fixture.assert_unchanged()
checks.append({"name": name, "status": "passed", "server_was_stopped": True,
"persistent_files_checked": len(before), "real_partitioned_have_preserved": True})
finally:
if created:
self.fixture.p4("client", "-d", "-f", client)
self.replace_client_config("client.readonly.dir", original_readonly)
self.replace_client_config("client.sendq.dir", original_sendq)
self.command(["sudo", "-n", "systemctl", "restart", service])
self.command([BIN / "p4login", self.args.instance])
shutil.rmtree(target)
shutil.rmtree(outside)
return checks
def refused_sendq_alias_checks(self, token):
original = self.client_config("client.sendq.dir")
service = "p4d_" + self.args.instance + ".service"
root = Path(self.env["P4ROOT"]).resolve()
checks = []
for name in ("protected_root_alias_refused", "configured_sendq_alias_refused"):
scratch = self.home / "tmp" / (token + "-" + name)
target = scratch / "queue"
tree = self.seed_sendq_tree(target, scratch / "outside")
required = target / "required alias"
root_override = None
if name == "protected_root_alias_refused":
# Only this helper invocation uses the alias. The installed
# service and its actual DB links retain their original paths.
required.symlink_to(root, target_is_directory=True)
root_override = str(required)
sendq_value = str(target)
else:
required.symlink_to(target, target_is_directory=True)
outer = scratch / "outside configured alias"
outer.symlink_to(required, target_is_directory=True)
sendq_value = str(outer)
tree["links"].append(required)
tree["link_targets"][required] = os.readlink(required)
try:
self.replace_client_config("client.sendq.dir", {self.env["SERVERID"]: sendq_value})
self.command(["sudo", "-n", "systemctl", "stop", service])
try:
if self.command(["sudo", "-n", "systemctl", "is-active", service], check=False).returncode == 0:
raise RuntimeError("Alias refusal fixture did not stop the installed service")
result = self.sendq_helper(root_override=root_override)
if result.returncode != 1:
raise RuntimeError("Required alias cleanup was not refused: " + name + "\n" + result.stdout + result.stderr)
self.assert_sendq_tree(tree, False)
if Path(sendq_value).resolve() != target.resolve():
raise RuntimeError("Refusal changed the configured queue resolution")
finally:
self.command(["sudo", "-n", "systemctl", "start", service])
self.command([BIN / "p4login", self.args.instance])
self.fixture.assert_unchanged()
checks.append({"name": name, "status": "passed", "server_was_stopped": True,
"required_alias_and_queue_preserved": True})
finally:
self.replace_client_config("client.sendq.dir", original)
shutil.rmtree(scratch)
return checks
def sendq_cleanup(self):
original = self.client_config("client.sendq.dir")
root = Path(self.env["P4ROOT"]).resolve()
token = ".sdp-sendq-live-" + str(time.time_ns())
scratch = self.home / "tmp" / token
scratch.mkdir()
relative_a, relative_b = token + "/global path", token + "/named path"
absolute = str(scratch / "absolute queue")
server, other, third = self.env["SERVERID"], "sdp-sendq-other", "sdp-sendq-third"
cases = [
("unset_noop", {}, None, {}, False),
("any_relative", {"any": relative_a}, "global", {}, False),
("any_absolute", {"any": absolute}, "absolute", {}, False),
("configured_root_symlink", {"any": token + "/root link"}, "absolute", {}, False),
("missing_directory_noop", {"any": token + "/missing"}, None, {}, False),
("configured_file_refused", {"any": relative_a + "/queue.db"}, None, {"EXPECTED_STATUS": 1}, False),
("local_relative_overrides_any", {"any": absolute, server: relative_b}, "named", {}, False),
("local_absolute_overrides_any", {"any": relative_a, server: absolute}, "absolute", {}, False),
("other_server_ignored", {"any": relative_a, other: relative_b}, "global", {}, False),
("named_empty_disables_any", {"any": relative_a, server: ""}, None, {}, False),
("p4name_selects_scope", {"any": relative_a, server: relative_b, other: absolute}, "absolute", {"P4NAME": other}, False),
("startup_In_selects_scope", {"any": relative_a, server: relative_b, other: absolute}, "absolute", {"P4NAME": server, "P4D_FLAGS": "-In " + other}, False),
("database_p4name_one_hop", {server: relative_a, other: relative_b, third: absolute}, "named", {"DB_P4NAME": {server: other, other: third}}, False),
("global_database_p4name_redirect", {server: relative_a, other: relative_b, third: absolute}, "named", {"DB_P4NAME": {"any": other, other: third}}, False),
("named_p4name_overrides_global_redirect", {server: relative_a, other: relative_b, third: absolute}, "absolute", {"DB_P4NAME": {"any": other, server: third}}, False),
("startup_In_bypasses_database_redirect", {server: relative_a, other: relative_b}, "global", {"P4D_FLAGS": "-In " + server, "DB_P4NAME": {server: other}}, False),
("running_server_refused", {server: relative_b}, None, {}, True),
]
checks = []
service = "p4d_" + self.args.instance + ".service"
try:
for name, settings, selected, identity, running in cases:
self.replace_client_config("client.sendq.dir", settings)
trees = {key: self.seed_sendq_tree(path, scratch / ("outside-" + key))
for key, path in {"global": root / relative_a, "named": root / relative_b,
"absolute": Path(absolute)}.items()}
alias = root / token / "root link"
alias.symlink_to(absolute, target_is_directory=True)
db_names = self.client_config("P4NAME")
changed_names = []
stopped = False
try:
if not running:
self.command(["sudo", "-n", "systemctl", "stop", service])
stopped = True
if self.command(["systemctl", "is-active", "--quiet", service], check=False).returncode == 0:
raise RuntimeError("Service remained active before send-queue cleanup")
for scope, value in identity.get("DB_P4NAME", {}).items():
self.command([self.env["P4DBIN"], "-r", self.env["P4ROOT"], "-J", "off",
"-cset " + scope + "#P4NAME=" + value])
changed_names.append(scope)
result = self.sendq_helper(identity=identity)
expected = identity.get("EXPECTED_STATUS", 1 if running else 0)
if result.returncode != expected:
log = self.logs / "sendq-cleanup-live.log"
detail = log.read_text(errors="replace")[-5000:] if log.exists() else ""
raise RuntimeError(name + ": cleanup exit %s, expected %s\n%s" % (result.returncode, expected, detail))
for key, tree in trees.items():
self.assert_sendq_tree(tree, key == selected)
finally:
for scope in changed_names:
option = ("-cset " + scope + "#P4NAME=" + db_names[scope]
if scope in db_names else "-cunset " + scope + "#P4NAME")
self.command([self.env["P4DBIN"], "-r", self.env["P4ROOT"], "-J", "off", option])
if stopped:
self.command(["sudo", "-n", "systemctl", "start", service])
self.command([BIN / "p4login", self.args.instance])
self.fixture.assert_unchanged()
checks.append({"name": name, "status": "passed", "server_was_stopped": not running})
(self.logs / "sendq-cleanup-evidence.json").write_text(json.dumps({"status": "incomplete", "checks": checks}, indent=2) + "\n")
alias.unlink()
for tree in trees.values():
shutil.rmtree(tree["root"])
for key in trees:
shutil.rmtree(scratch / ("outside-" + key))
checks.extend(self.shared_sendq_checks(token))
checks.extend(self.refused_sendq_alias_checks(token))
evidence = {"checks": checks, "regular_files_and_symlinks_removed": True,
"directories_and_fifo_preserved": True, "nested_symlink_targets_preserved": True,
"fixture_records_and_bytes_preserved": True}
(self.logs / "sendq-cleanup-evidence.json").write_text(json.dumps(evidence, indent=2) + "\n")
return evidence
finally:
self.replace_client_config("client.sendq.dir", original)
# Retain failed-case fixtures and logs. Only empty successful scratch
# roots are removed; a populated path is valuable failure evidence.
for path in (root / token, scratch):
if path.is_dir() and not any(path.iterdir()):
path.rmdir()
def recovery_switch(self):
if not self.records or self.records[0]["status"] != "passed":
raise RuntimeError("Checkpoint preparation failed; recovery switch was not attempted")
before = Path(self.env["P4ROOT"]).resolve()
replacement = (self.home / "offline_db").resolve()
original = self.client_config("client.sendq.dir")
original_readonly = self.client_config("client.readonly.dir")
relative = ".sdp-sendq-switch-" + str(time.time_ns())
outside = self.home / "tmp" / relative
trees = [self.seed_sendq_tree(path / relative, outside / str(number))
for number, path in enumerate((before, replacement))]
readonly = relative + "-readonly/readonly = clients"
client = "sendq." + self.fixture.name + "-switch"
workspace = outside / "partitioned workspace"
workspace.mkdir(parents=True)
created = False
try:
self.replace_client_config("client.sendq.dir", {self.env["SERVERID"]: relative})
self.replace_client_config("client.readonly.dir", {self.env["SERVERID"]: readonly})
self.fixture.form("client", {"Client": client, "Owner": self.env["P4USER"],
"Root": str(workspace), "Type": "partitioned", "LineEnd": "unix",
"View0": "//%s/... //%s/..." % (self.fixture.name, client)})
created = True
self.command([self.env["P4BIN"], "-c", client, "sync", "//" + self.fixture.name + "/..."])
have_command = [self.env["P4BIN"], "-c", client, "-ztag", "have", "//" + self.fixture.name + "/..."]
before_have = self.command(have_command).stdout
if "... haveRev " not in before_have or str(workspace) not in before_have:
raise RuntimeError("Recovery fixture has no real partitioned-client have state")
if not any((before / readonly).rglob("db.sendq.*")):
raise RuntimeError("Partitioned have table is not under the configured readonly path")
self.script("recreate_db_checkpoint.sh")
if self.command(have_command).stdout != before_have:
raise RuntimeError("Database switch lost real partitioned-client have revisions")
for tree in trees:
self.assert_sendq_tree(tree, True)
finally:
# Failed recovery may leave the service stopped and recovery data.
# Restore through the API only when the service is actually running.
if self.command(["systemctl", "is-active", "--quiet", "p4d_" + self.args.instance + ".service"], check=False).returncode == 0:
if created:
self.fixture.p4("client", "-d", "-f", client)
self.replace_client_config("client.readonly.dir", original_readonly)
self.replace_client_config("client.sendq.dir", original)
after = Path(self.env["P4ROOT"]).resolve()
if before == after:
raise RuntimeError("Recovery did not switch physical database roots")
value = self.command([self.env["P4BIN"], "counter", self.counter]).stdout.strip()
if value != "12345":
raise RuntimeError("Recovery switch did not preserve the live integration counter")
self.command(["systemctl", "is-active", "--quiet", "p4d_" + self.args.instance + ".service"])
self.fixture.assert_unchanged()
self.fixture.post_recovery_write()
if list(before.glob(".sdp-switch.*")):
raise RuntimeError("Successful switch left an unfinished transaction directory")
for tree in trees:
shutil.rmtree(tree["root"])
shutil.rmtree(outside)
return {"sendq_physical_roots_cleaned": [str(before), str(replacement)],
"both_physical_roots_verified": True, "real_partitioned_have_preserved": True,
"readonly_path_contains_space_and_equals": True, "fixture_records_and_bytes_preserved": True}
def ssh_rsync(self):
sshd = shutil.which("sshd") or "/usr/sbin/sshd"
if not Path(sshd).is_file():
raise RuntimeError("openssh-server is required for real host-trust testing")
with tempfile.TemporaryDirectory(prefix="sdp-ssh-", dir=self.home / "tmp") as temp:
work = Path(temp)
for name in ["host", "client", "changed-host"]:
self.command(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", work / name])
config = work / "sshd_config"
config.write_text("\n".join([
"Port 2222", "ListenAddress 127.0.0.1", "HostKey " + str(work / "host"),
"PidFile " + str(work / "sshd.pid"), "AuthorizedKeysFile " + str(work / "client.pub"),
"PasswordAuthentication no", "KbdInteractiveAuthentication no", "UsePAM no",
"StrictModes yes", "AllowUsers " + self.args.os_user, "LogLevel VERBOSE", ""]))
ssh_folder = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".ssh"
ssh_folder.mkdir(mode=0o700, exist_ok=True)
client_config = ssh_folder / "config"
original_config = client_config.read_bytes() if client_config.exists() else None
original_mode = client_config.stat().st_mode & 0o777 if client_config.exists() else None
instance_vars = Path("/p4/common/config/p4_" + self.args.instance + ".vars")
original_vars = instance_vars.read_bytes()
known_hosts = work / "known_hosts"
daemon = None
ssh_log = None
try:
client_config.write_text("Host 127.0.0.1\n Port 2222\n IdentityFile %s\n UserKnownHostsFile %s\n IdentitiesOnly yes\n BatchMode yes\n" % (work / "client", known_hosts))
client_config.chmod(0o600)
with instance_vars.open("a") as output:
output.write("\nexport P4MASTERHOST=127.0.0.1\n")
ssh_log = (work / "sshd.log").open("w")
daemon = subprocess.Popen([sshd, "-D", "-e", "-f", str(config)], stdout=ssh_log, stderr=subprocess.STDOUT)
for _ in range(60):
if daemon.poll() is not None:
raise RuntimeError("Fixture sshd failed: " + (work / "sshd.log").read_text())
try:
with socket.create_connection(("127.0.0.1", 2222), timeout=0.1):
break
except OSError:
time.sleep(0.1)
else:
raise RuntimeError("Fixture sshd did not become ready")
filename = 'sdp runtime $(touch forbidden)`touch forbidden2` quotes\'".txt'
archive = self.home / "depots/depot" / (filename + ",d")
archive.mkdir(parents=True, exist_ok=True)
(archive / "1.1").write_text("literal archive payload\n")
verify = work / "verify_output.log"
verify.write_text("info: //depot/" + filename + "#1 - MISSING!\n")
env = dict(self.env)
env.pop("RSYNC_RSH", None)
argv = ["bash", BIN / "rsync_missing.sh", self.args.instance, verify]
self.command(argv, env=env)
if not known_hosts.is_file() or not known_hosts.read_text().strip():
raise RuntimeError("First SSH connection did not record the host key")
if (self.home / "forbidden").exists() or (self.home / "forbidden2").exists():
raise RuntimeError("Filename content was interpreted by a shell")
host = known_hosts.read_text().split()[0]
changed_key = (work / "changed-host.pub").read_text().split()
known_hosts.write_text(host + " " + " ".join(changed_key[:2]) + "\n")
rejected = self.command(argv, check=False, env=env)
if rejected.returncode == 0:
raise RuntimeError("Changed SSH host key was incorrectly accepted")
if (archive / "1.1").read_text() != "literal archive payload\n":
raise RuntimeError("Host-key failure changed archive data")
finally:
if daemon is not None:
daemon.terminate()
try:
daemon.wait(timeout=10)
except subprocess.TimeoutExpired:
daemon.kill()
daemon.wait()
if ssh_log is not None:
ssh_log.close()
instance_vars.write_bytes(original_vars)
if original_config is None:
client_config.unlink(missing_ok=True)
else:
client_config.write_bytes(original_config)
client_config.chmod(original_mode)
def run(self):
self.scenario("checkpoint_restore", ["live_checkpoint.sh", "daily_checkpoint.sh", "recreate_offline_db.sh", "backup_functions.sh"],
"Real checkpoint/offline replay preserve nine revision records (including delete/move history) and seven exact content hashes, client/group/label specs, label members and shelf bytes, inspected through a separately served restored database.", self.checkpoint_cycle)
self.scenario("journal_rotation", ["rotate_journal.sh", "backup_functions.sh"],
"Three real rotations advance the counter and preserve the existing >2 gzip threshold and newest-journal window.", self.rotation)
self.scenario("sendq_cleanup", ["backup_functions.sh"],
"Actual local db.config settings select dedicated relative/absolute queues; stopped cleanup preserves directories, special entries, nested symlink targets and exact fixture bytes, and refuses the running server.", self.sendq_cleanup)
self.scenario("database_switch", ["recreate_db_checkpoint.sh", "backup_functions.sh", "p4d_base"],
"Real systemd stop/start and database-root swap clean queues in both physical roots, preserve exact fixture records and bytes, and allow a new post-recovery submit; staging is cleaned.", self.recovery_switch)
self.scenario("ssh_host_trust", ["rsync_missing.sh"],
"Real localhost SSH/rsync records a first host key, rejects a changed key, and treats archive names literally; no multi-host replica topology.", self.ssh_rsync)
print(json.dumps({"scenarios": self.records}, indent=2))
return int(any(record["status"] != "passed" for record in self.records))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True)
parser.add_argument("--instance", required=True)
parser.add_argument("--port", required=True, type=int)
parser.add_argument("--os-user", required=True)
parser.add_argument("--confirm-disposable", action="store_true")
try:
return RuntimeIntegration(parser.parse_args()).run()
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
print(json.dumps({"guard_error": str(error), "scenarios": []}, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #3 | 34016 | Russell C. Jackson (Rusty) |
Restore owned checkpoint-marker cleanup on handled failures and use MD5 completion markers for recovery. Release only the acquiring shell’s ckp_running.txt marker in die(), including Bash 3.2 subshell ownership checks. Remove the separate full checkpoint verification pass from load_checkpoint and recreate_db_checkpoint, retain readable sidecar/source checks, and fix gzip sidecar path selection. Update regression tests and operational documentation. Validation: combined acceptance gate passed; 44 platform stages, 30 topology scenarios, and 112 historical release scenarios across 14 RHEL 8/9 cells passed. All 8 Linux offline suites passed 526 tests each. Focused recovery tests passed on Bash 3.2 and 5.3. |
||
| #2 | 34011 | Russell C. Jackson (Rusty) |
Reduce checkpoint thread defaults to 16, tolerate missing journal numbers, and clean dedicated send queues during stopped database recreation. Resolve client.sendq.dir and client.readonly.dir from the local database with the effective server identity. Preserve all shared readonly storage with a warning, protect database and journal paths, and clean both database roots while stopped. Correct readonly configuration lookup and nested copy destinations. Preserve journal archival thresholds, intentional server.locks removal, and existing shell modes. Update operational documentation and regression coverage. Validation passed on the exact source manifest: 44 platform stages, 30 topology scenarios, 112 historical scenarios across p4d 2023.1-2026.1 on UBI 8/9, 70 focused tests on each of Bash 3.2/5.3, and the combined release gate. RHEL tests use UBI containers; historical 2023 releases use x86 emulation. |
||
| #1 | 34004 | Russell C. Jackson (Rusty) |
Improve SDP backup/recovery, Maintenance, logging and cloud operation. Add owned checkpoint locks, recovery-aware retention, preserved database switches, bounded cleanup and coordinated AWS/Azure snapshots. Fix service-user password initialization, edge staging, standby promotion state parsing, proxy cache setup, typed depot verification and historical-server configuration/error handling. Remove unused OTP programs; keep Windows removed, server.locks cleanup and the journal-count condition unchanged. Add no global shell strict modes. Update operational documentation and add reproducible mkdirs.sh/mkdirs.cfg container assurance. Validate Ubuntu 26.04 and UBI 8.10/9.8/10.2 across both Python runtimes: 44 stages; 428 offline tests per runtime/OS, zero offline skips; 19 deployed-archive comparisons per combination. Three six-node topology runs pass all 30 recovery/failure scenarios. P4D 2023.1 through 2026.1 on UBI 8/9 pass all 14 cells and 98 scenarios. Static, documentation and provenance checks pass. Containers do not certify every fleet kernel, patch build or integration. Tested source manifest SHA-256: e1a23e17b8ced8c1148d9a250589977c6f6e98606bf2a81dc3ff5d7c909e3695 Deployed comparison archive SHA-256: 1bd23217356ee3808e040d39aea5be9519bda7c8f2b4924656a70aff8ad7293a |