feat(release): add isolated journal profile installation
This commit is contained in:
@@ -26,6 +26,7 @@ dependency_initialize = importlib.import_module("scripts.release.dependency_init
|
||||
node_health = importlib.import_module("scripts.release.node_health")
|
||||
broker_health = importlib.import_module("scripts.release.broker_health")
|
||||
lifecycle = importlib.import_module("scripts.release.lifecycle")
|
||||
journal = importlib.import_module("scripts.release.journal")
|
||||
|
||||
|
||||
def test_delivery_authorities_and_layout():
|
||||
@@ -942,3 +943,32 @@ def test_maintenance_receipt_cannot_silently_adopt_unknown_history_or_release_pr
|
||||
value["drain_projection"]["factory"]["cleanup_complete"] = False
|
||||
with pytest.raises(policy.DeliveryError):
|
||||
lifecycle.validate_record(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("shared", True),
|
||||
("capacity_bytes", True),
|
||||
("capacity_bytes", 128 * 1024**2),
|
||||
("options", ["ro", "nosuid", "nodev", "noexec"]),
|
||||
("root", "/another-directory"),
|
||||
("filesystem", "tmpfs"),
|
||||
],
|
||||
)
|
||||
def test_journal_profile_never_substitutes_rotation_settings_for_a_dedicated_hard_volume(
|
||||
field, value
|
||||
):
|
||||
path = Path("/var/log/journal/" + "b" * 32 + ".kf-onlinejudge")
|
||||
observed = {
|
||||
"path": str(path),
|
||||
"root": "/",
|
||||
"filesystem": "ext4",
|
||||
"options": ["rw", "nosuid", "nodev", "noexec"],
|
||||
"shared": False,
|
||||
"capacity_bytes": 64 * 1024**2,
|
||||
}
|
||||
journal.validate_storage(observed, path)
|
||||
observed[field] = value
|
||||
with pytest.raises(policy.DeliveryError):
|
||||
journal.validate_storage(observed, path)
|
||||
|
||||
@@ -304,3 +304,21 @@ operations and cleanup results. Root factory termination uses a signal notice an
|
||||
the existing bounded guardian/recovery path; ordinary import termination finishes
|
||||
its current authorized operation before claiming another. These interfaces require
|
||||
actual independent-VM lifecycle acceptance and are not installation/safety proof.
|
||||
|
||||
`python3.13 -m scripts.release.journal` separately installs a fixed KF journal
|
||||
namespace profile. It requires a preprovisioned dedicated 64-MiB ext4 mount at
|
||||
`/var/log/journal/MACHINE_ID.kf-onlinejudge`, with the same private hard-volume
|
||||
mount rules used for dependencies. It never creates/formats/mounts a disk, changes
|
||||
the default system journal, adopts old unrecorded log data or starts a daemon.
|
||||
Unknown configuration/overrides and partial installations are retained and refused.
|
||||
|
||||
The profile fixes 32-MiB persistent/8-MiB runtime rotation budgets, bounded files,
|
||||
lines and seven-day retention. Its namespaced daemon override limits CPU, memory,
|
||||
swap and PIDs. Rotation settings are not a hard filesystem guarantee; the dedicated
|
||||
volume must also be observed. The `check_ready` interface reads actual namespace
|
||||
service/process/cgroup state. Connecting service templates to this profile,
|
||||
redaction and real retention/capacity behavior remain C+D/F installation acceptance
|
||||
work; installing the profile alone does not route existing service logs or verify
|
||||
their privacy. The profile relies on the pinned systemd 262
|
||||
[namespace interface](https://github.com/systemd/systemd/blob/v262/man/systemd.exec.xml)
|
||||
and [journal configuration](https://github.com/systemd/systemd/blob/v262/man/journald.conf.xml).
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""A separate bounded log namespace; never change the host's default journal."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import configuration, install, preflight
|
||||
from .policy import DeliveryError, canonical, digest, fields, read_json
|
||||
|
||||
NAMESPACE = "kf-onlinejudge"
|
||||
UNIT = "systemd-journald@kf-onlinejudge.service"
|
||||
CONFIG = Path("/etc/systemd/journald@kf-onlinejudge.conf")
|
||||
DROPIN = Path("/etc/systemd/system") / (UNIT + ".d") / "50-kf-bounds.conf"
|
||||
RECEIPT = Path("/var/lib/kf-onlinejudge/journal-profile.json")
|
||||
CAPACITY = 64 * 1024**2
|
||||
PROFILE = b"""[Journal]
|
||||
Storage=persistent
|
||||
SplitMode=none
|
||||
SystemMaxUse=32M
|
||||
RuntimeMaxUse=8M
|
||||
SystemMaxFileSize=4M
|
||||
RuntimeMaxFileSize=2M
|
||||
SystemMaxFiles=8
|
||||
RuntimeMaxFiles=4
|
||||
MaxFileSec=1h
|
||||
MaxRetentionSec=7day
|
||||
RateLimitIntervalSec=30s
|
||||
RateLimitBurst=100
|
||||
LineMax=8192
|
||||
ForwardToSyslog=no
|
||||
ForwardToKMsg=no
|
||||
ForwardToConsole=no
|
||||
ForwardToWall=no
|
||||
"""
|
||||
UNIT_BOUNDS = b"""[Unit]
|
||||
RequiresMountsFor=/var/log/journal/%m.kf-onlinejudge
|
||||
StartLimitIntervalSec=600
|
||||
StartLimitBurst=3
|
||||
[Service]
|
||||
Slice=kfonlinejudge.slice
|
||||
MemoryMax=64M
|
||||
MemorySwapMax=0
|
||||
TasksMax=16
|
||||
CPUQuota=25%
|
||||
LimitCORE=0
|
||||
LimitFSIZE=8M
|
||||
LimitNOFILE=256
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
TimeoutStopSec=15
|
||||
"""
|
||||
|
||||
|
||||
def manager(*args: str) -> str:
|
||||
# Import after service profiles are initialized so their future log-binding
|
||||
# checks can call this independent installer without an import cycle.
|
||||
from .service_install import systemctl
|
||||
|
||||
return systemctl(*args)
|
||||
|
||||
|
||||
def machine_id() -> str:
|
||||
path = Path("/etc/machine-id")
|
||||
preflight.protected(path)
|
||||
value = bounded_text(path, 64).strip()
|
||||
if not re.fullmatch(r"[0-9a-f]{32}", value) or value == "0" * 32:
|
||||
raise DeliveryError("fixed machine journal identity required")
|
||||
return value
|
||||
|
||||
|
||||
def bounded_text(path: Path, maximum: int) -> str:
|
||||
with path.open() as source:
|
||||
value = source.read(maximum + 1)
|
||||
if len(value) > maximum:
|
||||
raise DeliveryError("journal observation exceeds bound")
|
||||
return value
|
||||
|
||||
|
||||
def validate_storage(value: dict, path: Path) -> None:
|
||||
fields(
|
||||
value,
|
||||
{"path", "root", "filesystem", "options", "shared", "capacity_bytes"},
|
||||
"journal storage",
|
||||
)
|
||||
if (
|
||||
value["path"] != str(path)
|
||||
or value["root"] != "/"
|
||||
or value["filesystem"] != "ext4"
|
||||
or value["shared"] is not False
|
||||
or not isinstance(value["options"], list)
|
||||
or not all(isinstance(option, str) for option in value["options"])
|
||||
or not {"rw", "nosuid", "nodev", "noexec"} <= set(value["options"])
|
||||
or type(value["capacity_bytes"]) is not int
|
||||
or not CAPACITY * 9 // 10 <= value["capacity_bytes"] <= CAPACITY
|
||||
):
|
||||
raise DeliveryError("dedicated journal hard storage budget differs")
|
||||
|
||||
|
||||
def storage() -> dict:
|
||||
path = Path("/var/log/journal") / (machine_id() + "." + NAMESPACE)
|
||||
preflight.protected(path, directory=True)
|
||||
rows = [
|
||||
line.split() for line in bounded_text(Path("/proc/self/mountinfo"), 1024**2).splitlines()
|
||||
]
|
||||
selected = [row for row in rows if len(row) > 6 and row[4] == str(path)]
|
||||
if len(selected) != 1:
|
||||
raise DeliveryError("dedicated journal mount required; never created by installer")
|
||||
row = selected[0]
|
||||
separator = row.index("-")
|
||||
info = os.statvfs(path)
|
||||
value = {
|
||||
"path": str(path),
|
||||
"root": row[3],
|
||||
"filesystem": row[separator + 1],
|
||||
"options": row[5].split(","),
|
||||
"shared": any(other[2] == row[2] and other != row for other in rows),
|
||||
"capacity_bytes": info.f_frsize * info.f_blocks,
|
||||
}
|
||||
validate_storage(value, path)
|
||||
return value
|
||||
|
||||
|
||||
def validate_receipt(value: dict) -> None:
|
||||
fields(
|
||||
value,
|
||||
{
|
||||
"schema_version",
|
||||
"namespace",
|
||||
"machine_id",
|
||||
"files",
|
||||
"services_started",
|
||||
"execution_verified",
|
||||
},
|
||||
"journal receipt",
|
||||
)
|
||||
if (
|
||||
type(value["schema_version"]) is not int
|
||||
or value["schema_version"] != 1
|
||||
or value["namespace"] != NAMESPACE
|
||||
or not isinstance(value["machine_id"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{32}", value["machine_id"])
|
||||
or value["machine_id"] == "0" * 32
|
||||
or value["files"]
|
||||
!= {
|
||||
str(CONFIG): hashlib.sha256(PROFILE).hexdigest(),
|
||||
str(DROPIN): hashlib.sha256(UNIT_BOUNDS).hexdigest(),
|
||||
}
|
||||
or value["services_started"] is not False
|
||||
or value["execution_verified"] is not False
|
||||
):
|
||||
raise DeliveryError("journal receipt differs from fixed profile")
|
||||
|
||||
|
||||
def check_profile() -> dict:
|
||||
preflight.protected(RECEIPT)
|
||||
configuration.private(RECEIPT)
|
||||
receipt = read_json(RECEIPT, 4096)
|
||||
validate_receipt(receipt)
|
||||
if receipt["machine_id"] != machine_id():
|
||||
raise DeliveryError("journal profile belongs to another machine")
|
||||
for path, expected in receipt["files"].items():
|
||||
member = Path(path)
|
||||
preflight.protected(member)
|
||||
if (
|
||||
member.stat().st_gid != 0
|
||||
or stat.S_IMODE(member.stat().st_mode) != 0o644
|
||||
or digest(member) != expected
|
||||
):
|
||||
raise DeliveryError("protected journal profile changed")
|
||||
# Refuse unrecorded instance-specific overrides; systemd's actual limits are
|
||||
# checked separately, including overrides inherited from the vendor template.
|
||||
if {p.name for p in DROPIN.parent.iterdir()} != {DROPIN.name}:
|
||||
raise DeliveryError("unknown journal instance override")
|
||||
check_configuration_overrides()
|
||||
return storage()
|
||||
|
||||
|
||||
def check_configuration_overrides() -> None:
|
||||
for root in ("/etc/systemd", "/run/systemd", "/usr/local/lib/systemd", "/usr/lib/systemd"):
|
||||
directory = Path(root) / ("journald@" + NAMESPACE + ".conf.d")
|
||||
if directory.exists() or directory.is_symlink():
|
||||
preflight.protected(directory, directory=True)
|
||||
if next(directory.iterdir(), None) is not None:
|
||||
raise DeliveryError("unrecorded namespace journal configuration override")
|
||||
|
||||
|
||||
def check_ready() -> dict:
|
||||
value = check_profile()
|
||||
props = dict(
|
||||
line.split("=", 1)
|
||||
for line in manager(
|
||||
"show",
|
||||
UNIT,
|
||||
"--property=ActiveState,SubState,MainPID,ControlGroup,FragmentPath,DropInPaths",
|
||||
).splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
group = "/kfonlinejudge.slice/" + UNIT
|
||||
if (
|
||||
props.get("ActiveState") != "active"
|
||||
or props.get("SubState") != "running"
|
||||
or props.get("ControlGroup") != group
|
||||
):
|
||||
raise DeliveryError("bounded journal instance unavailable")
|
||||
if props.get(
|
||||
"FragmentPath"
|
||||
) != "/usr/lib/systemd/system/systemd-journald@.service" or props.get(
|
||||
"DropInPaths", ""
|
||||
).split() != [str(DROPIN)]:
|
||||
raise DeliveryError("unrecorded journal manager executable or override")
|
||||
pid = int(props.get("MainPID", "0"))
|
||||
if not 1 < pid < 2**31:
|
||||
raise DeliveryError("journal process identity unavailable")
|
||||
status = dict(
|
||||
line.split(":", 1)
|
||||
for line in bounded_text(Path(f"/proc/{pid}/status"), 16384).splitlines()
|
||||
if ":" in line
|
||||
)
|
||||
if status.get("Uid", "").split() != ["0"] * 4:
|
||||
raise DeliveryError("journal process authority differs")
|
||||
directory = Path("/sys/fs/cgroup" + group)
|
||||
limits = {
|
||||
name: (directory / name).read_text().strip()
|
||||
for name in ("memory.max", "memory.swap.max", "pids.max", "cpu.max")
|
||||
}
|
||||
if limits != {
|
||||
"memory.max": str(64 * 1024**2),
|
||||
"memory.swap.max": "0",
|
||||
"pids.max": "16",
|
||||
"cpu.max": "25000 100000",
|
||||
}:
|
||||
raise DeliveryError("effective journal budgets differ")
|
||||
value["effective_limits"] = limits
|
||||
return value
|
||||
|
||||
|
||||
def install_profile() -> dict:
|
||||
if os.getuid() != 0 or os.geteuid() != 0 or os.getgid() != 0:
|
||||
raise DeliveryError("root journal operator required")
|
||||
selected = install.selection()
|
||||
payload = install.RELEASES / selected["release_id"] / "payload"
|
||||
shipped = payload / "delivery/scripts/release/journal.py"
|
||||
preflight.protected(shipped)
|
||||
if digest(shipped) != digest(Path(__file__)):
|
||||
raise DeliveryError("journal operator differs from selected delivery")
|
||||
install.require_stopped()
|
||||
observed = storage()
|
||||
if RECEIPT.exists() or RECEIPT.is_symlink():
|
||||
check_profile()
|
||||
return {
|
||||
"profile_installed": True,
|
||||
"storage": observed,
|
||||
"services_started": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
props = dict(
|
||||
line.split("=", 1)
|
||||
for line in manager("show", UNIT, "--property=ActiveState,MainPID").splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
if props.get("ActiveState") != "inactive" or props.get("MainPID") != "0":
|
||||
raise DeliveryError("unrecorded journal instance; refused adoption")
|
||||
path = Path(observed["path"])
|
||||
if any(
|
||||
p.name != "lost+found"
|
||||
or p.is_symlink()
|
||||
or not p.is_dir()
|
||||
or next(p.iterdir(), None) is not None
|
||||
for p in path.iterdir()
|
||||
):
|
||||
raise DeliveryError("unrecorded journal data; refused adoption")
|
||||
for member in (CONFIG, DROPIN):
|
||||
if member.exists() or member.is_symlink():
|
||||
raise DeliveryError("unrecorded journal configuration; refused overwrite")
|
||||
check_configuration_overrides()
|
||||
install.directory(DROPIN.parent, 0o755)
|
||||
if next(DROPIN.parent.iterdir(), None) is not None:
|
||||
raise DeliveryError("unknown journal override directory")
|
||||
install.exclusive_file(CONFIG, PROFILE, 0o644)
|
||||
install.exclusive_file(DROPIN, UNIT_BOUNDS, 0o644)
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"namespace": NAMESPACE,
|
||||
"machine_id": machine_id(),
|
||||
"files": {
|
||||
str(CONFIG): hashlib.sha256(PROFILE).hexdigest(),
|
||||
str(DROPIN): hashlib.sha256(UNIT_BOUNDS).hexdigest(),
|
||||
},
|
||||
"services_started": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
validate_receipt(receipt)
|
||||
install.exclusive_file(RECEIPT, canonical(receipt), 0o600)
|
||||
manager("daemon-reload")
|
||||
check_profile()
|
||||
return {
|
||||
"profile_installed": True,
|
||||
"storage": observed,
|
||||
"services_started": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Install only the fixed KF log namespace profile")
|
||||
parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(install_profile(), sort_keys=True))
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
parser.exit(
|
||||
1,
|
||||
"Journal profile refused: "
|
||||
+ (str(error) if isinstance(error, DeliveryError) else type(error).__name__)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user