Spaces:
Running
Running
Repair Eggcracker fail-closed lifecycle for 0.1.1
Browse files- .github/pull_request_template.md +1 -1
- .github/workflows/ci.yml +10 -10
- AGENTS.md +0 -10
- CHANGELOG.md +5 -8
- CONTRIBUTING.md +2 -16
- LIMITATIONS.md +3 -5
- QUALIFICATION.md +11 -0
- README.md +30 -104
- RELEASE_NOTES.md +3 -21
- SECURITY.md +4 -2
- SECURITY_MODEL.md +7 -0
- pyproject.toml +3 -3
- scripts/build_release.py +19 -11
- scripts/install.py +34 -42
- scripts/run_native_matrix.py +10 -10
- scripts/smoke_local_ai.py +3 -3
- scripts/uninstall.py +32 -19
- scripts/verify_release.py +19 -33
- scripts/verify_uninstalled.py +7 -7
- src/lumi_eggcracker/__init__.py +3 -0
- src/{lumi_nutcracker → lumi_eggcracker}/__main__.py +0 -0
- src/{lumi_nutcracker → lumi_eggcracker}/cli.py +5 -2
- src/{lumi_nutcracker → lumi_eggcracker}/client.py +1 -1
- src/{lumi_nutcracker → lumi_eggcracker}/containment.py +26 -53
- src/lumi_eggcracker/gate.py +22 -0
- src/{lumi_nutcracker → lumi_eggcracker}/jsonio.py +5 -2
- src/{lumi_nutcracker → lumi_eggcracker}/records.py +48 -21
- src/{lumi_nutcracker → lumi_eggcracker}/supervisor.py +257 -111
- src/lumi_nutcracker/__init__.py +0 -3
- tests/fixtures/hostile_client.py +2 -2
- tests/test_cli.py +4 -6
- tests/test_containment.py +6 -7
- tests/test_records.py +11 -7
- tests/test_supervisor.py +47 -14
.github/pull_request_template.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
## Summary
|
| 2 |
|
| 3 |
-
Describe the user-visible change and why it belongs in Lumi
|
| 4 |
|
| 5 |
## Verification
|
| 6 |
|
|
|
|
| 1 |
## Summary
|
| 2 |
|
| 3 |
+
Describe the user-visible change and why it belongs in Lumi Eggcracker's narrow kill-switch scope.
|
| 4 |
|
| 5 |
## Verification
|
| 6 |
|
.github/workflows/ci.yml
CHANGED
|
@@ -13,11 +13,11 @@ jobs:
|
|
| 13 |
test-and-package:
|
| 14 |
runs-on: ubuntu-24.04
|
| 15 |
steps:
|
| 16 |
-
- uses: actions/checkout@
|
| 17 |
with:
|
| 18 |
fetch-depth: 0
|
| 19 |
|
| 20 |
-
- uses: actions/setup-python@
|
| 21 |
with:
|
| 22 |
python-version: "3.11"
|
| 23 |
|
|
@@ -38,19 +38,19 @@ jobs:
|
|
| 38 |
- name: Verify public release
|
| 39 |
run: >-
|
| 40 |
python scripts/verify_release.py
|
| 41 |
-
--artifact dist/github/lumi-
|
| 42 |
-
--source-archive dist/github/lumi-
|
| 43 |
-
--release-bundle dist/github/lumi-
|
| 44 |
|
| 45 |
- name: Upload tag artifacts
|
| 46 |
if: startsWith(github.ref, 'refs/tags/v')
|
| 47 |
-
uses: actions/upload-artifact@
|
| 48 |
with:
|
| 49 |
-
name: lumi-
|
| 50 |
path: |
|
| 51 |
-
dist/github/lumi-
|
| 52 |
-
dist/github/lumi-
|
| 53 |
-
dist/github/lumi-
|
| 54 |
dist/github/release-manifest.json
|
| 55 |
dist/github/SHA256SUMS
|
| 56 |
if-no-files-found: error
|
|
|
|
| 13 |
test-and-package:
|
| 14 |
runs-on: ubuntu-24.04
|
| 15 |
steps:
|
| 16 |
+
- uses: actions/checkout@v6
|
| 17 |
with:
|
| 18 |
fetch-depth: 0
|
| 19 |
|
| 20 |
+
- uses: actions/setup-python@v6
|
| 21 |
with:
|
| 22 |
python-version: "3.11"
|
| 23 |
|
|
|
|
| 38 |
- name: Verify public release
|
| 39 |
run: >-
|
| 40 |
python scripts/verify_release.py
|
| 41 |
+
--artifact dist/github/lumi-eggcracker-0.1.1.pyz
|
| 42 |
+
--source-archive dist/github/lumi-eggcracker-0.1.1-source.zip
|
| 43 |
+
--release-bundle dist/github/lumi-eggcracker-0.1.1-linux.zip
|
| 44 |
|
| 45 |
- name: Upload tag artifacts
|
| 46 |
if: startsWith(github.ref, 'refs/tags/v')
|
| 47 |
+
uses: actions/upload-artifact@v6
|
| 48 |
with:
|
| 49 |
+
name: lumi-eggcracker-${{ github.ref_name }}
|
| 50 |
path: |
|
| 51 |
+
dist/github/lumi-eggcracker-0.1.1.pyz
|
| 52 |
+
dist/github/lumi-eggcracker-0.1.1-source.zip
|
| 53 |
+
dist/github/lumi-eggcracker-0.1.1-linux.zip
|
| 54 |
dist/github/release-manifest.json
|
| 55 |
dist/github/SHA256SUMS
|
| 56 |
if-no-files-found: error
|
AGENTS.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# Lumi Nutcracker repository rules
|
| 2 |
-
|
| 3 |
-
- This product supports only explicitly launched workloads on Linux with systemd and unified cgroup v2.
|
| 4 |
-
- Tests may affect only fresh Nutcracker-owned units and fixture processes they create. Never signal PID 1, the test runner, its ancestors, a pre-existing process or an unrelated cgroup.
|
| 5 |
-
- The root supervisor is the sole enforcement component. The workload identity must never gain control-socket access or root authority.
|
| 6 |
-
- Direct `cgroup.kill` is authoritative. Do not replace it with process ancestry, compatibility backends or service-stop-only proof.
|
| 7 |
-
- Keep a trigger-side path free of durable state, evidence, hash, model and networking operations until direct containment has been attempted.
|
| 8 |
-
- Do not add network isolation, behavioural models, AI identification, BPF, cloud services or unsupported-platform claims without a separately approved evidence boundary.
|
| 9 |
-
- Preserve unrelated user changes. Keep generated artifacts, models, VM images, keys and local evidence out of source control.
|
| 10 |
-
- A partial or ambiguous result must never be reported as a successful termination.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHANGELOG.md
CHANGED
|
@@ -1,13 +1,10 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## 0.1.0
|
| 6 |
|
| 7 |
-
-
|
| 8 |
-
- Root-supervised launch of explicitly selected workloads in owned cgroup-v2 units.
|
| 9 |
-
- Operator-triggered complete workload-tree termination using `cgroup.kill`.
|
| 10 |
-
- Automatic PID-limit tripwire containment.
|
| 11 |
-
- Dedicated workload/operator identity separation and control-socket authentication.
|
| 12 |
-
- Post-containment receipts, status, list, doctor and version commands.
|
| 13 |
-
- Clean installer, uninstaller, native qualification matrix and local AI smoke demonstration.
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## 0.1.1
|
| 4 |
+
|
| 5 |
+
- Renamed the public product to Lumi Eggcracker.
|
| 6 |
+
- Repaired launch, watcher, lifecycle, receipt, identity, and release hygiene paths.
|
| 7 |
|
| 8 |
## 0.1.0
|
| 9 |
|
| 10 |
+
- Retained public engineering-preview predecessor; not rewritten by this release.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CONTRIBUTING.md
CHANGED
|
@@ -1,19 +1,5 @@
|
|
| 1 |
# Contributing
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
```bash
|
| 8 |
-
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
| 9 |
-
ruff check src tests scripts
|
| 10 |
-
python3 scripts/build_release.py --output dist
|
| 11 |
-
python3 scripts/verify_release.py \
|
| 12 |
-
--artifact dist/lumi-nutcracker-0.1.0.pyz \
|
| 13 |
-
--source-archive dist/lumi-nutcracker-0.1.0-source.zip \
|
| 14 |
-
--release-bundle dist/lumi-nutcracker-0.1.0-linux.zip
|
| 15 |
-
```
|
| 16 |
-
|
| 17 |
-
Native tests create and terminate root-owned systemd cgroups. Run them only inside a disposable Linux VM you own, and never modify them to target pre-existing processes or cgroups.
|
| 18 |
-
|
| 19 |
-
Open an issue before adding a new containment backend, network isolation, AI identification, behavioural models or a broader security claim.
|
|
|
|
| 1 |
# Contributing
|
| 2 |
|
| 3 |
+
Keep the product claim narrow and mechanically testable. Direct `cgroup.kill` plus exact empty proof remains authoritative; do not add unsupported backends, automatic AI recognition, behavioural models, networking, or host-wide claims without a separately qualified release boundary.
|
| 4 |
|
| 5 |
+
Run unit tests, lint, release construction, and public-artifact verification before proposing a change.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LIMITATIONS.md
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
# Limitations
|
| 2 |
|
| 3 |
-
Lumi
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
The
|
| 8 |
-
|
| 9 |
-
Qualification is limited to the published tested native Linux environment. Passing local tests does not establish universal Linux compatibility or production readiness for workloads outside that environment.
|
|
|
|
| 1 |
# Limitations
|
| 2 |
|
| 3 |
+
Lumi Eggcracker protects only commands explicitly launched through its local root supervisor on the qualified Linux/systemd/cgroup-v2 environment.
|
| 4 |
|
| 5 |
+
It does not discover AI processes, attach existing processes, identify malware, detect unknown intrusions, isolate networks, credentials or filesystems, provide general malware prevention, or replace EDR.
|
| 6 |
|
| 7 |
+
The `start` command is non-interactive in 0.1.1: there is no terminal attachment, current-working-directory forwarding, timeout, memory/CPU limit UX, or output streaming. The supervisor supports one active protected workload globally. Terminal records are retained locally up to 128 entries.
|
|
|
|
|
|
QUALIFICATION.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Qualification
|
| 2 |
+
|
| 3 |
+
The 0.1.1 candidate is releaseable only when its exact commit passes the native Ubuntu matrix and produces the minimal evidence pack. Required gates include 100/100 fork-race workload-tree kills, 100/100 unrelated-canary survivals, zero workload control-socket accesses or replacement launches, PID-tripwire containment, benign near-limit completion, fail-closed supervisor restart recovery, and p95 trigger-to-empty latency below 500 ms.
|
| 4 |
+
|
| 5 |
+
Run the matrix only on a disposable or dedicated Linux test host:
|
| 6 |
+
|
| 7 |
+
```sh
|
| 8 |
+
sudo python3 scripts/run_native_matrix.py --fork-race-repetitions 100 --benign-repetitions 50 --restart-repetitions 20 --socket-attempts 100 --output ./native-matrix.json
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
The evidence establishes behavior only for the tested environment and commit. It does not establish universal Linux compatibility or any unsupported security claim.
|
README.md
CHANGED
|
@@ -1,125 +1,51 @@
|
|
| 1 |
-
# Lumi
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
Lumi
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
- runs that command as a dedicated unprivileged, no-login user;
|
| 15 |
-
- kills the complete owned cgroup with the kernel's `cgroup.kill` primitive;
|
| 16 |
-
- verifies that the cgroup and all descendants are empty before issuing a success receipt;
|
| 17 |
-
- automatically contains workloads that hit their configured PID limit;
|
| 18 |
-
- fails closed for active workloads when the supervisor restarts.
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
Lumi Nutcracker does not discover AI processes, attach to existing processes, identify malware, detect unknown intrusions, isolate networks or credentials, prevent general malware, or replace endpoint security.
|
| 23 |
|
| 24 |
## Requirements
|
| 25 |
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
-
|
| 30 |
-
- a non-root local operator account.
|
| 31 |
-
|
| 32 |
-
## Install a GitHub release
|
| 33 |
-
|
| 34 |
-
Download and extract `lumi-nutcracker-0.1.0-linux.zip`, then run:
|
| 35 |
-
|
| 36 |
-
```bash
|
| 37 |
-
cd lumi-nutcracker-0.1.0
|
| 38 |
-
sha256sum -c SHA256SUMS
|
| 39 |
-
sudo python3 scripts/install.py \
|
| 40 |
-
--operator "$(id -un)" \
|
| 41 |
-
--artifact ./lumi-nutcracker-0.1.0.pyz
|
| 42 |
-
nutcracker doctor
|
| 43 |
-
```
|
| 44 |
-
|
| 45 |
-
The installer creates the dedicated `lumi-nutcracker-workload` identity dynamically. It does not assume a particular operator UID.
|
| 46 |
-
|
| 47 |
-
## Build from source
|
| 48 |
-
|
| 49 |
-
```bash
|
| 50 |
-
python3 scripts/build_release.py --output dist
|
| 51 |
-
python3 scripts/verify_release.py \
|
| 52 |
-
--artifact dist/lumi-nutcracker-0.1.0.pyz \
|
| 53 |
-
--source-archive dist/lumi-nutcracker-0.1.0-source.zip \
|
| 54 |
-
--release-bundle dist/lumi-nutcracker-0.1.0-linux.zip
|
| 55 |
-
sudo python3 scripts/install.py \
|
| 56 |
-
--operator "$(id -un)" \
|
| 57 |
-
--artifact dist/lumi-nutcracker-0.1.0.pyz
|
| 58 |
-
```
|
| 59 |
-
|
| 60 |
-
Release builds refuse a dirty Git tree so the embedded source commit remains meaningful.
|
| 61 |
|
| 62 |
## Quick start
|
| 63 |
|
| 64 |
-
``
|
| 65 |
-
nutcracker start --name local-agent --max-pids 64 -- /usr/bin/python3 agent.py
|
| 66 |
-
nutcracker status --name local-agent
|
| 67 |
-
nutcracker kill --name local-agent --receipt ./kill-receipt.json
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
-
The receipt is written only after direct cgroup kill and empty-hierarchy verification succeed.
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
nutcracker list
|
| 79 |
-
nutcracker doctor
|
| 80 |
-
nutcracker version
|
| 81 |
-
```
|
| 82 |
-
|
| 83 |
-
## Local AI smoke demonstration
|
| 84 |
-
|
| 85 |
-
With a locally installed `llama-cli` and a GGUF model readable by the workload identity:
|
| 86 |
-
|
| 87 |
-
```bash
|
| 88 |
-
python3 scripts/smoke_local_ai.py \
|
| 89 |
-
--llama-cli /opt/llama/llama-cli \
|
| 90 |
-
--model /opt/models/local.gguf \
|
| 91 |
-
--output ai-smoke.json
|
| 92 |
-
```
|
| 93 |
-
|
| 94 |
-
The script waits for visible model output, kills that selected inference workload through Nutcracker, and verifies that an unrelated canary survives.
|
| 95 |
-
|
| 96 |
-
## Uninstall
|
| 97 |
-
|
| 98 |
-
Terminate active protected workloads first, then run from the extracted release or source tree:
|
| 99 |
-
|
| 100 |
-
```bash
|
| 101 |
sudo python3 scripts/uninstall.py
|
| 102 |
-
sudo python3 scripts/verify_uninstalled.py
|
| 103 |
```
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
The 0.1.0 native Ubuntu qualification completed:
|
| 108 |
|
| 109 |
-
|
| 110 |
-
- 100/100 unrelated-canary survivals;
|
| 111 |
-
- zero successful control-socket accesses from the workload identity;
|
| 112 |
-
- zero successful replacement launches;
|
| 113 |
-
- automatic PID-tripwire containment;
|
| 114 |
-
- 50/50 bounded benign completions without a false kill;
|
| 115 |
-
- 20/20 fail-closed supervisor restart recoveries;
|
| 116 |
-
- measured p95 trigger-to-empty latency below 500 ms;
|
| 117 |
-
- a real local llama.cpp workload smoke test;
|
| 118 |
-
- clean installation and complete uninstallation;
|
| 119 |
-
- no firewall-rule objects or dependencies.
|
| 120 |
|
| 121 |
-
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
|
|
|
| 1 |
+
# Lumi Eggcracker
|
| 2 |
|
| 3 |
+
Lumi Eggcracker is a small, local Linux kill switch for an AI or agent workload you explicitly select.
|
| 4 |
|
| 5 |
+
> Lumi Eggcracker launches one explicitly selected AI or agent workload inside a root-controlled Linux cgroup and terminates the complete workload tree on operator command or when its configured PID-runaway tripwire fires.
|
| 6 |
|
| 7 |
+
It uses one root-owned supervisor, direct cgroup-v2 `cgroup.kill`, and an exact empty-cgroup proof. The workload runs under a dedicated no-login account that cannot access the control socket.
|
| 8 |
|
| 9 |
+
## It does
|
| 10 |
|
| 11 |
+
- launch one selected command in an exact owned cgroup;
|
| 12 |
+
- kill its complete cgroup tree on `eggcracker kill`;
|
| 13 |
+
- kill it automatically when its PID limit trips;
|
| 14 |
+
- write a post-containment receipt only after an exact empty proof;
|
| 15 |
+
- provide `start`, `kill`, `status`, `list`, `doctor`, and `version`.
|
| 16 |
|
| 17 |
+
## It does not do
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
It does not identify AI automatically, find unknown processes, detect intrusions or malware, isolate networks or credentials, prevent general malware, replace EDR, or attach an existing process to its boundary.
|
|
|
|
|
|
|
| 20 |
|
| 21 |
## Requirements
|
| 22 |
|
| 23 |
+
- Linux with systemd and unified cgroup v2;
|
| 24 |
+
- `pids` controller and `cgroup.kill` on transient service cgroups;
|
| 25 |
+
- root for install/uninstall; a non-root local operator account;
|
| 26 |
+
- Python 3.11 or newer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
## Quick start
|
| 29 |
|
| 30 |
+
Download and extract `lumi-eggcracker-0.1.1-linux.zip`, then run as root:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
```sh
|
| 33 |
+
cd lumi-eggcracker-0.1.1
|
| 34 |
+
sudo python3 scripts/install.py --operator "$USER" --artifact ./lumi-eggcracker-0.1.1.pyz
|
| 35 |
+
eggcracker doctor
|
| 36 |
+
eggcracker start --name demo --max-pids 8 -- /bin/sleep 60
|
| 37 |
+
eggcracker kill --name demo --receipt ./demo-receipt.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
sudo python3 scripts/uninstall.py
|
|
|
|
| 39 |
```
|
| 40 |
|
| 41 |
+
The selected command is launched through a systemd transient unit. It does not attach an interactive terminal, preserve your current working directory, or stream output to the CLI in this preview. Use absolute paths and redirect output in a wrapper script when needed.
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
## Real local AI smoke
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
With a local `llama-cli` and GGUF model available, the release bundle includes a smoke demonstration that waits for visible model output, kills the explicitly selected inference workload, and verifies an unrelated canary survives:
|
| 46 |
|
| 47 |
+
```sh
|
| 48 |
+
sudo python3 scripts/smoke_local_ai.py --llama-cli /path/to/llama-cli --model /path/to/model.gguf --output ./ai-smoke.json
|
| 49 |
+
```
|
| 50 |
|
| 51 |
+
See [SECURITY_MODEL.md](SECURITY_MODEL.md), [LIMITATIONS.md](LIMITATIONS.md), and [QUALIFICATION.md](QUALIFICATION.md) before using the preview.
|
RELEASE_NOTES.md
CHANGED
|
@@ -1,23 +1,5 @@
|
|
| 1 |
-
# Lumi
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
> Lumi Nutcracker launches an explicitly selected AI or agent workload inside a root-controlled Linux cgroup and terminates the complete workload tree on operator command or when its configured PID-runaway tripwire fires.
|
| 8 |
-
|
| 9 |
-
## Highlights
|
| 10 |
-
|
| 11 |
-
- One supported root-supervisor backend on systemd and unified cgroup v2.
|
| 12 |
-
- Dedicated no-login workload identity separated from the human operator.
|
| 13 |
-
- Direct `cgroup.kill` enforcement followed by exact empty-hierarchy verification.
|
| 14 |
-
- Operator kill, automatic PID tripwire, post-containment receipts and restart fail-closed recovery.
|
| 15 |
-
- Public commands: `start`, `kill`, `status`, `list`, `doctor` and `version`.
|
| 16 |
-
|
| 17 |
-
## Qualification
|
| 18 |
-
|
| 19 |
-
The release passed 100/100 fork-race kills, 100/100 unrelated-canary survivals, 100 denied workload socket attempts, zero replacement launches, five automatic PID-tripwire trials, 50 benign completions, 20 supervisor restart recoveries, clean install/uninstall and a real local AI smoke demonstration. Native p95 trigger-to-empty latency was below the precommitted 500 ms ceiling.
|
| 20 |
-
|
| 21 |
-
## Important limits
|
| 22 |
-
|
| 23 |
-
This release does not identify AI, discover unknown processes, attach to existing workloads, isolate network or credentials, detect malware, or replace EDR. See [LIMITATIONS.md](LIMITATIONS.md).
|
|
|
|
| 1 |
+
# Lumi Eggcracker 0.1.1
|
| 2 |
|
| 3 |
+
0.1.1 is a corrective engineering-preview candidate. It introduces a fail-closed launch gate, cgroup-event lifecycle authority, direct cgroup-kill-first enforcement, receipt-before-cleanup ordering, bounded run records, stricter workload-account isolation, and a consistent Lumi Eggcracker public surface.
|
| 4 |
|
| 5 |
+
It is not published automatically by this repository. Native Ubuntu qualification and explicit release approval are required before tag or promotion.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SECURITY.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
# Security policy
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
| 1 |
# Security policy
|
| 2 |
|
| 3 |
+
Lumi Eggcracker is a privileged local process-containment preview. Test only workloads and machines you own or administer.
|
| 4 |
|
| 5 |
+
Report vulnerabilities privately through the repository security-advisory channel. Do not include exploit code, machine-specific paths, credentials, or live workload details in public issues.
|
| 6 |
+
|
| 7 |
+
The security boundary and non-claims are documented in [SECURITY_MODEL.md](SECURITY_MODEL.md).
|
SECURITY_MODEL.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security model
|
| 2 |
+
|
| 3 |
+
The root-owned `lumi-eggcracker.service` is the only enforcement component. It accepts requests only from root or the configured operator UID over a root-owned Unix socket. A selected workload runs as a dedicated no-login unprivileged user with no supplementary groups; that identity cannot read, connect to, or invoke the socket.
|
| 4 |
+
|
| 5 |
+
Each workload is placed in an exactly identified systemd transient cgroup. On an operator request, PID-limit tripwire, watcher failure, or supervisor restart recovery, the supervisor validates that exact cgroup identity and writes `1` to its `cgroup.kill` control file. It then proves that the complete cgroup hierarchy is empty. Systemd stop is cleanup only.
|
| 6 |
+
|
| 7 |
+
This is not a general sandbox, host intrusion detector, malware detector, network isolator, credential isolator, or EDR replacement. The selected workload may still access everything normally available to its unprivileged account.
|
pyproject.toml
CHANGED
|
@@ -3,8 +3,8 @@ requires = ["setuptools>=77"]
|
|
| 3 |
build-backend = "setuptools.build_meta"
|
| 4 |
|
| 5 |
[project]
|
| 6 |
-
name = "lumi-
|
| 7 |
-
version = "0.1.
|
| 8 |
description = "A local Linux kill switch for explicitly selected AI and agent workloads"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.11"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[project.scripts]
|
| 25 |
-
|
| 26 |
|
| 27 |
[tool.setuptools]
|
| 28 |
package-dir = {"" = "src"}
|
|
|
|
| 3 |
build-backend = "setuptools.build_meta"
|
| 4 |
|
| 5 |
[project]
|
| 6 |
+
name = "lumi-eggcracker"
|
| 7 |
+
version = "0.1.1"
|
| 8 |
description = "A local Linux kill switch for explicitly selected AI and agent workloads"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.11"
|
|
|
|
| 22 |
]
|
| 23 |
|
| 24 |
[project.scripts]
|
| 25 |
+
eggcracker = "lumi_eggcracker.cli:main"
|
| 26 |
|
| 27 |
[tool.setuptools]
|
| 28 |
package-dir = {"" = "src"}
|
scripts/build_release.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Build a clean Lumi
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -24,7 +24,7 @@ def digest(path: Path) -> str:
|
|
| 24 |
|
| 25 |
|
| 26 |
def version() -> str:
|
| 27 |
-
value = (ROOT / "src" / "
|
| 28 |
match = re.search(r'__version__ = "([0-9]+\.[0-9]+\.[0-9]+)"', value)
|
| 29 |
if not match:
|
| 30 |
raise RuntimeError("cannot determine public version")
|
|
@@ -48,20 +48,20 @@ def main() -> int:
|
|
| 48 |
release_version = version()
|
| 49 |
commit = command(["git", "-C", str(ROOT), "rev-parse", "HEAD"])
|
| 50 |
args.output.mkdir(parents=True, exist_ok=True)
|
| 51 |
-
artifact = args.output / f"lumi-
|
| 52 |
-
source = args.output / f"lumi-
|
| 53 |
-
bundle = args.output / f"lumi-
|
| 54 |
-
with tempfile.TemporaryDirectory(prefix="lumi-
|
| 55 |
stage = Path(raw) / "src"
|
| 56 |
-
shutil.copytree(ROOT / "src", stage, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
| 57 |
-
(stage / "__main__.py").write_text("from
|
| 58 |
-
(stage / "
|
| 59 |
zipapp.create_archive(stage, target=artifact, interpreter="/usr/bin/env python3")
|
| 60 |
-
command(["git", "-C", str(ROOT), "archive", "--format=zip", f"--prefix=lumi-
|
| 61 |
manifest = {"artifact": artifact.name, "sha256": digest(artifact), "source_archive": source.name, "source_archive_sha256": digest(source), "source_commit": commit, "version": release_version}
|
| 62 |
(args.output / "release-manifest.json").write_text(__import__("json").dumps(manifest, sort_keys=True) + "\n", encoding="utf-8")
|
| 63 |
(args.output / "SHA256SUMS").write_text(f"{manifest['sha256']} {artifact.name}\n{manifest['source_archive_sha256']} {source.name}\n", encoding="utf-8")
|
| 64 |
-
prefix = f"lumi-
|
| 65 |
release_files = {
|
| 66 |
artifact: artifact.name,
|
| 67 |
source: source.name,
|
|
@@ -71,6 +71,8 @@ def main() -> int:
|
|
| 71 |
ROOT / "LICENSE": "LICENSE",
|
| 72 |
ROOT / "LIMITATIONS.md": "LIMITATIONS.md",
|
| 73 |
ROOT / "SECURITY.md": "SECURITY.md",
|
|
|
|
|
|
|
| 74 |
ROOT / "RELEASE_NOTES.md": "RELEASE_NOTES.md",
|
| 75 |
ROOT / "scripts" / "install.py": "scripts/install.py",
|
| 76 |
ROOT / "scripts" / "uninstall.py": "scripts/uninstall.py",
|
|
@@ -80,6 +82,12 @@ def main() -> int:
|
|
| 80 |
with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
| 81 |
for path, name in release_files.items():
|
| 82 |
archive.write(path, f"{prefix}/{name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
print(bundle)
|
| 84 |
return 0
|
| 85 |
|
|
|
|
| 1 |
+
"""Build a clean Lumi Eggcracker zipapp, source archive, and checksums."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def version() -> str:
|
| 27 |
+
value = (ROOT / "src" / "lumi_eggcracker" / "__init__.py").read_text(encoding="utf-8")
|
| 28 |
match = re.search(r'__version__ = "([0-9]+\.[0-9]+\.[0-9]+)"', value)
|
| 29 |
if not match:
|
| 30 |
raise RuntimeError("cannot determine public version")
|
|
|
|
| 48 |
release_version = version()
|
| 49 |
commit = command(["git", "-C", str(ROOT), "rev-parse", "HEAD"])
|
| 50 |
args.output.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
artifact = args.output / f"lumi-eggcracker-{release_version}.pyz"
|
| 52 |
+
source = args.output / f"lumi-eggcracker-{release_version}-source.zip"
|
| 53 |
+
bundle = args.output / f"lumi-eggcracker-{release_version}-linux.zip"
|
| 54 |
+
with tempfile.TemporaryDirectory(prefix="lumi-eggcracker-build-") as raw:
|
| 55 |
stage = Path(raw) / "src"
|
| 56 |
+
shutil.copytree(ROOT / "src", stage, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.egg-info"))
|
| 57 |
+
(stage / "__main__.py").write_text("from lumi_eggcracker.cli import main\nraise SystemExit(main())\n", encoding="utf-8")
|
| 58 |
+
(stage / "lumi_eggcracker" / "build_info.py").write_text(f'SOURCE_COMMIT = "{commit}"\n', encoding="utf-8")
|
| 59 |
zipapp.create_archive(stage, target=artifact, interpreter="/usr/bin/env python3")
|
| 60 |
+
command(["git", "-C", str(ROOT), "archive", "--format=zip", f"--prefix=lumi-eggcracker-{release_version}/", "-o", str(source), "HEAD"])
|
| 61 |
manifest = {"artifact": artifact.name, "sha256": digest(artifact), "source_archive": source.name, "source_archive_sha256": digest(source), "source_commit": commit, "version": release_version}
|
| 62 |
(args.output / "release-manifest.json").write_text(__import__("json").dumps(manifest, sort_keys=True) + "\n", encoding="utf-8")
|
| 63 |
(args.output / "SHA256SUMS").write_text(f"{manifest['sha256']} {artifact.name}\n{manifest['source_archive_sha256']} {source.name}\n", encoding="utf-8")
|
| 64 |
+
prefix = f"lumi-eggcracker-{release_version}"
|
| 65 |
release_files = {
|
| 66 |
artifact: artifact.name,
|
| 67 |
source: source.name,
|
|
|
|
| 71 |
ROOT / "LICENSE": "LICENSE",
|
| 72 |
ROOT / "LIMITATIONS.md": "LIMITATIONS.md",
|
| 73 |
ROOT / "SECURITY.md": "SECURITY.md",
|
| 74 |
+
ROOT / "SECURITY_MODEL.md": "SECURITY_MODEL.md",
|
| 75 |
+
ROOT / "QUALIFICATION.md": "QUALIFICATION.md",
|
| 76 |
ROOT / "RELEASE_NOTES.md": "RELEASE_NOTES.md",
|
| 77 |
ROOT / "scripts" / "install.py": "scripts/install.py",
|
| 78 |
ROOT / "scripts" / "uninstall.py": "scripts/uninstall.py",
|
|
|
|
| 82 |
with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
| 83 |
for path, name in release_files.items():
|
| 84 |
archive.write(path, f"{prefix}/{name}")
|
| 85 |
+
# The bundle cannot contain a checksum of itself without recursion. The
|
| 86 |
+
# detached checksum file intentionally covers all published binary assets.
|
| 87 |
+
(args.output / "SHA256SUMS").write_text(
|
| 88 |
+
f"{manifest['sha256']} {artifact.name}\n{manifest['source_archive_sha256']} {source.name}\n{digest(bundle)} {bundle.name}\n",
|
| 89 |
+
encoding="utf-8",
|
| 90 |
+
)
|
| 91 |
print(bundle)
|
| 92 |
return 0
|
| 93 |
|
scripts/install.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Manifest-bound installer for
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -16,14 +16,15 @@ import time
|
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
| 25 |
SOCKET = RUNTIME / "control.sock"
|
| 26 |
-
WORKLOAD_NAME = "lumi-
|
| 27 |
TARGETS = (LIB, BIN, ETC, UNIT, STATE, RUNTIME)
|
| 28 |
|
| 29 |
|
|
@@ -54,7 +55,7 @@ def manifest_for(artifact: Path) -> dict[str, Any]:
|
|
| 54 |
expected = {"artifact", "sha256", "source_archive", "source_archive_sha256", "source_commit", "version"}
|
| 55 |
if set(value) != expected or value["artifact"] != artifact.name or value["sha256"] != digest(artifact):
|
| 56 |
raise RuntimeError("release artifact identity does not match manifest")
|
| 57 |
-
if value["version"] !=
|
| 58 |
raise RuntimeError("release manifest version or source identity is invalid")
|
| 59 |
return value
|
| 60 |
|
|
@@ -63,7 +64,7 @@ def workload_account() -> tuple[pwd.struct_passwd, bool]:
|
|
| 63 |
try:
|
| 64 |
account = pwd.getpwnam(WORKLOAD_NAME)
|
| 65 |
if account.pw_uid == 0 or account.pw_shell not in {"/usr/sbin/nologin", "/sbin/nologin"} or account.pw_dir != "/nonexistent":
|
| 66 |
-
raise RuntimeError("existing workload account does not meet
|
| 67 |
return account, False
|
| 68 |
except KeyError:
|
| 69 |
nologin = "/usr/sbin/nologin" if Path("/usr/sbin/nologin").is_file() else "/sbin/nologin"
|
|
@@ -74,12 +75,11 @@ def workload_account() -> tuple[pwd.struct_passwd, bool]:
|
|
| 74 |
|
| 75 |
|
| 76 |
def service() -> bytes:
|
| 77 |
-
return b"""[Unit]\nDescription=Lumi
|
| 78 |
|
| 79 |
|
| 80 |
def cgroup_kill_available() -> bool:
|
| 81 |
-
|
| 82 |
-
unit = f"lumi-nutcracker-preflight-{secrets.token_hex(8)}.service"
|
| 83 |
started = run(["/usr/bin/systemd-run", f"--unit={unit}", "--collect", "--property=Type=exec", "--", "/bin/sleep", "5"])
|
| 84 |
if started.returncode:
|
| 85 |
return False
|
|
@@ -87,14 +87,13 @@ def cgroup_kill_available() -> bool:
|
|
| 87 |
shown = run(["/usr/bin/systemctl", "show", unit, "--property=ControlGroup"])
|
| 88 |
values = dict(line.split("=", 1) for line in shown.stdout.splitlines() if "=" in line)
|
| 89 |
cgroup = values.get("ControlGroup", "")
|
| 90 |
-
|
| 91 |
-
return bool(cgroup.startswith("/system.slice/")) and (Path("/sys/fs/cgroup").joinpath(*parts) / "cgroup.kill").is_file()
|
| 92 |
finally:
|
| 93 |
run(["/usr/bin/systemctl", "stop", unit])
|
| 94 |
|
| 95 |
|
| 96 |
-
def cleanup(created: list[Path], created_user: bool, created_group: bool
|
| 97 |
-
run(["/usr/bin/systemctl", "disable", "--now", "lumi-
|
| 98 |
for path in reversed(created):
|
| 99 |
if path.is_dir() and not path.is_symlink():
|
| 100 |
shutil.rmtree(path)
|
|
@@ -121,39 +120,32 @@ def main() -> int:
|
|
| 121 |
if operator.pw_uid == 0:
|
| 122 |
raise SystemExit("operator must be non-root")
|
| 123 |
if any(path.exists() or path.is_symlink() for path in TARGETS):
|
| 124 |
-
raise SystemExit("refusing pre-existing
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
raise SystemExit("cgroup PID controller is required")
|
| 129 |
-
if not cgroup_kill_available():
|
| 130 |
-
raise SystemExit("a transient system workload cgroup with cgroup.kill is required")
|
| 131 |
account, created_user = workload_account()
|
| 132 |
group = grp.getgrgid(account.pw_gid)
|
| 133 |
created_group = created_user and group.gr_name == WORKLOAD_NAME
|
| 134 |
-
|
|
|
|
| 135 |
cleanup([], created_user, created_group)
|
| 136 |
-
raise SystemExit("workload identity must be
|
| 137 |
created: list[Path] = []
|
| 138 |
try:
|
| 139 |
-
LIB.mkdir(mode=0o755)
|
| 140 |
-
created.append(
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
created.append(
|
| 145 |
-
|
| 146 |
-
os.chmod(LIB / "lumi-nutcracker.pyz", 0o755)
|
| 147 |
-
write_new(BIN, b"#!/bin/sh\nexec /usr/bin/python3 /usr/local/lib/lumi-nutcracker/lumi-nutcracker.pyz \"$@\"\n", 0o755)
|
| 148 |
-
created.append(BIN)
|
| 149 |
-
policy = {"operator_gid": operator.pw_gid, "operator_uid": operator.pw_uid, "schema_version": "lumi-nutcracker.policy.v1", "socket_path": str(SOCKET), "source_commit": release["source_commit"], "state_dir": str(STATE), "unit_prefix": "lumi-nutcracker-workload-", "version": release["version"], "workload_gid": account.pw_gid, "workload_uid": account.pw_uid}
|
| 150 |
write_new(ETC / "policy.json", (json.dumps(policy, sort_keys=True) + "\n").encode(), 0o600)
|
| 151 |
-
write_new(UNIT, service(), 0o644)
|
| 152 |
-
|
| 153 |
-
manifest = {"created_workload_group": created_group, "created_workload_user": created_user, "files": {str(BIN): digest(BIN), str(ETC / "policy.json"): digest(ETC / "policy.json"), str(LIB / "lumi-nutcracker.pyz"): digest(LIB / "lumi-nutcracker.pyz"), str(UNIT): digest(UNIT)}, "operator": operator.pw_name, "operator_uid": operator.pw_uid, "schema_version": "lumi-nutcracker.install.v1", "targets": [str(path) for path in TARGETS], "workload_group": group.gr_name, "workload_uid": account.pw_uid, "workload_user": account.pw_name}
|
| 154 |
write_new(STATE / "install-manifest.json", (json.dumps(manifest, sort_keys=True) + "\n").encode(), 0o600)
|
| 155 |
checked = run(["/usr/bin/systemctl", "daemon-reload"])
|
| 156 |
-
started = run(["/usr/bin/systemctl", "enable", "--now", "lumi-
|
| 157 |
if checked.returncode or started.returncode:
|
| 158 |
raise RuntimeError((checked.stderr + started.stderr).strip() or "cannot start supervisor")
|
| 159 |
deadline = time.monotonic() + 15
|
|
@@ -162,7 +154,7 @@ def main() -> int:
|
|
| 162 |
metadata = SOCKET.stat()
|
| 163 |
if not SOCKET.exists() or stat.S_IMODE(metadata.st_mode) != 0o660 or metadata.st_uid != 0 or metadata.st_gid != operator.pw_gid:
|
| 164 |
raise RuntimeError("control socket ownership contract failed")
|
| 165 |
-
print(json.dumps({"result": "INSTALLED", "service": "lumi-
|
| 166 |
return 0
|
| 167 |
except Exception:
|
| 168 |
cleanup(created, created_user, created_group)
|
|
|
|
| 1 |
+
"""Manifest-bound installer for Lumi Eggcracker's root supervisor."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
+
VERSION = "0.1.1"
|
| 20 |
+
LIB = Path("/usr/local/lib/lumi-eggcracker")
|
| 21 |
+
BIN = Path("/usr/local/bin/eggcracker")
|
| 22 |
+
ETC = Path("/etc/lumi-eggcracker")
|
| 23 |
+
UNIT = Path("/etc/systemd/system/lumi-eggcracker.service")
|
| 24 |
+
STATE = Path("/var/lib/lumi-eggcracker")
|
| 25 |
+
RUNTIME = Path("/run/lumi-eggcracker")
|
| 26 |
SOCKET = RUNTIME / "control.sock"
|
| 27 |
+
WORKLOAD_NAME = "lumi-eggcracker-workload"
|
| 28 |
TARGETS = (LIB, BIN, ETC, UNIT, STATE, RUNTIME)
|
| 29 |
|
| 30 |
|
|
|
|
| 55 |
expected = {"artifact", "sha256", "source_archive", "source_archive_sha256", "source_commit", "version"}
|
| 56 |
if set(value) != expected or value["artifact"] != artifact.name or value["sha256"] != digest(artifact):
|
| 57 |
raise RuntimeError("release artifact identity does not match manifest")
|
| 58 |
+
if value["version"] != VERSION or len(value["source_commit"]) != 40:
|
| 59 |
raise RuntimeError("release manifest version or source identity is invalid")
|
| 60 |
return value
|
| 61 |
|
|
|
|
| 64 |
try:
|
| 65 |
account = pwd.getpwnam(WORKLOAD_NAME)
|
| 66 |
if account.pw_uid == 0 or account.pw_shell not in {"/usr/sbin/nologin", "/sbin/nologin"} or account.pw_dir != "/nonexistent":
|
| 67 |
+
raise RuntimeError("existing workload account does not meet Eggcracker contract")
|
| 68 |
return account, False
|
| 69 |
except KeyError:
|
| 70 |
nologin = "/usr/sbin/nologin" if Path("/usr/sbin/nologin").is_file() else "/sbin/nologin"
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def service() -> bytes:
|
| 78 |
+
return b"""[Unit]\nDescription=Lumi Eggcracker protected workload supervisor\nAfter=multi-user.target\nStartLimitIntervalSec=60\nStartLimitBurst=60\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/lib/lumi-eggcracker/lumi-eggcracker.pyz _supervisor --policy /etc/lumi-eggcracker/policy.json\nRestart=always\nRestartSec=0.1\nRuntimeDirectory=lumi-eggcracker\nRuntimeDirectoryMode=0710\nUMask=0077\nNoNewPrivileges=yes\n\n[Install]\nWantedBy=multi-user.target\n"""
|
| 79 |
|
| 80 |
|
| 81 |
def cgroup_kill_available() -> bool:
|
| 82 |
+
unit = f"lumi-eggcracker-preflight-{secrets.token_hex(8)}.service"
|
|
|
|
| 83 |
started = run(["/usr/bin/systemd-run", f"--unit={unit}", "--collect", "--property=Type=exec", "--", "/bin/sleep", "5"])
|
| 84 |
if started.returncode:
|
| 85 |
return False
|
|
|
|
| 87 |
shown = run(["/usr/bin/systemctl", "show", unit, "--property=ControlGroup"])
|
| 88 |
values = dict(line.split("=", 1) for line in shown.stdout.splitlines() if "=" in line)
|
| 89 |
cgroup = values.get("ControlGroup", "")
|
| 90 |
+
return bool(cgroup.startswith("/system.slice/")) and (Path("/sys/fs/cgroup").joinpath(*cgroup.lstrip("/").split("/")) / "cgroup.kill").is_file()
|
|
|
|
| 91 |
finally:
|
| 92 |
run(["/usr/bin/systemctl", "stop", unit])
|
| 93 |
|
| 94 |
|
| 95 |
+
def cleanup(created: list[Path], created_user: bool, created_group: bool) -> None:
|
| 96 |
+
run(["/usr/bin/systemctl", "disable", "--now", "lumi-eggcracker.service"])
|
| 97 |
for path in reversed(created):
|
| 98 |
if path.is_dir() and not path.is_symlink():
|
| 99 |
shutil.rmtree(path)
|
|
|
|
| 120 |
if operator.pw_uid == 0:
|
| 121 |
raise SystemExit("operator must be non-root")
|
| 122 |
if any(path.exists() or path.is_symlink() for path in TARGETS):
|
| 123 |
+
raise SystemExit("refusing pre-existing Eggcracker installation target")
|
| 124 |
+
controllers = Path("/sys/fs/cgroup/cgroup.controllers")
|
| 125 |
+
if not controllers.is_file() or "pids" not in controllers.read_text(encoding="ascii").split() or not cgroup_kill_available():
|
| 126 |
+
raise SystemExit("unified cgroup v2 with cgroup.kill and PID controller is required")
|
|
|
|
|
|
|
|
|
|
| 127 |
account, created_user = workload_account()
|
| 128 |
group = grp.getgrgid(account.pw_gid)
|
| 129 |
created_group = created_user and group.gr_name == WORKLOAD_NAME
|
| 130 |
+
supplementary = set(os.getgrouplist(account.pw_name, account.pw_gid))
|
| 131 |
+
if account.pw_uid in {0, operator.pw_uid} or account.pw_gid == operator.pw_gid or supplementary != {account.pw_gid}:
|
| 132 |
cleanup([], created_user, created_group)
|
| 133 |
+
raise SystemExit("workload identity must be isolated from the operator and have no supplementary groups")
|
| 134 |
created: list[Path] = []
|
| 135 |
try:
|
| 136 |
+
LIB.mkdir(mode=0o755); created.append(LIB)
|
| 137 |
+
ETC.mkdir(mode=0o700); created.append(ETC)
|
| 138 |
+
STATE.mkdir(mode=0o700); created.append(STATE)
|
| 139 |
+
shutil.copyfile(args.artifact, LIB / "lumi-eggcracker.pyz")
|
| 140 |
+
os.chmod(LIB / "lumi-eggcracker.pyz", 0o755)
|
| 141 |
+
write_new(BIN, b"#!/bin/sh\nexec /usr/bin/python3 /usr/local/lib/lumi-eggcracker/lumi-eggcracker.pyz \"$@\"\n", 0o755); created.append(BIN)
|
| 142 |
+
policy = {"operator_gid": operator.pw_gid, "operator_uid": operator.pw_uid, "schema_version": "lumi-eggcracker.policy.v2", "socket_path": str(SOCKET), "source_commit": release["source_commit"], "state_dir": str(STATE), "unit_prefix": "lumi-eggcracker-workload-", "version": release["version"], "workload_gid": account.pw_gid, "workload_uid": account.pw_uid}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
write_new(ETC / "policy.json", (json.dumps(policy, sort_keys=True) + "\n").encode(), 0o600)
|
| 144 |
+
write_new(UNIT, service(), 0o644); created.append(UNIT)
|
| 145 |
+
manifest = {"created_workload_group": created_group, "created_workload_user": created_user, "files": {str(BIN): digest(BIN), str(ETC / "policy.json"): digest(ETC / "policy.json"), str(LIB / "lumi-eggcracker.pyz"): digest(LIB / "lumi-eggcracker.pyz"), str(UNIT): digest(UNIT)}, "operator": operator.pw_name, "operator_uid": operator.pw_uid, "schema_version": "lumi-eggcracker.install.v2", "targets": [str(path) for path in TARGETS], "workload_group": group.gr_name, "workload_uid": account.pw_uid, "workload_user": account.pw_name}
|
|
|
|
| 146 |
write_new(STATE / "install-manifest.json", (json.dumps(manifest, sort_keys=True) + "\n").encode(), 0o600)
|
| 147 |
checked = run(["/usr/bin/systemctl", "daemon-reload"])
|
| 148 |
+
started = run(["/usr/bin/systemctl", "enable", "--now", "lumi-eggcracker.service"])
|
| 149 |
if checked.returncode or started.returncode:
|
| 150 |
raise RuntimeError((checked.stderr + started.stderr).strip() or "cannot start supervisor")
|
| 151 |
deadline = time.monotonic() + 15
|
|
|
|
| 154 |
metadata = SOCKET.stat()
|
| 155 |
if not SOCKET.exists() or stat.S_IMODE(metadata.st_mode) != 0o660 or metadata.st_uid != 0 or metadata.st_gid != operator.pw_gid:
|
| 156 |
raise RuntimeError("control socket ownership contract failed")
|
| 157 |
+
print(json.dumps({"result": "INSTALLED", "service": "lumi-eggcracker.service", "workload_uid": account.pw_uid}, sort_keys=True))
|
| 158 |
return 0
|
| 159 |
except Exception:
|
| 160 |
cleanup(created, created_user, created_group)
|
scripts/run_native_matrix.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Root-only native qualification for fresh
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
| 14 |
from typing import Any
|
| 15 |
|
| 16 |
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
-
CLI = "/usr/local/bin/
|
| 18 |
|
| 19 |
|
| 20 |
def run(argv: list[str], *, check: bool = True, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
|
@@ -77,7 +77,7 @@ def main() -> int:
|
|
| 77 |
raise SystemExit("output must be a new file under an existing directory")
|
| 78 |
if args.fork_race_repetitions != 100 or args.benign_repetitions < 50 or args.restart_repetitions < 20 or args.socket_attempts < 100:
|
| 79 |
raise SystemExit("qualification counts are below the precommitted gates")
|
| 80 |
-
install = json.loads(Path("/var/lib/lumi-
|
| 81 |
operator = str(install["operator"])
|
| 82 |
workload = str(install["workload_user"])
|
| 83 |
workload_uid = pwd.getpwnam(workload).pw_uid
|
|
@@ -97,7 +97,7 @@ def main() -> int:
|
|
| 97 |
name = f"race-{token}-{index}"
|
| 98 |
call(operator, ["start", "--name", name, "--max-pids", "4096", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/fork_race.py"), modes[index % len(modes)]])
|
| 99 |
time.sleep(0.12)
|
| 100 |
-
receipt_path = Path("/tmp") / f"lumi-
|
| 101 |
receipt = call(operator, ["kill", "--name", name, "--receipt", str(receipt_path)])
|
| 102 |
if receipt.get("result") != "TERMINATED" or receipt["trigger"]["kind"] != "OPERATOR" or receipt["containment"]["surviving_pids"]:
|
| 103 |
raise RuntimeError("fork race did not produce exact termination receipt")
|
|
@@ -113,7 +113,7 @@ def main() -> int:
|
|
| 113 |
name = f"pressure-{token}-{index}"
|
| 114 |
call(operator, ["start", "--name", name, "--max-pids", "4", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/pid_pressure.py")])
|
| 115 |
state = wait_state(operator, name, "TERMINATED")
|
| 116 |
-
receipt_files = sorted(Path("/var/lib/lumi-
|
| 117 |
receipt = json.loads(receipt_files[-1].read_text(encoding="utf-8"))
|
| 118 |
if receipt["trigger"]["kind"] != "PID_LIMIT":
|
| 119 |
raise RuntimeError(f"PID tripwire did not create PID receipt: {state}")
|
|
@@ -126,7 +126,7 @@ def main() -> int:
|
|
| 126 |
if state.get("state") != "COMPLETED_ALLOWED":
|
| 127 |
raise RuntimeError("benign near-limit workload was not allowed")
|
| 128 |
results["benign"].append(state["state"])
|
| 129 |
-
hostile_path = Path("/tmp") / f"lumi-
|
| 130 |
hostile = f"hostile-{token}"
|
| 131 |
call(operator, ["start", "--name", hostile, "--max-pids", "8", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/hostile_client.py"), str(hostile_path), str(args.socket_attempts)])
|
| 132 |
wait_state(operator, hostile, "COMPLETED_ALLOWED", timeout=30)
|
|
@@ -141,9 +141,9 @@ def main() -> int:
|
|
| 141 |
for index in range(args.restart_repetitions):
|
| 142 |
name = f"restart-{token}-{index}"
|
| 143 |
call(operator, ["start", "--name", name, "--max-pids", "8", "--", "/bin/sleep", "30"])
|
| 144 |
-
run(["/usr/bin/systemctl", "kill", "--kill-who=main", "-s", "SIGKILL", "lumi-
|
| 145 |
state = wait_state(operator, name, "TERMINATED", timeout=12)
|
| 146 |
-
receipt_files = sorted(Path("/var/lib/lumi-
|
| 147 |
receipt = json.loads(receipt_files[-1].read_text(encoding="utf-8"))
|
| 148 |
if receipt["trigger"]["kind"] != "SUPERVISOR_RESTART_FAIL_CLOSED":
|
| 149 |
raise RuntimeError(f"restart did not fail closed: {state}")
|
|
@@ -162,9 +162,9 @@ def main() -> int:
|
|
| 162 |
args.output.write_text(json.dumps(results, sort_keys=True) + "\n", encoding="utf-8")
|
| 163 |
raise
|
| 164 |
finally:
|
| 165 |
-
active = run(["/usr/bin/systemctl", "list-units", "lumi-
|
| 166 |
for line in active.stdout.splitlines():
|
| 167 |
-
if line.split() and line.split()[0].startswith("lumi-
|
| 168 |
run(["/usr/bin/systemctl", "stop", line.split()[0]], check=False)
|
| 169 |
|
| 170 |
|
|
|
|
| 1 |
+
"""Root-only native qualification for fresh Eggcracker-owned fixture workloads."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 14 |
from typing import Any
|
| 15 |
|
| 16 |
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
CLI = "/usr/local/bin/eggcracker"
|
| 18 |
|
| 19 |
|
| 20 |
def run(argv: list[str], *, check: bool = True, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
|
|
|
| 77 |
raise SystemExit("output must be a new file under an existing directory")
|
| 78 |
if args.fork_race_repetitions != 100 or args.benign_repetitions < 50 or args.restart_repetitions < 20 or args.socket_attempts < 100:
|
| 79 |
raise SystemExit("qualification counts are below the precommitted gates")
|
| 80 |
+
install = json.loads(Path("/var/lib/lumi-eggcracker/install-manifest.json").read_text(encoding="utf-8"))
|
| 81 |
operator = str(install["operator"])
|
| 82 |
workload = str(install["workload_user"])
|
| 83 |
workload_uid = pwd.getpwnam(workload).pw_uid
|
|
|
|
| 97 |
name = f"race-{token}-{index}"
|
| 98 |
call(operator, ["start", "--name", name, "--max-pids", "4096", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/fork_race.py"), modes[index % len(modes)]])
|
| 99 |
time.sleep(0.12)
|
| 100 |
+
receipt_path = Path("/tmp") / f"lumi-eggcracker-receipt-{token}-{index}.json"
|
| 101 |
receipt = call(operator, ["kill", "--name", name, "--receipt", str(receipt_path)])
|
| 102 |
if receipt.get("result") != "TERMINATED" or receipt["trigger"]["kind"] != "OPERATOR" or receipt["containment"]["surviving_pids"]:
|
| 103 |
raise RuntimeError("fork race did not produce exact termination receipt")
|
|
|
|
| 113 |
name = f"pressure-{token}-{index}"
|
| 114 |
call(operator, ["start", "--name", name, "--max-pids", "4", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/pid_pressure.py")])
|
| 115 |
state = wait_state(operator, name, "TERMINATED")
|
| 116 |
+
receipt_files = sorted(Path("/var/lib/lumi-eggcracker/receipts").glob("*.json"), key=lambda path: path.stat().st_mtime_ns)
|
| 117 |
receipt = json.loads(receipt_files[-1].read_text(encoding="utf-8"))
|
| 118 |
if receipt["trigger"]["kind"] != "PID_LIMIT":
|
| 119 |
raise RuntimeError(f"PID tripwire did not create PID receipt: {state}")
|
|
|
|
| 126 |
if state.get("state") != "COMPLETED_ALLOWED":
|
| 127 |
raise RuntimeError("benign near-limit workload was not allowed")
|
| 128 |
results["benign"].append(state["state"])
|
| 129 |
+
hostile_path = Path("/tmp") / f"lumi-eggcracker-hostile-{token}.json"
|
| 130 |
hostile = f"hostile-{token}"
|
| 131 |
call(operator, ["start", "--name", hostile, "--max-pids", "8", "--", "/usr/bin/python3", str(ROOT / "tests/fixtures/hostile_client.py"), str(hostile_path), str(args.socket_attempts)])
|
| 132 |
wait_state(operator, hostile, "COMPLETED_ALLOWED", timeout=30)
|
|
|
|
| 141 |
for index in range(args.restart_repetitions):
|
| 142 |
name = f"restart-{token}-{index}"
|
| 143 |
call(operator, ["start", "--name", name, "--max-pids", "8", "--", "/bin/sleep", "30"])
|
| 144 |
+
run(["/usr/bin/systemctl", "kill", "--kill-who=main", "-s", "SIGKILL", "lumi-eggcracker.service"])
|
| 145 |
state = wait_state(operator, name, "TERMINATED", timeout=12)
|
| 146 |
+
receipt_files = sorted(Path("/var/lib/lumi-eggcracker/receipts").glob("*.json"), key=lambda path: path.stat().st_mtime_ns)
|
| 147 |
receipt = json.loads(receipt_files[-1].read_text(encoding="utf-8"))
|
| 148 |
if receipt["trigger"]["kind"] != "SUPERVISOR_RESTART_FAIL_CLOSED":
|
| 149 |
raise RuntimeError(f"restart did not fail closed: {state}")
|
|
|
|
| 162 |
args.output.write_text(json.dumps(results, sort_keys=True) + "\n", encoding="utf-8")
|
| 163 |
raise
|
| 164 |
finally:
|
| 165 |
+
active = run(["/usr/bin/systemctl", "list-units", "lumi-eggcracker-workload-*", "--type=service", "--state=active", "--plain", "--no-legend"], check=False)
|
| 166 |
for line in active.stdout.splitlines():
|
| 167 |
+
if line.split() and line.split()[0].startswith("lumi-eggcracker-workload-"):
|
| 168 |
run(["/usr/bin/systemctl", "stop", line.split()[0]], check=False)
|
| 169 |
|
| 170 |
|
scripts/smoke_local_ai.py
CHANGED
|
@@ -23,9 +23,9 @@ def digest(path: Path) -> str:
|
|
| 23 |
|
| 24 |
|
| 25 |
def call(args: list[str]) -> dict[str, object]:
|
| 26 |
-
result = subprocess.run(["/usr/local/bin/
|
| 27 |
if result.returncode:
|
| 28 |
-
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "
|
| 29 |
return json.loads(result.stdout)
|
| 30 |
|
| 31 |
|
|
@@ -39,7 +39,7 @@ def main() -> int:
|
|
| 39 |
raise SystemExit("output must be a new file under an existing directory")
|
| 40 |
if not args.llama_cli.is_file() or not os.access(args.llama_cli, os.X_OK) or not args.model.is_file():
|
| 41 |
raise SystemExit("llama-cli executable and GGUF model are required")
|
| 42 |
-
with tempfile.TemporaryDirectory(prefix="lumi-
|
| 43 |
root = Path(raw)
|
| 44 |
# The operator owns this short-lived directory; the dedicated workload
|
| 45 |
# identity needs a writable output location for the visible demo.
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def call(args: list[str]) -> dict[str, object]:
|
| 26 |
+
result = subprocess.run(["/usr/local/bin/eggcracker", *args], capture_output=True, text=True, check=False, timeout=30)
|
| 27 |
if result.returncode:
|
| 28 |
+
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "Eggcracker command failed")
|
| 29 |
return json.loads(result.stdout)
|
| 30 |
|
| 31 |
|
|
|
|
| 39 |
raise SystemExit("output must be a new file under an existing directory")
|
| 40 |
if not args.llama_cli.is_file() or not os.access(args.llama_cli, os.X_OK) or not args.model.is_file():
|
| 41 |
raise SystemExit("llama-cli executable and GGUF model are required")
|
| 42 |
+
with tempfile.TemporaryDirectory(prefix="lumi-eggcracker-ai-smoke-", dir="/tmp") as raw:
|
| 43 |
root = Path(raw)
|
| 44 |
# The operator owns this short-lived directory; the dedicated workload
|
| 45 |
# identity needs a writable output location for the visible demo.
|
scripts/uninstall.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Exact manifest-bound uninstaller for Lumi
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -11,12 +11,13 @@ import shutil
|
|
| 11 |
import subprocess
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
-
LIB = Path("/usr/local/lib/lumi-
|
| 15 |
-
BIN = Path("/usr/local/bin/
|
| 16 |
-
ETC = Path("/etc/lumi-
|
| 17 |
-
UNIT = Path("/etc/systemd/system/lumi-
|
| 18 |
-
STATE = Path("/var/lib/lumi-
|
| 19 |
-
RUNTIME = Path("/run/lumi-
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def digest(path: Path) -> str:
|
|
@@ -31,6 +32,23 @@ def run(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
|
| 31 |
return subprocess.run(argv, capture_output=True, text=True, check=False, timeout=60)
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def main() -> int:
|
| 35 |
if os.geteuid() != 0:
|
| 36 |
raise SystemExit("uninstaller must run as root")
|
|
@@ -38,23 +56,19 @@ def main() -> int:
|
|
| 38 |
if manifest_path.is_symlink() or not manifest_path.is_file():
|
| 39 |
raise SystemExit("installed manifest is missing")
|
| 40 |
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 41 |
-
if manifest.get("schema_version") != "lumi-
|
| 42 |
raise SystemExit("installed manifest schema is invalid")
|
| 43 |
for name, expected in manifest.get("files", {}).items():
|
| 44 |
path = Path(name)
|
| 45 |
if path.is_symlink() or not path.is_file() or digest(path) != expected:
|
| 46 |
raise SystemExit(f"refusing uninstall because installed file drifted: {path}")
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
run(["/usr/bin/systemctl", "
|
| 51 |
-
run(["/usr/bin/systemctl", "reset-failed", "lumi-nutcracker.service"])
|
| 52 |
for path in (UNIT, BIN, LIB, ETC, STATE, RUNTIME):
|
| 53 |
if path.exists() and not path.is_symlink():
|
| 54 |
-
if path.is_dir()
|
| 55 |
-
shutil.rmtree(path)
|
| 56 |
-
else:
|
| 57 |
-
path.unlink()
|
| 58 |
if manifest.get("created_workload_user"):
|
| 59 |
try:
|
| 60 |
account = pwd.getpwnam(str(manifest["workload_user"]))
|
|
@@ -65,9 +79,8 @@ def main() -> int:
|
|
| 65 |
if result.returncode:
|
| 66 |
raise SystemExit(result.stderr.strip() or "cannot remove created workload account")
|
| 67 |
if manifest.get("created_workload_group"):
|
| 68 |
-
group_name = str(manifest.get("workload_group", ""))
|
| 69 |
try:
|
| 70 |
-
group = grp.getgrnam(
|
| 71 |
except KeyError:
|
| 72 |
group = None
|
| 73 |
if group:
|
|
|
|
| 1 |
+
"""Exact manifest-bound uninstaller for Lumi Eggcracker."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 11 |
import subprocess
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
+
LIB = Path("/usr/local/lib/lumi-eggcracker")
|
| 15 |
+
BIN = Path("/usr/local/bin/eggcracker")
|
| 16 |
+
ETC = Path("/etc/lumi-eggcracker")
|
| 17 |
+
UNIT = Path("/etc/systemd/system/lumi-eggcracker.service")
|
| 18 |
+
STATE = Path("/var/lib/lumi-eggcracker")
|
| 19 |
+
RUNTIME = Path("/run/lumi-eggcracker")
|
| 20 |
+
PREFIX = "lumi-eggcracker-workload-"
|
| 21 |
|
| 22 |
|
| 23 |
def digest(path: Path) -> str:
|
|
|
|
| 32 |
return subprocess.run(argv, capture_output=True, text=True, check=False, timeout=60)
|
| 33 |
|
| 34 |
|
| 35 |
+
def owned_cgroups_empty() -> bool:
|
| 36 |
+
root = Path("/sys/fs/cgroup/system.slice")
|
| 37 |
+
if not root.is_dir():
|
| 38 |
+
return False
|
| 39 |
+
for path in root.glob(f"{PREFIX}*.service"):
|
| 40 |
+
if not path.is_dir() or path.is_symlink():
|
| 41 |
+
return False
|
| 42 |
+
events = path / "cgroup.events"
|
| 43 |
+
try:
|
| 44 |
+
populated = dict(line.split(" ", 1) for line in events.read_text(encoding="ascii").splitlines()).get("populated")
|
| 45 |
+
except OSError:
|
| 46 |
+
return False
|
| 47 |
+
if populated != "0":
|
| 48 |
+
return False
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
|
| 52 |
def main() -> int:
|
| 53 |
if os.geteuid() != 0:
|
| 54 |
raise SystemExit("uninstaller must run as root")
|
|
|
|
| 56 |
if manifest_path.is_symlink() or not manifest_path.is_file():
|
| 57 |
raise SystemExit("installed manifest is missing")
|
| 58 |
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 59 |
+
if manifest.get("schema_version") != "lumi-eggcracker.install.v2":
|
| 60 |
raise SystemExit("installed manifest schema is invalid")
|
| 61 |
for name, expected in manifest.get("files", {}).items():
|
| 62 |
path = Path(name)
|
| 63 |
if path.is_symlink() or not path.is_file() or digest(path) != expected:
|
| 64 |
raise SystemExit(f"refusing uninstall because installed file drifted: {path}")
|
| 65 |
+
if not owned_cgroups_empty():
|
| 66 |
+
raise SystemExit("refusing uninstall with populated or uncertain Eggcracker cgroups")
|
| 67 |
+
run(["/usr/bin/systemctl", "disable", "--now", "lumi-eggcracker.service"])
|
| 68 |
+
run(["/usr/bin/systemctl", "reset-failed", "lumi-eggcracker.service"])
|
|
|
|
| 69 |
for path in (UNIT, BIN, LIB, ETC, STATE, RUNTIME):
|
| 70 |
if path.exists() and not path.is_symlink():
|
| 71 |
+
shutil.rmtree(path) if path.is_dir() else path.unlink()
|
|
|
|
|
|
|
|
|
|
| 72 |
if manifest.get("created_workload_user"):
|
| 73 |
try:
|
| 74 |
account = pwd.getpwnam(str(manifest["workload_user"]))
|
|
|
|
| 79 |
if result.returncode:
|
| 80 |
raise SystemExit(result.stderr.strip() or "cannot remove created workload account")
|
| 81 |
if manifest.get("created_workload_group"):
|
|
|
|
| 82 |
try:
|
| 83 |
+
group = grp.getgrnam(str(manifest.get("workload_group", "")))
|
| 84 |
except KeyError:
|
| 85 |
group = None
|
| 86 |
if group:
|
scripts/verify_release.py
CHANGED
|
@@ -10,18 +10,19 @@ import sys
|
|
| 10 |
import zipfile
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def text_from_zip(path: Path) -> str:
|
| 17 |
with zipfile.ZipFile(path) as archive:
|
| 18 |
-
|
| 19 |
-
for name in archive.namelist():
|
| 20 |
-
if name.endswith("scripts/verify_release.py"):
|
| 21 |
-
continue
|
| 22 |
-
if name.endswith((".py", ".md", ".toml", ".txt")):
|
| 23 |
-
values.append(archive.read(name).decode("utf-8", errors="ignore").lower())
|
| 24 |
-
return "\n".join(values)
|
| 25 |
|
| 26 |
|
| 27 |
def digest_bytes(value: bytes) -> str:
|
|
@@ -34,43 +35,28 @@ def main() -> int:
|
|
| 34 |
parser.add_argument("--source-archive", required=True, type=Path)
|
| 35 |
parser.add_argument("--release-bundle", required=True, type=Path)
|
| 36 |
args = parser.parse_args()
|
| 37 |
-
if not
|
| 38 |
raise SystemExit("release artifacts are missing")
|
| 39 |
for path in (args.artifact, args.source_archive, args.release_bundle):
|
| 40 |
-
|
| 41 |
-
leaks = [item for item in FORBIDDEN if item in value]
|
| 42 |
if leaks:
|
| 43 |
raise SystemExit(f"forbidden public artifact content in {path.name}: {leaks}")
|
| 44 |
result = subprocess.run([sys.executable, str(args.artifact), "version"], capture_output=True, text=True, check=False)
|
| 45 |
-
if result.returncode or result.stdout.strip() !=
|
| 46 |
raise SystemExit("artifact version is inconsistent")
|
| 47 |
manifest = json.loads((args.artifact.parent / "release-manifest.json").read_text(encoding="utf-8"))
|
| 48 |
-
if manifest.get("version") !=
|
| 49 |
raise SystemExit("release manifest is inconsistent")
|
| 50 |
-
|
| 51 |
-
expected = {
|
| 52 |
-
prefix + args.artifact.name,
|
| 53 |
-
prefix + args.source_archive.name,
|
| 54 |
-
prefix + "release-manifest.json",
|
| 55 |
-
prefix + "SHA256SUMS",
|
| 56 |
-
prefix + "README.md",
|
| 57 |
-
prefix + "LICENSE",
|
| 58 |
-
prefix + "LIMITATIONS.md",
|
| 59 |
-
prefix + "SECURITY.md",
|
| 60 |
-
prefix + "RELEASE_NOTES.md",
|
| 61 |
-
prefix + "scripts/install.py",
|
| 62 |
-
prefix + "scripts/uninstall.py",
|
| 63 |
-
prefix + "scripts/verify_uninstalled.py",
|
| 64 |
-
prefix + "scripts/smoke_local_ai.py",
|
| 65 |
-
}
|
| 66 |
with zipfile.ZipFile(args.release_bundle) as archive:
|
| 67 |
if set(archive.namelist()) != expected:
|
| 68 |
raise SystemExit("release bundle contents are inconsistent")
|
| 69 |
-
if digest_bytes(archive.read(
|
| 70 |
raise SystemExit("bundled artifact digest is inconsistent")
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
return 0
|
| 75 |
|
| 76 |
|
|
|
|
| 10 |
import zipfile
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
+
VERSION = "0.1.1"
|
| 14 |
+
PREFIX = f"lumi-eggcracker-{VERSION}/"
|
| 15 |
+
FORBIDDEN = (
|
| 16 |
+
"/mnt/" + "f/", "f" + ":\\", "network" + "-deny", "network" + "_rule",
|
| 17 |
+
"nft" + "ables", "/usr/sbin/" + "nft", "b" + "20", "brief" + " 1",
|
| 18 |
+
"brief" + "-", "root" + "less", "skylark" + " sentinel",
|
| 19 |
+
"skylark" + "-sentinel", "nut" + "cracker",
|
| 20 |
+
)
|
| 21 |
|
| 22 |
|
| 23 |
def text_from_zip(path: Path) -> str:
|
| 24 |
with zipfile.ZipFile(path) as archive:
|
| 25 |
+
return "\n".join(archive.read(name).decode("utf-8", errors="ignore").lower() for name in archive.namelist() if name.endswith((".py", ".md", ".toml", ".txt")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def digest_bytes(value: bytes) -> str:
|
|
|
|
| 35 |
parser.add_argument("--source-archive", required=True, type=Path)
|
| 36 |
parser.add_argument("--release-bundle", required=True, type=Path)
|
| 37 |
args = parser.parse_args()
|
| 38 |
+
if not all(path.is_file() for path in (args.artifact, args.source_archive, args.release_bundle)):
|
| 39 |
raise SystemExit("release artifacts are missing")
|
| 40 |
for path in (args.artifact, args.source_archive, args.release_bundle):
|
| 41 |
+
leaks = [item for item in FORBIDDEN if item in text_from_zip(path)]
|
|
|
|
| 42 |
if leaks:
|
| 43 |
raise SystemExit(f"forbidden public artifact content in {path.name}: {leaks}")
|
| 44 |
result = subprocess.run([sys.executable, str(args.artifact), "version"], capture_output=True, text=True, check=False)
|
| 45 |
+
if result.returncode or result.stdout.strip() != VERSION:
|
| 46 |
raise SystemExit("artifact version is inconsistent")
|
| 47 |
manifest = json.loads((args.artifact.parent / "release-manifest.json").read_text(encoding="utf-8"))
|
| 48 |
+
if manifest.get("version") != VERSION or manifest.get("artifact") != args.artifact.name:
|
| 49 |
raise SystemExit("release manifest is inconsistent")
|
| 50 |
+
expected = {PREFIX + name for name in (args.artifact.name, args.source_archive.name, "release-manifest.json", "SHA256SUMS", "README.md", "LICENSE", "LIMITATIONS.md", "SECURITY.md", "SECURITY_MODEL.md", "QUALIFICATION.md", "RELEASE_NOTES.md", "scripts/install.py", "scripts/uninstall.py", "scripts/verify_uninstalled.py", "scripts/smoke_local_ai.py")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
with zipfile.ZipFile(args.release_bundle) as archive:
|
| 52 |
if set(archive.namelist()) != expected:
|
| 53 |
raise SystemExit("release bundle contents are inconsistent")
|
| 54 |
+
if digest_bytes(archive.read(PREFIX + args.artifact.name)) != manifest["sha256"] or digest_bytes(archive.read(PREFIX + args.source_archive.name)) != manifest["source_archive_sha256"]:
|
| 55 |
raise SystemExit("bundled artifact digest is inconsistent")
|
| 56 |
+
sums = (args.artifact.parent / "SHA256SUMS").read_text(encoding="utf-8")
|
| 57 |
+
if args.artifact.name not in sums or args.source_archive.name not in sums or args.release_bundle.name not in sums:
|
| 58 |
+
raise SystemExit("checksums do not cover every release asset")
|
| 59 |
+
print(json.dumps({"result": "PASS", "version": VERSION}, sort_keys=True))
|
| 60 |
return 0
|
| 61 |
|
| 62 |
|
scripts/verify_uninstalled.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Verify that a clean-install test left no
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -8,25 +8,25 @@ import pwd
|
|
| 8 |
import subprocess
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
-
PATHS = (Path("/usr/local/lib/lumi-
|
| 12 |
|
| 13 |
|
| 14 |
def main() -> int:
|
| 15 |
if os.geteuid() != 0:
|
| 16 |
raise SystemExit("uninstall verifier must run as root")
|
| 17 |
if any(path.exists() or path.is_symlink() for path in PATHS):
|
| 18 |
-
raise SystemExit("
|
| 19 |
-
units = subprocess.run(["/usr/bin/systemctl", "list-units", "lumi-
|
| 20 |
if units.stdout.strip():
|
| 21 |
-
raise SystemExit("
|
| 22 |
try:
|
| 23 |
-
pwd.getpwnam("lumi-
|
| 24 |
except KeyError:
|
| 25 |
pass
|
| 26 |
else:
|
| 27 |
raise SystemExit("created workload account remains")
|
| 28 |
try:
|
| 29 |
-
grp.getgrnam("lumi-
|
| 30 |
except KeyError:
|
| 31 |
print('{"result":"PASS"}')
|
| 32 |
return 0
|
|
|
|
| 1 |
+
"""Verify that a clean-install test left no Eggcracker paths behind."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 8 |
import subprocess
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
+
PATHS = (Path("/usr/local/lib/lumi-eggcracker"), Path("/usr/local/bin/eggcracker"), Path("/etc/lumi-eggcracker"), Path("/etc/systemd/system/lumi-eggcracker.service"), Path("/var/lib/lumi-eggcracker"), Path("/run/lumi-eggcracker"))
|
| 12 |
|
| 13 |
|
| 14 |
def main() -> int:
|
| 15 |
if os.geteuid() != 0:
|
| 16 |
raise SystemExit("uninstall verifier must run as root")
|
| 17 |
if any(path.exists() or path.is_symlink() for path in PATHS):
|
| 18 |
+
raise SystemExit("Eggcracker installation path remains")
|
| 19 |
+
units = subprocess.run(["/usr/bin/systemctl", "list-units", "lumi-eggcracker*", "--all", "--plain", "--no-legend"], capture_output=True, text=True, check=False)
|
| 20 |
if units.stdout.strip():
|
| 21 |
+
raise SystemExit("Eggcracker unit remains")
|
| 22 |
try:
|
| 23 |
+
pwd.getpwnam("lumi-eggcracker-workload")
|
| 24 |
except KeyError:
|
| 25 |
pass
|
| 26 |
else:
|
| 27 |
raise SystemExit("created workload account remains")
|
| 28 |
try:
|
| 29 |
+
grp.getgrnam("lumi-eggcracker-workload")
|
| 30 |
except KeyError:
|
| 31 |
print('{"result":"PASS"}')
|
| 32 |
return 0
|
src/lumi_eggcracker/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lumi Eggcracker: explicit protected Linux workload termination."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.1"
|
src/{lumi_nutcracker → lumi_eggcracker}/__main__.py
RENAMED
|
File without changes
|
src/{lumi_nutcracker → lumi_eggcracker}/cli.py
RENAMED
|
@@ -9,12 +9,13 @@ from pathlib import Path
|
|
| 9 |
|
| 10 |
from . import __version__
|
| 11 |
from .client import request
|
|
|
|
| 12 |
from .jsonio import JsonInputError, write_new_json
|
| 13 |
from .supervisor import main as supervisor_main
|
| 14 |
|
| 15 |
|
| 16 |
def _parser() -> argparse.ArgumentParser:
|
| 17 |
-
parser = argparse.ArgumentParser(prog="
|
| 18 |
commands = parser.add_subparsers(dest="command", required=True)
|
| 19 |
start = commands.add_parser("start", help="launch an explicitly selected protected workload")
|
| 20 |
start.add_argument("--name", required=True)
|
|
@@ -39,6 +40,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 39 |
values = sys.argv[1:] if argv is None else argv
|
| 40 |
if values[:1] == ["_supervisor"]:
|
| 41 |
return supervisor_main(values[1:])
|
|
|
|
|
|
|
| 42 |
args = _parser().parse_args(values)
|
| 43 |
if args.command == "version":
|
| 44 |
print(__version__)
|
|
@@ -63,5 +66,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 63 |
print(json.dumps(value, sort_keys=True))
|
| 64 |
return 0
|
| 65 |
except (JsonInputError, OSError) as error:
|
| 66 |
-
print(f"
|
| 67 |
return 4
|
|
|
|
| 9 |
|
| 10 |
from . import __version__
|
| 11 |
from .client import request
|
| 12 |
+
from .gate import main as gate_main
|
| 13 |
from .jsonio import JsonInputError, write_new_json
|
| 14 |
from .supervisor import main as supervisor_main
|
| 15 |
|
| 16 |
|
| 17 |
def _parser() -> argparse.ArgumentParser:
|
| 18 |
+
parser = argparse.ArgumentParser(prog="eggcracker")
|
| 19 |
commands = parser.add_subparsers(dest="command", required=True)
|
| 20 |
start = commands.add_parser("start", help="launch an explicitly selected protected workload")
|
| 21 |
start.add_argument("--name", required=True)
|
|
|
|
| 40 |
values = sys.argv[1:] if argv is None else argv
|
| 41 |
if values[:1] == ["_supervisor"]:
|
| 42 |
return supervisor_main(values[1:])
|
| 43 |
+
if values[:1] == ["_gate"]:
|
| 44 |
+
return gate_main(values[1:])
|
| 45 |
args = _parser().parse_args(values)
|
| 46 |
if args.command == "version":
|
| 47 |
print(__version__)
|
|
|
|
| 66 |
print(json.dumps(value, sort_keys=True))
|
| 67 |
return 0
|
| 68 |
except (JsonInputError, OSError) as error:
|
| 69 |
+
print(f"eggcracker: {error}", file=sys.stderr)
|
| 70 |
return 4
|
src/{lumi_nutcracker → lumi_eggcracker}/client.py
RENAMED
|
@@ -10,7 +10,7 @@ from typing import Any
|
|
| 10 |
from .jsonio import JsonInputError
|
| 11 |
|
| 12 |
MAX_FRAME = 32 * 1024
|
| 13 |
-
SOCKET_PATH = "/run/lumi-
|
| 14 |
|
| 15 |
|
| 16 |
def _receive(connection: socket.socket) -> dict[str, Any]:
|
|
|
|
| 10 |
from .jsonio import JsonInputError
|
| 11 |
|
| 12 |
MAX_FRAME = 32 * 1024
|
| 13 |
+
SOCKET_PATH = "/run/lumi-eggcracker/control.sock"
|
| 14 |
|
| 15 |
|
| 16 |
def _receive(connection: socket.socket) -> dict[str, Any]:
|
src/{lumi_nutcracker → lumi_eggcracker}/containment.py
RENAMED
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
| 12 |
from .jsonio import JsonInputError
|
| 13 |
|
| 14 |
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
| 15 |
-
UNIT_RE = re.compile(r"^/system\.slice/lumi-
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass(frozen=True)
|
|
@@ -24,16 +24,6 @@ class CgroupIdentity:
|
|
| 24 |
run_id: str
|
| 25 |
unit: str
|
| 26 |
|
| 27 |
-
def as_dict(self) -> dict[str, object]:
|
| 28 |
-
return {
|
| 29 |
-
"boot_id": self.boot_id,
|
| 30 |
-
"cgroup": self.cgroup,
|
| 31 |
-
"cgroup_device": self.device,
|
| 32 |
-
"cgroup_inode": self.inode,
|
| 33 |
-
"run_id": self.run_id,
|
| 34 |
-
"unit": self.unit,
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
|
| 38 |
@dataclass(frozen=True)
|
| 39 |
class EmptyProof:
|
|
@@ -52,35 +42,40 @@ def boot_id() -> str:
|
|
| 52 |
|
| 53 |
def _path(cgroup: str, *, root: Path = CGROUP_ROOT) -> Path:
|
| 54 |
if not UNIT_RE.fullmatch(cgroup):
|
| 55 |
-
raise JsonInputError("cgroup is outside the
|
| 56 |
candidate = root.joinpath(*cgroup.lstrip("/").split("/"))
|
| 57 |
if candidate.is_symlink() or not candidate.is_dir():
|
| 58 |
raise JsonInputError("owned cgroup is unavailable")
|
| 59 |
return candidate
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def _events(path: Path) -> dict[str, int]:
|
| 63 |
try:
|
| 64 |
-
|
|
|
|
| 65 |
except OSError as error:
|
| 66 |
raise JsonInputError(f"cannot read {path.name}: {error}") from error
|
| 67 |
-
value = {key: raw for key, raw in pairs if key and raw}
|
| 68 |
-
if any(not raw.isdigit() for raw in value.values()):
|
| 69 |
-
raise JsonInputError(f"{path.name} has invalid values")
|
| 70 |
-
return {key: int(raw) for key, raw in value.items()}
|
| 71 |
|
| 72 |
|
| 73 |
def capture_identity(cgroup: str, run_id: str, unit: str, *, root: Path = CGROUP_ROOT) -> CgroupIdentity:
|
| 74 |
-
if unit != f"lumi-
|
| 75 |
raise JsonInputError("unit and run identity disagree")
|
| 76 |
path = _path(cgroup, root=root)
|
| 77 |
required = ("cgroup.kill", "cgroup.events", "cgroup.procs", "pids.events", "pids.max")
|
| 78 |
if not all((path / name).is_file() for name in required):
|
| 79 |
raise JsonInputError("owned cgroup lacks required v2 control files")
|
| 80 |
-
if "populated" not in _events(path / "cgroup.events"):
|
| 81 |
-
raise JsonInputError("owned cgroup does not expose
|
| 82 |
-
if "max" not in _events(path / "pids.events"):
|
| 83 |
-
raise JsonInputError("owned cgroup does not expose PID events")
|
| 84 |
metadata = path.stat(follow_symlinks=False)
|
| 85 |
return CgroupIdentity(boot_id(), cgroup, metadata.st_dev, metadata.st_ino, run_id, unit)
|
| 86 |
|
|
@@ -89,21 +84,13 @@ def validate_identity(identity: CgroupIdentity, *, root: Path = CGROUP_ROOT) ->
|
|
| 89 |
if identity.boot_id != boot_id():
|
| 90 |
raise JsonInputError("workload boot identity changed")
|
| 91 |
path = _path(identity.cgroup, root=root)
|
| 92 |
-
|
| 93 |
-
metadata = path.stat(follow_symlinks=False)
|
| 94 |
-
except OSError as error:
|
| 95 |
-
raise JsonInputError("owned cgroup is unavailable") from error
|
| 96 |
if metadata.st_dev != identity.device or metadata.st_ino != identity.inode:
|
| 97 |
raise JsonInputError("owned cgroup identity drifted")
|
| 98 |
return path
|
| 99 |
|
| 100 |
|
| 101 |
-
def pids_max_event(identity: CgroupIdentity, *, root: Path = CGROUP_ROOT) -> int:
|
| 102 |
-
return _events(validate_identity(identity, root=root) / "pids.events")["max"]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
def kill_path(path: Path) -> tuple[int, int]:
|
| 106 |
-
"""Write a previously validated exact cgroup.kill file directly."""
|
| 107 |
control = path / "cgroup.kill"
|
| 108 |
started = time.monotonic_ns()
|
| 109 |
descriptor = os.open(control, os.O_WRONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0))
|
|
@@ -116,55 +103,41 @@ def kill_path(path: Path) -> tuple[int, int]:
|
|
| 116 |
|
| 117 |
|
| 118 |
def kill(identity: CgroupIdentity, *, root: Path = CGROUP_ROOT) -> tuple[int, int]:
|
| 119 |
-
"""Validate
|
| 120 |
return kill_path(validate_identity(identity, root=root))
|
| 121 |
|
| 122 |
|
| 123 |
def _survivors(path: Path) -> tuple[list[int], int]:
|
| 124 |
-
directories = [path]
|
| 125 |
-
directories.extend(item for item in path.rglob("*") if item.is_dir() and not item.is_symlink())
|
| 126 |
values: list[int] = []
|
| 127 |
for directory in directories:
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
raise JsonInputError("cgroup.procs contains invalid PID")
|
| 133 |
-
values.append(int(line))
|
| 134 |
return sorted(set(values)), len(directories)
|
| 135 |
|
| 136 |
|
| 137 |
-
def _collected(error:
|
| 138 |
return isinstance(error, OSError) and error.errno in {errno.ENOENT, errno.ENODEV}
|
| 139 |
|
| 140 |
|
| 141 |
def verify_empty(identity: CgroupIdentity, *, deadline_seconds: float = 0.9, root: Path = CGROUP_ROOT) -> tuple[int, EmptyProof]:
|
| 142 |
-
"""Prove an empty hierarchy after direct kill, re-applying it across fork races."""
|
| 143 |
deadline = time.monotonic() + deadline_seconds
|
| 144 |
last = EmptyProof(False, 0, -1, [])
|
| 145 |
while time.monotonic() <= deadline:
|
| 146 |
try:
|
| 147 |
path = validate_identity(identity, root=root)
|
| 148 |
-
except JsonInputError as error:
|
| 149 |
-
if "unavailable" in str(error):
|
| 150 |
-
return time.monotonic_ns(), EmptyProof(True, 0, 0, [])
|
| 151 |
-
raise
|
| 152 |
-
try:
|
| 153 |
events = _events(path / "cgroup.events")
|
| 154 |
survivors, descendants = _survivors(path)
|
| 155 |
except (JsonInputError, OSError) as error:
|
| 156 |
-
|
| 157 |
-
# ENODEV/ENOENT from cgroupfs is proof that no live hierarchy remains.
|
| 158 |
-
if _collected(error) or "No such device" in str(error) or "No such file" in str(error):
|
| 159 |
return time.monotonic_ns(), EmptyProof(True, 0, 0, [])
|
| 160 |
raise
|
| 161 |
populated = events.get("populated", -1)
|
| 162 |
last = EmptyProof(populated == 0 and not survivors, descendants, populated, survivors)
|
| 163 |
if last.complete:
|
| 164 |
return time.monotonic_ns(), last
|
| 165 |
-
# A process can fork while the kernel is walking a busy hierarchy for a
|
| 166 |
-
# prior cgroup.kill. Re-applying the same direct primitive closes that
|
| 167 |
-
# narrow race; nothing is persisted or explained before enforcement.
|
| 168 |
try:
|
| 169 |
kill_path(path)
|
| 170 |
except OSError as error:
|
|
|
|
| 12 |
from .jsonio import JsonInputError
|
| 13 |
|
| 14 |
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
| 15 |
+
UNIT_RE = re.compile(r"^/system\.slice/lumi-eggcracker-workload-([0-9a-f]{24})\.service$")
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass(frozen=True)
|
|
|
|
| 24 |
run_id: str
|
| 25 |
unit: str
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
@dataclass(frozen=True)
|
| 29 |
class EmptyProof:
|
|
|
|
| 42 |
|
| 43 |
def _path(cgroup: str, *, root: Path = CGROUP_ROOT) -> Path:
|
| 44 |
if not UNIT_RE.fullmatch(cgroup):
|
| 45 |
+
raise JsonInputError("cgroup is outside the Eggcracker unit namespace")
|
| 46 |
candidate = root.joinpath(*cgroup.lstrip("/").split("/"))
|
| 47 |
if candidate.is_symlink() or not candidate.is_dir():
|
| 48 |
raise JsonInputError("owned cgroup is unavailable")
|
| 49 |
return candidate
|
| 50 |
|
| 51 |
|
| 52 |
+
def events_from_fd(descriptor: int) -> dict[str, int]:
|
| 53 |
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
| 54 |
+
raw = os.read(descriptor, 4096).decode("ascii")
|
| 55 |
+
pairs = [line.split(" ", 1) for line in raw.splitlines()]
|
| 56 |
+
value = {key: item for key, item in pairs if key and item}
|
| 57 |
+
if not value or any(not item.isdigit() for item in value.values()):
|
| 58 |
+
raise JsonInputError("cgroup event file has invalid values")
|
| 59 |
+
return {key: int(item) for key, item in value.items()}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
def _events(path: Path) -> dict[str, int]:
|
| 63 |
try:
|
| 64 |
+
with path.open("rb", buffering=0) as handle:
|
| 65 |
+
return events_from_fd(handle.fileno())
|
| 66 |
except OSError as error:
|
| 67 |
raise JsonInputError(f"cannot read {path.name}: {error}") from error
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def capture_identity(cgroup: str, run_id: str, unit: str, *, root: Path = CGROUP_ROOT) -> CgroupIdentity:
|
| 71 |
+
if unit != f"lumi-eggcracker-workload-{run_id}.service":
|
| 72 |
raise JsonInputError("unit and run identity disagree")
|
| 73 |
path = _path(cgroup, root=root)
|
| 74 |
required = ("cgroup.kill", "cgroup.events", "cgroup.procs", "pids.events", "pids.max")
|
| 75 |
if not all((path / name).is_file() for name in required):
|
| 76 |
raise JsonInputError("owned cgroup lacks required v2 control files")
|
| 77 |
+
if "populated" not in _events(path / "cgroup.events") or "max" not in _events(path / "pids.events"):
|
| 78 |
+
raise JsonInputError("owned cgroup does not expose required events")
|
|
|
|
|
|
|
| 79 |
metadata = path.stat(follow_symlinks=False)
|
| 80 |
return CgroupIdentity(boot_id(), cgroup, metadata.st_dev, metadata.st_ino, run_id, unit)
|
| 81 |
|
|
|
|
| 84 |
if identity.boot_id != boot_id():
|
| 85 |
raise JsonInputError("workload boot identity changed")
|
| 86 |
path = _path(identity.cgroup, root=root)
|
| 87 |
+
metadata = path.stat(follow_symlinks=False)
|
|
|
|
|
|
|
|
|
|
| 88 |
if metadata.st_dev != identity.device or metadata.st_ino != identity.inode:
|
| 89 |
raise JsonInputError("owned cgroup identity drifted")
|
| 90 |
return path
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def kill_path(path: Path) -> tuple[int, int]:
|
|
|
|
| 94 |
control = path / "cgroup.kill"
|
| 95 |
started = time.monotonic_ns()
|
| 96 |
descriptor = os.open(control, os.O_WRONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0))
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def kill(identity: CgroupIdentity, *, root: Path = CGROUP_ROOT) -> tuple[int, int]:
|
| 106 |
+
"""Validate the exact identity, then apply the authoritative primitive."""
|
| 107 |
return kill_path(validate_identity(identity, root=root))
|
| 108 |
|
| 109 |
|
| 110 |
def _survivors(path: Path) -> tuple[list[int], int]:
|
| 111 |
+
directories = [path, *(item for item in path.rglob("*") if item.is_dir() and not item.is_symlink())]
|
|
|
|
| 112 |
values: list[int] = []
|
| 113 |
for directory in directories:
|
| 114 |
+
for line in (directory / "cgroup.procs").read_text(encoding="ascii").splitlines():
|
| 115 |
+
if not line.isdigit():
|
| 116 |
+
raise JsonInputError("cgroup.procs contains invalid PID")
|
| 117 |
+
values.append(int(line))
|
|
|
|
|
|
|
| 118 |
return sorted(set(values)), len(directories)
|
| 119 |
|
| 120 |
|
| 121 |
+
def _collected(error: BaseException) -> bool:
|
| 122 |
return isinstance(error, OSError) and error.errno in {errno.ENOENT, errno.ENODEV}
|
| 123 |
|
| 124 |
|
| 125 |
def verify_empty(identity: CgroupIdentity, *, deadline_seconds: float = 0.9, root: Path = CGROUP_ROOT) -> tuple[int, EmptyProof]:
|
|
|
|
| 126 |
deadline = time.monotonic() + deadline_seconds
|
| 127 |
last = EmptyProof(False, 0, -1, [])
|
| 128 |
while time.monotonic() <= deadline:
|
| 129 |
try:
|
| 130 |
path = validate_identity(identity, root=root)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
events = _events(path / "cgroup.events")
|
| 132 |
survivors, descendants = _survivors(path)
|
| 133 |
except (JsonInputError, OSError) as error:
|
| 134 |
+
if _collected(error) or "unavailable" in str(error) or "No such file" in str(error) or "No such device" in str(error):
|
|
|
|
|
|
|
| 135 |
return time.monotonic_ns(), EmptyProof(True, 0, 0, [])
|
| 136 |
raise
|
| 137 |
populated = events.get("populated", -1)
|
| 138 |
last = EmptyProof(populated == 0 and not survivors, descendants, populated, survivors)
|
| 139 |
if last.complete:
|
| 140 |
return time.monotonic_ns(), last
|
|
|
|
|
|
|
|
|
|
| 141 |
try:
|
| 142 |
kill_path(path)
|
| 143 |
except OSError as error:
|
src/lumi_eggcracker/gate.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny workload-side pre-exec gate; it has no supervisor privileges."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main(argv: list[str] | None = None) -> int:
|
| 11 |
+
parser = argparse.ArgumentParser(prog="eggcracker internal-gate")
|
| 12 |
+
parser.add_argument("--fifo", required=True, type=Path)
|
| 13 |
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
| 14 |
+
args = parser.parse_args(argv)
|
| 15 |
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
| 16 |
+
if not command:
|
| 17 |
+
raise SystemExit("missing gated command")
|
| 18 |
+
with args.fifo.open("rb", buffering=0) as gate:
|
| 19 |
+
if gate.read(3) != b"GO\n":
|
| 20 |
+
raise SystemExit("invalid launch-gate release")
|
| 21 |
+
os.execvp(command[0], command)
|
| 22 |
+
return 127
|
src/{lumi_nutcracker → lumi_eggcracker}/jsonio.py
RENAMED
|
@@ -54,14 +54,17 @@ def write_new_json(destination: Path, value: dict[str, Any], *, mode: int = 0o60
|
|
| 54 |
descriptor, raw = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent)
|
| 55 |
temporary = Path(raw)
|
| 56 |
try:
|
| 57 |
-
|
|
|
|
| 58 |
os.write(descriptor, canonical_bytes(value))
|
| 59 |
os.fsync(descriptor)
|
| 60 |
finally:
|
| 61 |
os.close(descriptor)
|
|
|
|
|
|
|
| 62 |
try:
|
| 63 |
os.link(temporary, destination)
|
| 64 |
-
directory = os.open(destination.parent, os.O_RDONLY)
|
| 65 |
try:
|
| 66 |
os.fsync(directory)
|
| 67 |
finally:
|
|
|
|
| 54 |
descriptor, raw = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent)
|
| 55 |
temporary = Path(raw)
|
| 56 |
try:
|
| 57 |
+
if hasattr(os, "fchmod"):
|
| 58 |
+
os.fchmod(descriptor, mode)
|
| 59 |
os.write(descriptor, canonical_bytes(value))
|
| 60 |
os.fsync(descriptor)
|
| 61 |
finally:
|
| 62 |
os.close(descriptor)
|
| 63 |
+
if not hasattr(os, "fchmod"):
|
| 64 |
+
os.chmod(temporary, mode)
|
| 65 |
try:
|
| 66 |
os.link(temporary, destination)
|
| 67 |
+
directory = os.open(destination.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
| 68 |
try:
|
| 69 |
os.fsync(directory)
|
| 70 |
finally:
|
src/{lumi_nutcracker → lumi_eggcracker}/records.py
RENAMED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import re
|
| 7 |
import tempfile
|
|
@@ -11,54 +12,80 @@ from typing import Any
|
|
| 11 |
from .containment import CgroupIdentity, EmptyProof
|
| 12 |
from .jsonio import JsonInputError, canonical_bytes, load_regular_json
|
| 13 |
|
| 14 |
-
RUN_SCHEMA = "lumi-
|
| 15 |
-
RECEIPT_SCHEMA = "lumi-
|
| 16 |
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def write_atomic(path: Path, value: dict[str, Any]) -> None:
|
|
|
|
| 20 |
descriptor, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
| 21 |
temporary = Path(raw)
|
| 22 |
try:
|
| 23 |
-
|
|
|
|
| 24 |
os.write(descriptor, canonical_bytes(value))
|
| 25 |
os.fsync(descriptor)
|
| 26 |
finally:
|
| 27 |
os.close(descriptor)
|
|
|
|
|
|
|
| 28 |
os.replace(temporary, path)
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
try:
|
| 31 |
os.fsync(directory)
|
|
|
|
|
|
|
| 32 |
finally:
|
| 33 |
os.close(directory)
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
if not
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
raise JsonInputError("workload name is invalid")
|
| 39 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def validate_run(value: dict[str, Any]) -> dict[str, Any]:
|
| 43 |
expected = {
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"workload_gid", "workload_uid",
|
| 47 |
}
|
| 48 |
if set(value) != expected or value.get("schema_version") != RUN_SCHEMA:
|
| 49 |
raise JsonInputError("run record schema is invalid")
|
| 50 |
if not isinstance(value["name"], str) or not NAME.fullmatch(value["name"]):
|
| 51 |
raise JsonInputError("run record name is invalid")
|
| 52 |
-
if not isinstance(value["run_id"], str) or not
|
| 53 |
raise JsonInputError("run record identity is invalid")
|
| 54 |
-
if value["unit"] != f"lumi-
|
| 55 |
raise JsonInputError("run record unit is invalid")
|
| 56 |
-
if not isinstance(value["
|
| 57 |
-
raise JsonInputError("run record
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
if any(isinstance(value[key], bool) or not isinstance(value[key], int) or value[key] < 0 for key in keys):
|
| 60 |
raise JsonInputError("run record integer field is invalid")
|
| 61 |
-
if value["state"] not in
|
| 62 |
raise JsonInputError("run record state is invalid")
|
| 63 |
return value
|
| 64 |
|
|
@@ -68,20 +95,20 @@ def identity_from_run(value: dict[str, Any]) -> CgroupIdentity:
|
|
| 68 |
return CgroupIdentity(record["boot_id"], record["cgroup"], record["cgroup_device"], record["cgroup_inode"], record["run_id"], record["unit"])
|
| 69 |
|
| 70 |
|
| 71 |
-
def load_run(runs: Path,
|
| 72 |
-
return validate_run(load_regular_json(
|
| 73 |
|
| 74 |
|
| 75 |
def make_receipt(
|
| 76 |
*, record: dict[str, Any], trigger: str, trigger_ns: int, kill_started_ns: int, kill_complete_ns: int,
|
| 77 |
-
empty_ns: int, proof: EmptyProof, version: str, source_commit: str,
|
| 78 |
) -> dict[str, Any]:
|
| 79 |
-
if trigger not in {"OPERATOR", "PID_LIMIT", "SUPERVISOR_RESTART_FAIL_CLOSED"} or not
|
| 80 |
raise JsonInputError("receipt trigger or event identity is invalid")
|
| 81 |
if not proof.complete or proof.root_populated != 0 or proof.surviving_pids:
|
| 82 |
raise JsonInputError("cannot issue a success receipt before exact emptiness proof")
|
| 83 |
return {
|
| 84 |
-
"cleanup":
|
| 85 |
"containment": {
|
| 86 |
"cgroup_kill_written": True,
|
| 87 |
"descendant_cgroups_checked": proof.descendant_cgroups_checked,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import hashlib
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
import tempfile
|
|
|
|
| 12 |
from .containment import CgroupIdentity, EmptyProof
|
| 13 |
from .jsonio import JsonInputError, canonical_bytes, load_regular_json
|
| 14 |
|
| 15 |
+
RUN_SCHEMA = "lumi-eggcracker.run.v2"
|
| 16 |
+
RECEIPT_SCHEMA = "lumi-eggcracker.receipt.v2"
|
| 17 |
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
| 18 |
+
RUN_ID = re.compile(r"[0-9a-f]{24}\Z")
|
| 19 |
+
ACTIVE_STATES = {"STARTING", "RUNNING"}
|
| 20 |
+
TERMINAL_STATES = {"COMPLETED_ALLOWED", "TERMINATED", "CONTAINMENT_FAILED", "CONTAINED_RECEIPT_FAILED"}
|
| 21 |
|
| 22 |
|
| 23 |
def write_atomic(path: Path, value: dict[str, Any]) -> None:
|
| 24 |
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
| 25 |
descriptor, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
| 26 |
temporary = Path(raw)
|
| 27 |
try:
|
| 28 |
+
if hasattr(os, "fchmod"):
|
| 29 |
+
os.fchmod(descriptor, 0o600)
|
| 30 |
os.write(descriptor, canonical_bytes(value))
|
| 31 |
os.fsync(descriptor)
|
| 32 |
finally:
|
| 33 |
os.close(descriptor)
|
| 34 |
+
if not hasattr(os, "fchmod"):
|
| 35 |
+
os.chmod(temporary, 0o600)
|
| 36 |
os.replace(temporary, path)
|
| 37 |
+
try:
|
| 38 |
+
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
| 39 |
+
except OSError:
|
| 40 |
+
return # Directory fsync is unavailable on Windows test hosts.
|
| 41 |
try:
|
| 42 |
os.fsync(directory)
|
| 43 |
+
except OSError:
|
| 44 |
+
return
|
| 45 |
finally:
|
| 46 |
os.close(directory)
|
| 47 |
|
| 48 |
|
| 49 |
+
def run_path(runs: Path, run_id: str) -> Path:
|
| 50 |
+
if not isinstance(run_id, str) or not RUN_ID.fullmatch(run_id):
|
| 51 |
+
raise JsonInputError("workload run identity is invalid")
|
| 52 |
+
return runs / f"{run_id}.json"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def name_path(names: Path, name: str) -> Path:
|
| 56 |
+
if not isinstance(name, str) or not NAME.fullmatch(name):
|
| 57 |
raise JsonInputError("workload name is invalid")
|
| 58 |
+
return names / f"{name}.json"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def command_summary(argv: list[str]) -> dict[str, Any]:
|
| 62 |
+
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv):
|
| 63 |
+
raise JsonInputError("workload argv is invalid")
|
| 64 |
+
return {"argv_count": len(argv), "argv_sha256": hashlib.sha256("\0".join(argv).encode("utf-8")).hexdigest(), "executable": argv[0]}
|
| 65 |
|
| 66 |
|
| 67 |
def validate_run(value: dict[str, Any]) -> dict[str, Any]:
|
| 68 |
expected = {
|
| 69 |
+
"argv_count", "argv_sha256", "boot_id", "cgroup", "cgroup_device", "cgroup_inode",
|
| 70 |
+
"created_monotonic_ns", "executable", "max_pids", "name", "operator_uid", "run_id",
|
| 71 |
+
"schema_version", "state", "unit", "workload_gid", "workload_uid",
|
| 72 |
}
|
| 73 |
if set(value) != expected or value.get("schema_version") != RUN_SCHEMA:
|
| 74 |
raise JsonInputError("run record schema is invalid")
|
| 75 |
if not isinstance(value["name"], str) or not NAME.fullmatch(value["name"]):
|
| 76 |
raise JsonInputError("run record name is invalid")
|
| 77 |
+
if not isinstance(value["run_id"], str) or not RUN_ID.fullmatch(value["run_id"]):
|
| 78 |
raise JsonInputError("run record identity is invalid")
|
| 79 |
+
if value["unit"] != f"lumi-eggcracker-workload-{value['run_id']}.service":
|
| 80 |
raise JsonInputError("run record unit is invalid")
|
| 81 |
+
if not isinstance(value["executable"], str) or not value["executable"]:
|
| 82 |
+
raise JsonInputError("run record executable is invalid")
|
| 83 |
+
if not isinstance(value["argv_sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", value["argv_sha256"]):
|
| 84 |
+
raise JsonInputError("run record command hash is invalid")
|
| 85 |
+
keys = ("cgroup_device", "cgroup_inode", "created_monotonic_ns", "operator_uid", "workload_gid", "workload_uid", "max_pids", "argv_count")
|
| 86 |
if any(isinstance(value[key], bool) or not isinstance(value[key], int) or value[key] < 0 for key in keys):
|
| 87 |
raise JsonInputError("run record integer field is invalid")
|
| 88 |
+
if value["state"] not in ACTIVE_STATES | TERMINAL_STATES:
|
| 89 |
raise JsonInputError("run record state is invalid")
|
| 90 |
return value
|
| 91 |
|
|
|
|
| 95 |
return CgroupIdentity(record["boot_id"], record["cgroup"], record["cgroup_device"], record["cgroup_inode"], record["run_id"], record["unit"])
|
| 96 |
|
| 97 |
|
| 98 |
+
def load_run(runs: Path, run_id: str) -> dict[str, Any]:
|
| 99 |
+
return validate_run(load_regular_json(run_path(runs, run_id)))
|
| 100 |
|
| 101 |
|
| 102 |
def make_receipt(
|
| 103 |
*, record: dict[str, Any], trigger: str, trigger_ns: int, kill_started_ns: int, kill_complete_ns: int,
|
| 104 |
+
empty_ns: int, proof: EmptyProof, version: str, source_commit: str, event_id: str,
|
| 105 |
) -> dict[str, Any]:
|
| 106 |
+
if trigger not in {"OPERATOR", "PID_LIMIT", "SUPERVISOR_FAILURE", "SUPERVISOR_RESTART_FAIL_CLOSED"} or not RUN_ID.fullmatch(event_id):
|
| 107 |
raise JsonInputError("receipt trigger or event identity is invalid")
|
| 108 |
if not proof.complete or proof.root_populated != 0 or proof.surviving_pids:
|
| 109 |
raise JsonInputError("cannot issue a success receipt before exact emptiness proof")
|
| 110 |
return {
|
| 111 |
+
"cleanup": {"attempted": False},
|
| 112 |
"containment": {
|
| 113 |
"cgroup_kill_written": True,
|
| 114 |
"descendant_cgroups_checked": proof.descendant_cgroups_checked,
|
src/{lumi_nutcracker → lumi_eggcracker}/supervisor.py
RENAMED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
-
"""Root-owned
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
import datetime as dt
|
|
|
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
import re
|
|
@@ -12,6 +13,7 @@ import signal
|
|
| 12 |
import socket
|
| 13 |
import struct
|
| 14 |
import subprocess
|
|
|
|
| 15 |
import threading
|
| 16 |
import time
|
| 17 |
from pathlib import Path
|
|
@@ -20,29 +22,34 @@ from typing import Any
|
|
| 20 |
from . import __version__
|
| 21 |
from .containment import (
|
| 22 |
capture_identity,
|
|
|
|
| 23 |
kill_path,
|
| 24 |
-
pids_max_event,
|
| 25 |
validate_identity,
|
| 26 |
verify_empty,
|
| 27 |
)
|
| 28 |
from .jsonio import JsonInputError, load_regular_json
|
| 29 |
from .records import (
|
|
|
|
|
|
|
| 30 |
RUN_SCHEMA,
|
|
|
|
| 31 |
identity_from_run,
|
| 32 |
load_run,
|
| 33 |
make_receipt,
|
| 34 |
-
|
|
|
|
| 35 |
validate_run,
|
| 36 |
write_atomic,
|
| 37 |
)
|
| 38 |
|
| 39 |
MAX_FRAME = 32 * 1024
|
| 40 |
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
@@ -103,11 +110,14 @@ class Supervisor:
|
|
| 103 |
raise JsonInputError("supervisor build identity is invalid")
|
| 104 |
self.policy = policy
|
| 105 |
self.runs = STATE_DIR / "runs"
|
|
|
|
| 106 |
self.receipts = STATE_DIR / "receipts"
|
| 107 |
self.stop_event = threading.Event()
|
| 108 |
self.locks: dict[str, threading.Lock] = {}
|
|
|
|
|
|
|
| 109 |
self.completed: dict[str, dict[str, Any]] = {}
|
| 110 |
-
self.operations: list[str] = [] # Test-visible
|
| 111 |
|
| 112 |
@property
|
| 113 |
def operator_uid(self) -> int:
|
|
@@ -118,24 +128,75 @@ class Supervisor:
|
|
| 118 |
|
| 119 |
def _show(self, unit: str) -> dict[str, str]:
|
| 120 |
if not unit.startswith(UNIT_PREFIX) or not unit.endswith(".service"):
|
| 121 |
-
raise JsonInputError("unit is outside
|
| 122 |
result = self._run(["/usr/bin/systemctl", "show", unit, "--property=ActiveState", "--property=ControlGroup", "--property=TasksMax"])
|
| 123 |
if result.returncode:
|
| 124 |
-
return {"ActiveState": "
|
| 125 |
return {key: value for key, value in (line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
def _store(self, record: dict[str, Any]) -> None:
|
|
|
|
| 128 |
self.operations.append("durable-state")
|
| 129 |
-
write_atomic(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
def _load(self, name: str) -> dict[str, Any]:
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
def _receipt_path(self, event_id: str) -> Path:
|
| 135 |
return self.receipts / f"{event_id}.json"
|
| 136 |
|
| 137 |
def _prepare(self) -> None:
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
| 139 |
path.mkdir(mode=mode, parents=True, exist_ok=True)
|
| 140 |
os.chown(path, 0, gid)
|
| 141 |
os.chmod(path, mode)
|
|
@@ -143,38 +204,25 @@ class Supervisor:
|
|
| 143 |
raise JsonInputError("control socket already exists")
|
| 144 |
self._recover()
|
| 145 |
|
| 146 |
-
def _new_lock(self, name: str) -> threading.Lock:
|
| 147 |
-
if name not in self.locks:
|
| 148 |
-
self.locks[name] = threading.Lock()
|
| 149 |
-
return self.locks[name]
|
| 150 |
-
|
| 151 |
def _cleanup(self, unit: str) -> dict[str, Any]:
|
| 152 |
result = self._run(["/usr/bin/systemctl", "stop", unit])
|
| 153 |
-
return {"
|
| 154 |
|
| 155 |
-
def
|
| 156 |
-
""
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
if record["run_id"] in self.completed or record["state"] != "RUNNING":
|
| 160 |
-
return False
|
| 161 |
-
if self._show(record["unit"])["ActiveState"] == "active":
|
| 162 |
-
return False
|
| 163 |
-
record["state"] = "COMPLETED_ALLOWED"
|
| 164 |
-
self._store(record)
|
| 165 |
-
return True
|
| 166 |
|
| 167 |
def _contain(self, record: dict[str, Any], trigger: str, trigger_ns: int | None = None) -> dict[str, Any]:
|
| 168 |
-
"""
|
| 169 |
-
lock = self._new_lock(record["
|
| 170 |
with lock:
|
| 171 |
if record["run_id"] in self.completed:
|
| 172 |
return self.completed[record["run_id"]]
|
| 173 |
identity = identity_from_run(record)
|
| 174 |
-
# Identity validation completes before the trigger is declared.
|
| 175 |
-
path = validate_identity(identity)
|
| 176 |
observed = trigger_ns if trigger_ns is not None else time.monotonic_ns()
|
| 177 |
try:
|
|
|
|
| 178 |
self.operations.append("cgroup.kill")
|
| 179 |
kill_started, kill_completed = kill_path(path)
|
| 180 |
empty_ns, proof = verify_empty(identity)
|
|
@@ -184,112 +232,205 @@ class Supervisor:
|
|
| 184 |
record["state"] = "CONTAINMENT_FAILED"
|
| 185 |
self._store(record)
|
| 186 |
raise JsonInputError(f"containment failed: {error}") from error
|
| 187 |
-
cleanup = self._cleanup(record["unit"])
|
| 188 |
event_id = os.urandom(12).hex()
|
| 189 |
-
receipt = make_receipt(record=record, trigger=trigger, trigger_ns=observed, kill_started_ns=kill_started, kill_complete_ns=kill_completed, empty_ns=empty_ns, proof=proof, version=__version__, source_commit=self.policy["source_commit"],
|
| 190 |
-
receipt["receipt_written_utc"] = dt.datetime.now(dt.UTC).isoformat().replace("+00:00", "Z")
|
| 191 |
try:
|
| 192 |
-
self.
|
| 193 |
-
write_atomic(self._receipt_path(event_id), receipt)
|
| 194 |
record["state"] = "TERMINATED"
|
| 195 |
self._store(record)
|
| 196 |
except Exception as error:
|
| 197 |
record["state"] = "CONTAINED_RECEIPT_FAILED"
|
| 198 |
self._store(record)
|
| 199 |
raise JsonInputError(f"contained but receipt persistence failed: {error}") from error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
receipt["receipt_path"] = str(self._receipt_path(event_id))
|
| 201 |
self.completed[record["run_id"]] = receipt
|
| 202 |
return receipt
|
| 203 |
|
| 204 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
identity = identity_from_run(record)
|
|
|
|
|
|
|
|
|
|
| 206 |
try:
|
| 207 |
-
baseline =
|
| 208 |
-
|
|
|
|
| 209 |
poller = select.poll()
|
| 210 |
-
poller.register(
|
|
|
|
|
|
|
|
|
|
| 211 |
while not self.stop_event.is_set():
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
# pids.events read. An already inactive unit completed normally;
|
| 218 |
-
# do not turn that lifecycle race into a false containment failure.
|
| 219 |
-
if self._complete_allowed(record):
|
| 220 |
-
return
|
| 221 |
-
os.lseek(descriptor, 0, os.SEEK_SET)
|
| 222 |
-
if pids_max_event(identity) > baseline:
|
| 223 |
self._contain(record, "PID_LIMIT", time.monotonic_ns())
|
| 224 |
return
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
try:
|
| 228 |
-
self._contain(record, "SUPERVISOR_RESTART_FAIL_CLOSED", time.monotonic_ns())
|
| 229 |
-
except JsonInputError:
|
| 230 |
-
pass
|
| 231 |
finally:
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
os.close(descriptor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
def _start(self, args: dict[str, Any]) -> dict[str, Any]:
|
| 236 |
if set(args) != {"argv", "max_pids", "name"}:
|
| 237 |
raise JsonInputError("start arguments are invalid")
|
| 238 |
name, argv, maximum = args["name"], args["argv"], args["max_pids"]
|
| 239 |
-
if not isinstance(name, str) or not NAME.fullmatch(name)
|
| 240 |
-
raise JsonInputError("workload name is
|
| 241 |
-
|
| 242 |
-
raise JsonInputError("workload argv is invalid")
|
| 243 |
if isinstance(maximum, bool) or not isinstance(maximum, int) or not 4 <= maximum <= 4096:
|
| 244 |
raise JsonInputError("max_pids must be from 4 to 4096")
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
run_id = unit.removeprefix(UNIT_PREFIX).removesuffix(".service")
|
| 268 |
if not RUN_ID.fullmatch(run_id):
|
| 269 |
-
raise JsonInputError("orphan
|
| 270 |
identity = capture_identity(cgroup, run_id, unit)
|
| 271 |
-
return {"
|
| 272 |
-
|
| 273 |
-
def _active_units(self) -> list[str]:
|
| 274 |
-
result = self._run(["/usr/bin/systemctl", "list-units", f"{UNIT_PREFIX}*", "--type=service", "--all", "--plain", "--no-legend"])
|
| 275 |
-
if result.returncode:
|
| 276 |
-
raise JsonInputError("cannot enumerate owned units")
|
| 277 |
-
return [line.split()[0] for line in result.stdout.splitlines() if line.split() and line.split()[0].startswith(UNIT_PREFIX)]
|
| 278 |
|
| 279 |
def _recover(self) -> None:
|
| 280 |
-
|
| 281 |
for path in self.runs.glob("*.json"):
|
| 282 |
try:
|
| 283 |
record = load_run(self.runs, path.stem)
|
| 284 |
-
records[record["unit"]] = record
|
| 285 |
except JsonInputError:
|
| 286 |
continue
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
continue
|
| 291 |
-
|
| 292 |
-
|
|
|
|
| 293 |
|
| 294 |
def handle(self, value: dict[str, Any]) -> dict[str, Any]:
|
| 295 |
if set(value) != {"action", "args"} or not isinstance(value["action"], str) or not isinstance(value["args"], dict):
|
|
@@ -301,13 +442,14 @@ class Supervisor:
|
|
| 301 |
if action == "start":
|
| 302 |
return self._start(args)
|
| 303 |
if action == "status" and set(args) == {"name"}:
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
return {"
|
| 309 |
if action == "list" and not args:
|
| 310 |
-
|
|
|
|
| 311 |
if action == "kill" and set(args) == {"name"}:
|
| 312 |
return self._contain(self._load(args["name"]), "OPERATOR")
|
| 313 |
raise JsonInputError("unsupported supervisor action")
|
|
@@ -341,11 +483,15 @@ class Supervisor:
|
|
| 341 |
|
| 342 |
|
| 343 |
def main(argv: list[str] | None = None) -> int:
|
| 344 |
-
parser = argparse.ArgumentParser(prog="
|
| 345 |
parser.add_argument("--policy", required=True, type=Path)
|
| 346 |
args = parser.parse_args(argv)
|
| 347 |
supervisor = Supervisor(args.policy)
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
return supervisor.serve()
|
| 350 |
|
| 351 |
|
|
|
|
| 1 |
+
"""Root-owned, fail-closed supervisor for one explicit protected workload."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
import datetime as dt
|
| 7 |
+
import errno
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
import re
|
|
|
|
| 13 |
import socket
|
| 14 |
import struct
|
| 15 |
import subprocess
|
| 16 |
+
import sys
|
| 17 |
import threading
|
| 18 |
import time
|
| 19 |
from pathlib import Path
|
|
|
|
| 22 |
from . import __version__
|
| 23 |
from .containment import (
|
| 24 |
capture_identity,
|
| 25 |
+
events_from_fd,
|
| 26 |
kill_path,
|
|
|
|
| 27 |
validate_identity,
|
| 28 |
verify_empty,
|
| 29 |
)
|
| 30 |
from .jsonio import JsonInputError, load_regular_json
|
| 31 |
from .records import (
|
| 32 |
+
ACTIVE_STATES,
|
| 33 |
+
RUN_ID,
|
| 34 |
RUN_SCHEMA,
|
| 35 |
+
command_summary,
|
| 36 |
identity_from_run,
|
| 37 |
load_run,
|
| 38 |
make_receipt,
|
| 39 |
+
name_path,
|
| 40 |
+
run_path,
|
| 41 |
validate_run,
|
| 42 |
write_atomic,
|
| 43 |
)
|
| 44 |
|
| 45 |
MAX_FRAME = 32 * 1024
|
| 46 |
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
| 47 |
+
POLICY_SCHEMA = "lumi-eggcracker.policy.v2"
|
| 48 |
+
SOCKET_PATH = Path("/run/lumi-eggcracker/control.sock")
|
| 49 |
+
STATE_DIR = Path("/var/lib/lumi-eggcracker")
|
| 50 |
+
UNIT_PREFIX = "lumi-eggcracker-workload-"
|
| 51 |
+
GATES_DIR = Path("/run/lumi-eggcracker/gates")
|
| 52 |
+
MAX_TERMINAL_RECORDS = 128
|
| 53 |
|
| 54 |
|
| 55 |
def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
|
|
| 110 |
raise JsonInputError("supervisor build identity is invalid")
|
| 111 |
self.policy = policy
|
| 112 |
self.runs = STATE_DIR / "runs"
|
| 113 |
+
self.names = STATE_DIR / "names"
|
| 114 |
self.receipts = STATE_DIR / "receipts"
|
| 115 |
self.stop_event = threading.Event()
|
| 116 |
self.locks: dict[str, threading.Lock] = {}
|
| 117 |
+
self.lock_guard = threading.Lock()
|
| 118 |
+
self.start_lock = threading.Lock()
|
| 119 |
self.completed: dict[str, dict[str, Any]] = {}
|
| 120 |
+
self.operations: list[str] = [] # Test-visible ordering only.
|
| 121 |
|
| 122 |
@property
|
| 123 |
def operator_uid(self) -> int:
|
|
|
|
| 128 |
|
| 129 |
def _show(self, unit: str) -> dict[str, str]:
|
| 130 |
if not unit.startswith(UNIT_PREFIX) or not unit.endswith(".service"):
|
| 131 |
+
raise JsonInputError("unit is outside Eggcracker namespace")
|
| 132 |
result = self._run(["/usr/bin/systemctl", "show", unit, "--property=ActiveState", "--property=ControlGroup", "--property=TasksMax"])
|
| 133 |
if result.returncode:
|
| 134 |
+
return {"ActiveState": "unknown", "ControlGroup": "", "TasksMax": ""}
|
| 135 |
return {key: value for key, value in (line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)}
|
| 136 |
|
| 137 |
+
def _new_lock(self, name: str) -> threading.Lock:
|
| 138 |
+
with self.lock_guard:
|
| 139 |
+
return self.locks.setdefault(name, threading.Lock())
|
| 140 |
+
|
| 141 |
def _store(self, record: dict[str, Any]) -> None:
|
| 142 |
+
record = validate_run(record)
|
| 143 |
self.operations.append("durable-state")
|
| 144 |
+
write_atomic(run_path(self.runs, record["run_id"]), record)
|
| 145 |
+
pointer = name_path(self.names, record["name"])
|
| 146 |
+
if record["state"] in ACTIVE_STATES:
|
| 147 |
+
write_atomic(pointer, {"run_id": record["run_id"]})
|
| 148 |
+
elif pointer.exists():
|
| 149 |
+
try:
|
| 150 |
+
current = load_regular_json(pointer)
|
| 151 |
+
if current == {"run_id": record["run_id"]}:
|
| 152 |
+
pointer.unlink()
|
| 153 |
+
except JsonInputError:
|
| 154 |
+
raise JsonInputError("workload name index is invalid")
|
| 155 |
+
self._prune_terminal_records()
|
| 156 |
+
|
| 157 |
+
def _prune_terminal_records(self) -> None:
|
| 158 |
+
records: list[tuple[int, Path]] = []
|
| 159 |
+
for path in self.runs.glob("*.json"):
|
| 160 |
+
try:
|
| 161 |
+
value = load_run(self.runs, path.stem)
|
| 162 |
+
except JsonInputError:
|
| 163 |
+
continue
|
| 164 |
+
if value["state"] not in ACTIVE_STATES:
|
| 165 |
+
records.append((int(value["created_monotonic_ns"]), path))
|
| 166 |
+
for _, path in sorted(records, reverse=True)[MAX_TERMINAL_RECORDS:]:
|
| 167 |
+
path.unlink(missing_ok=True)
|
| 168 |
|
| 169 |
def _load(self, name: str) -> dict[str, Any]:
|
| 170 |
+
pointer = load_regular_json(name_path(self.names, name))
|
| 171 |
+
if set(pointer) != {"run_id"}:
|
| 172 |
+
raise JsonInputError("workload name index is invalid")
|
| 173 |
+
record = load_run(self.runs, pointer["run_id"])
|
| 174 |
+
if record["name"] != name or record["state"] not in ACTIVE_STATES:
|
| 175 |
+
raise JsonInputError("workload name is unavailable")
|
| 176 |
+
return record
|
| 177 |
+
|
| 178 |
+
def _latest_by_name(self, name: str) -> dict[str, Any]:
|
| 179 |
+
"""Resolve a completed run only when its name is no longer active."""
|
| 180 |
+
candidates: list[dict[str, Any]] = []
|
| 181 |
+
for path in self.runs.glob("*.json"):
|
| 182 |
+
try:
|
| 183 |
+
record = load_run(self.runs, path.stem)
|
| 184 |
+
except JsonInputError:
|
| 185 |
+
continue
|
| 186 |
+
if record["name"] == name:
|
| 187 |
+
candidates.append(record)
|
| 188 |
+
if not candidates:
|
| 189 |
+
raise JsonInputError("workload name is unavailable")
|
| 190 |
+
return max(candidates, key=lambda item: int(item["created_monotonic_ns"]))
|
| 191 |
|
| 192 |
def _receipt_path(self, event_id: str) -> Path:
|
| 193 |
return self.receipts / f"{event_id}.json"
|
| 194 |
|
| 195 |
def _prepare(self) -> None:
|
| 196 |
+
# Workloads need traversal only to their group-readable gate. The
|
| 197 |
+
# control socket remains a root/operator 0660 inode, so traversal does
|
| 198 |
+
# not grant control-socket access or directory listing.
|
| 199 |
+
for path, mode, gid in ((SOCKET_PATH.parent, 0o711, self.policy["operator_gid"]), (GATES_DIR, 0o710, self.policy["workload_gid"]), (STATE_DIR, 0o700, 0), (self.runs, 0o700, 0), (self.names, 0o700, 0), (self.receipts, 0o700, 0)):
|
| 200 |
path.mkdir(mode=mode, parents=True, exist_ok=True)
|
| 201 |
os.chown(path, 0, gid)
|
| 202 |
os.chmod(path, mode)
|
|
|
|
| 204 |
raise JsonInputError("control socket already exists")
|
| 205 |
self._recover()
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
def _cleanup(self, unit: str) -> dict[str, Any]:
|
| 208 |
result = self._run(["/usr/bin/systemctl", "stop", unit])
|
| 209 |
+
return {"attempted": True, "systemctl_stop_returncode": result.returncode, "systemctl_stop_stderr": result.stderr.strip()}
|
| 210 |
|
| 211 |
+
def _write_receipt(self, receipt: dict[str, Any], event_id: str) -> None:
|
| 212 |
+
receipt["receipt_written_utc"] = dt.datetime.now(dt.UTC).isoformat().replace("+00:00", "Z")
|
| 213 |
+
self.operations.append("durable-receipt")
|
| 214 |
+
write_atomic(self._receipt_path(event_id), receipt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
def _contain(self, record: dict[str, Any], trigger: str, trigger_ns: int | None = None) -> dict[str, Any]:
|
| 217 |
+
"""Direct cgroup.kill is the first trigger-side effect; cleanup is post-proof only."""
|
| 218 |
+
lock = self._new_lock(record["run_id"])
|
| 219 |
with lock:
|
| 220 |
if record["run_id"] in self.completed:
|
| 221 |
return self.completed[record["run_id"]]
|
| 222 |
identity = identity_from_run(record)
|
|
|
|
|
|
|
| 223 |
observed = trigger_ns if trigger_ns is not None else time.monotonic_ns()
|
| 224 |
try:
|
| 225 |
+
path = validate_identity(identity)
|
| 226 |
self.operations.append("cgroup.kill")
|
| 227 |
kill_started, kill_completed = kill_path(path)
|
| 228 |
empty_ns, proof = verify_empty(identity)
|
|
|
|
| 232 |
record["state"] = "CONTAINMENT_FAILED"
|
| 233 |
self._store(record)
|
| 234 |
raise JsonInputError(f"containment failed: {error}") from error
|
|
|
|
| 235 |
event_id = os.urandom(12).hex()
|
| 236 |
+
receipt = make_receipt(record=record, trigger=trigger, trigger_ns=observed, kill_started_ns=kill_started, kill_complete_ns=kill_completed, empty_ns=empty_ns, proof=proof, version=__version__, source_commit=self.policy["source_commit"], event_id=event_id)
|
|
|
|
| 237 |
try:
|
| 238 |
+
self._write_receipt(receipt, event_id)
|
|
|
|
| 239 |
record["state"] = "TERMINATED"
|
| 240 |
self._store(record)
|
| 241 |
except Exception as error:
|
| 242 |
record["state"] = "CONTAINED_RECEIPT_FAILED"
|
| 243 |
self._store(record)
|
| 244 |
raise JsonInputError(f"contained but receipt persistence failed: {error}") from error
|
| 245 |
+
# Cleanup is deliberately not in the enforcement or proof path.
|
| 246 |
+
cleanup = self._cleanup(record["unit"])
|
| 247 |
+
receipt["cleanup"] = cleanup
|
| 248 |
+
try:
|
| 249 |
+
write_atomic(self._receipt_path(event_id), receipt)
|
| 250 |
+
except (OSError, JsonInputError):
|
| 251 |
+
receipt["cleanup_update_error"] = True
|
| 252 |
receipt["receipt_path"] = str(self._receipt_path(event_id))
|
| 253 |
self.completed[record["run_id"]] = receipt
|
| 254 |
return receipt
|
| 255 |
|
| 256 |
+
def _complete_allowed(self, record: dict[str, Any], cgroup_events_fd: int) -> bool:
|
| 257 |
+
"""Only exact cgroup population, not systemd state, permits completion."""
|
| 258 |
+
events = events_from_fd(cgroup_events_fd)
|
| 259 |
+
if events.get("populated") != 0:
|
| 260 |
+
return False
|
| 261 |
+
lock = self._new_lock(record["run_id"])
|
| 262 |
+
with lock:
|
| 263 |
+
if record["state"] not in ACTIVE_STATES:
|
| 264 |
+
return False
|
| 265 |
+
record["state"] = "COMPLETED_ALLOWED"
|
| 266 |
+
self._store(record)
|
| 267 |
+
return True
|
| 268 |
+
|
| 269 |
+
def _watch_once(self, record: dict[str, Any], ready: threading.Event | None = None) -> None:
|
| 270 |
identity = identity_from_run(record)
|
| 271 |
+
path = validate_identity(identity)
|
| 272 |
+
pids_fd = os.open(path / "pids.events", os.O_RDONLY | os.O_CLOEXEC)
|
| 273 |
+
cgroup_fd = os.open(path / "cgroup.events", os.O_RDONLY | os.O_CLOEXEC)
|
| 274 |
try:
|
| 275 |
+
baseline = events_from_fd(pids_fd).get("max")
|
| 276 |
+
if baseline is None:
|
| 277 |
+
raise JsonInputError("pids.events lacks max counter")
|
| 278 |
poller = select.poll()
|
| 279 |
+
poller.register(pids_fd, select.POLLPRI)
|
| 280 |
+
poller.register(cgroup_fd, select.POLLPRI)
|
| 281 |
+
if ready is not None:
|
| 282 |
+
ready.set()
|
| 283 |
while not self.stop_event.is_set():
|
| 284 |
+
poller.poll(250)
|
| 285 |
+
current = events_from_fd(pids_fd).get("max")
|
| 286 |
+
if current is None:
|
| 287 |
+
raise JsonInputError("pids.events lacks max counter")
|
| 288 |
+
if current > baseline:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
self._contain(record, "PID_LIMIT", time.monotonic_ns())
|
| 290 |
return
|
| 291 |
+
if self._complete_allowed(record, cgroup_fd):
|
| 292 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
finally:
|
| 294 |
+
os.close(pids_fd)
|
| 295 |
+
os.close(cgroup_fd)
|
| 296 |
+
|
| 297 |
+
def _watch(self, record: dict[str, Any], ready: threading.Event | None = None) -> None:
|
| 298 |
+
failure: BaseException | None = None
|
| 299 |
+
for attempt in range(2):
|
| 300 |
+
try:
|
| 301 |
+
self._watch_once(record, ready)
|
| 302 |
+
return
|
| 303 |
+
except (JsonInputError, OSError, RuntimeError) as error:
|
| 304 |
+
failure = error
|
| 305 |
+
if attempt == 0:
|
| 306 |
+
continue
|
| 307 |
+
try:
|
| 308 |
+
self._contain(record, "SUPERVISOR_FAILURE", time.monotonic_ns())
|
| 309 |
+
except JsonInputError:
|
| 310 |
+
# A failed containment is intentionally left as a durable failure state.
|
| 311 |
+
pass
|
| 312 |
+
if failure is not None:
|
| 313 |
+
print(f"eggcracker watcher failed closed: {failure}", file=sys.stderr, flush=True)
|
| 314 |
+
|
| 315 |
+
def _make_gate(self, run_id: str) -> Path:
|
| 316 |
+
gate = GATES_DIR / run_id
|
| 317 |
+
if gate.exists() or gate.is_symlink():
|
| 318 |
+
raise JsonInputError("launch gate already exists")
|
| 319 |
+
os.mkfifo(gate, 0o640)
|
| 320 |
+
os.chown(gate, 0, self.policy["workload_gid"])
|
| 321 |
+
os.chmod(gate, 0o640)
|
| 322 |
+
return gate
|
| 323 |
+
|
| 324 |
+
def _release_gate(self, gate: Path) -> None:
|
| 325 |
+
deadline = time.monotonic() + 1.5
|
| 326 |
+
try:
|
| 327 |
+
while True:
|
| 328 |
+
try:
|
| 329 |
+
descriptor = os.open(gate, os.O_WRONLY | os.O_NONBLOCK | os.O_CLOEXEC)
|
| 330 |
+
break
|
| 331 |
+
except OSError as error:
|
| 332 |
+
if error.errno != errno.ENXIO or time.monotonic() >= deadline:
|
| 333 |
+
raise JsonInputError("workload gate did not attach") from error
|
| 334 |
+
time.sleep(0.01)
|
| 335 |
+
try:
|
| 336 |
+
if os.write(descriptor, b"GO\n") != 3:
|
| 337 |
+
raise JsonInputError("short launch-gate release")
|
| 338 |
+
finally:
|
| 339 |
os.close(descriptor)
|
| 340 |
+
finally:
|
| 341 |
+
gate.unlink(missing_ok=True)
|
| 342 |
+
|
| 343 |
+
def _active_exists(self) -> bool:
|
| 344 |
+
for path in self.runs.glob("*.json"):
|
| 345 |
+
try:
|
| 346 |
+
if load_run(self.runs, path.stem)["state"] in ACTIVE_STATES:
|
| 347 |
+
return True
|
| 348 |
+
except JsonInputError:
|
| 349 |
+
raise JsonInputError("run record is invalid")
|
| 350 |
+
return False
|
| 351 |
|
| 352 |
def _start(self, args: dict[str, Any]) -> dict[str, Any]:
|
| 353 |
if set(args) != {"argv", "max_pids", "name"}:
|
| 354 |
raise JsonInputError("start arguments are invalid")
|
| 355 |
name, argv, maximum = args["name"], args["argv"], args["max_pids"]
|
| 356 |
+
if not isinstance(name, str) or not NAME.fullmatch(name):
|
| 357 |
+
raise JsonInputError("workload name is invalid")
|
| 358 |
+
summary = command_summary(argv)
|
|
|
|
| 359 |
if isinstance(maximum, bool) or not isinstance(maximum, int) or not 4 <= maximum <= 4096:
|
| 360 |
raise JsonInputError("max_pids must be from 4 to 4096")
|
| 361 |
+
with self.start_lock:
|
| 362 |
+
if name_path(self.names, name).exists() or self._active_exists():
|
| 363 |
+
raise JsonInputError("one protected workload is already active or name is unavailable")
|
| 364 |
+
run_id = os.urandom(12).hex()
|
| 365 |
+
unit = f"{UNIT_PREFIX}{run_id}.service"
|
| 366 |
+
gate = self._make_gate(run_id)
|
| 367 |
+
result = self._run(["/usr/bin/systemd-run", "--no-block", f"--unit={unit}", "--collect", f"--uid={self.policy['workload_uid']}", f"--gid={self.policy['workload_gid']}", "--property=Type=exec", "--property=ExitType=cgroup", "--property=KillMode=control-group", "--property=NoNewPrivileges=yes", "--property=UMask=0077", f"--property=TasksMax={maximum}", "--", "/usr/bin/python3", "/usr/local/lib/lumi-eggcracker/lumi-eggcracker.pyz", "_gate", "--fifo", str(gate), "--", *argv])
|
| 368 |
+
if result.returncode:
|
| 369 |
+
gate.unlink(missing_ok=True)
|
| 370 |
+
raise JsonInputError(result.stderr.strip() or "system workload launch failed")
|
| 371 |
+
try:
|
| 372 |
+
deadline = time.monotonic() + 2.0
|
| 373 |
+
props: dict[str, str] = {}
|
| 374 |
+
while time.monotonic() < deadline:
|
| 375 |
+
props = self._show(unit)
|
| 376 |
+
if props["ActiveState"] == "active" and props["ControlGroup"]:
|
| 377 |
+
break
|
| 378 |
+
time.sleep(0.01)
|
| 379 |
+
if props.get("ActiveState") != "active" or not props.get("ControlGroup"):
|
| 380 |
+
raise JsonInputError("gated workload did not become active")
|
| 381 |
+
identity = capture_identity(props["ControlGroup"], run_id, unit)
|
| 382 |
+
record = {**summary, "boot_id": identity.boot_id, "cgroup": identity.cgroup, "cgroup_device": identity.device, "cgroup_inode": identity.inode, "created_monotonic_ns": time.monotonic_ns(), "max_pids": maximum, "name": name, "operator_uid": self.operator_uid, "run_id": run_id, "schema_version": RUN_SCHEMA, "state": "STARTING", "unit": unit, "workload_gid": self.policy["workload_gid"], "workload_uid": self.policy["workload_uid"]}
|
| 383 |
+
self._store(record)
|
| 384 |
+
ready = threading.Event()
|
| 385 |
+
watcher = threading.Thread(target=self._watch, args=(record, ready), daemon=True)
|
| 386 |
+
watcher.start()
|
| 387 |
+
if not ready.wait(2.0):
|
| 388 |
+
raise JsonInputError("watcher did not become ready before target release")
|
| 389 |
+
# The gated target has not executed yet. The watcher has opened
|
| 390 |
+
# both event descriptors and captured the PID baseline.
|
| 391 |
+
self._release_gate(gate)
|
| 392 |
+
if record["state"] in ACTIVE_STATES:
|
| 393 |
+
record["state"] = "RUNNING"
|
| 394 |
+
self._store(record)
|
| 395 |
+
return {"name": name, "run_id": run_id, "state": record["state"], "unit": unit, "workload_uid": record["workload_uid"]}
|
| 396 |
+
except Exception:
|
| 397 |
+
gate.unlink(missing_ok=True)
|
| 398 |
+
try:
|
| 399 |
+
# Best effort rollback after a launch failure; direct kill is still authoritative.
|
| 400 |
+
identity = capture_identity(props["ControlGroup"], run_id, unit)
|
| 401 |
+
kill_path(validate_identity(identity))
|
| 402 |
+
verify_empty(identity)
|
| 403 |
+
except (JsonInputError, OSError, RuntimeError):
|
| 404 |
+
self._run(["/usr/bin/systemctl", "stop", unit])
|
| 405 |
+
raise
|
| 406 |
+
|
| 407 |
+
def _orphan_record(self, cgroup: str) -> dict[str, Any]:
|
| 408 |
+
unit = Path(cgroup).name
|
| 409 |
run_id = unit.removeprefix(UNIT_PREFIX).removesuffix(".service")
|
| 410 |
if not RUN_ID.fullmatch(run_id):
|
| 411 |
+
raise JsonInputError("orphan cgroup identity is invalid")
|
| 412 |
identity = capture_identity(cgroup, run_id, unit)
|
| 413 |
+
return {"argv_count": 0, "argv_sha256": "0" * 64, "boot_id": identity.boot_id, "cgroup": identity.cgroup, "cgroup_device": identity.device, "cgroup_inode": identity.inode, "created_monotonic_ns": time.monotonic_ns(), "executable": "<orphaned-owned-cgroup>", "max_pids": 0, "name": f"orphan-{run_id}", "operator_uid": self.operator_uid, "run_id": run_id, "schema_version": RUN_SCHEMA, "state": "RUNNING", "unit": unit, "workload_gid": self.policy["workload_gid"], "workload_uid": self.policy["workload_uid"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
def _recover(self) -> None:
|
| 416 |
+
recorded_ids: set[str] = set()
|
| 417 |
for path in self.runs.glob("*.json"):
|
| 418 |
try:
|
| 419 |
record = load_run(self.runs, path.stem)
|
|
|
|
| 420 |
except JsonInputError:
|
| 421 |
continue
|
| 422 |
+
recorded_ids.add(record["run_id"])
|
| 423 |
+
if record["state"] in ACTIVE_STATES:
|
| 424 |
+
self._contain(record, "SUPERVISOR_RESTART_FAIL_CLOSED")
|
| 425 |
+
root = Path("/sys/fs/cgroup/system.slice")
|
| 426 |
+
if not root.is_dir():
|
| 427 |
+
return
|
| 428 |
+
for path in root.glob(f"{UNIT_PREFIX}*.service"):
|
| 429 |
+
if not path.is_dir() or path.is_symlink():
|
| 430 |
continue
|
| 431 |
+
run_id = path.name.removeprefix(UNIT_PREFIX).removesuffix(".service")
|
| 432 |
+
if RUN_ID.fullmatch(run_id) and run_id not in recorded_ids:
|
| 433 |
+
self._contain(self._orphan_record("/system.slice/" + path.name), "SUPERVISOR_RESTART_FAIL_CLOSED")
|
| 434 |
|
| 435 |
def handle(self, value: dict[str, Any]) -> dict[str, Any]:
|
| 436 |
if set(value) != {"action", "args"} or not isinstance(value["action"], str) or not isinstance(value["args"], dict):
|
|
|
|
| 442 |
if action == "start":
|
| 443 |
return self._start(args)
|
| 444 |
if action == "status" and set(args) == {"name"}:
|
| 445 |
+
try:
|
| 446 |
+
record = self._load(args["name"])
|
| 447 |
+
except JsonInputError:
|
| 448 |
+
record = self._latest_by_name(args["name"])
|
| 449 |
+
return {"name": record["name"], "run_id": record["run_id"], "state": record["state"], "unit": record["unit"], "workload_uid": record["workload_uid"]}
|
| 450 |
if action == "list" and not args:
|
| 451 |
+
runs = [self.handle({"action": "status", "args": {"name": path.stem}}) for path in sorted(self.names.glob("*.json"))]
|
| 452 |
+
return {"runs": runs}
|
| 453 |
if action == "kill" and set(args) == {"name"}:
|
| 454 |
return self._contain(self._load(args["name"]), "OPERATOR")
|
| 455 |
raise JsonInputError("unsupported supervisor action")
|
|
|
|
| 483 |
|
| 484 |
|
| 485 |
def main(argv: list[str] | None = None) -> int:
|
| 486 |
+
parser = argparse.ArgumentParser(prog="eggcracker internal-supervisor")
|
| 487 |
parser.add_argument("--policy", required=True, type=Path)
|
| 488 |
args = parser.parse_args(argv)
|
| 489 |
supervisor = Supervisor(args.policy)
|
| 490 |
+
def stop(*_: object) -> None:
|
| 491 |
+
supervisor.stop_event.set()
|
| 492 |
+
raise SystemExit(0)
|
| 493 |
+
|
| 494 |
+
signal.signal(signal.SIGTERM, stop)
|
| 495 |
return supervisor.serve()
|
| 496 |
|
| 497 |
|
src/lumi_nutcracker/__init__.py
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
"""Lumi Nutcracker: explicit protected Linux workload termination."""
|
| 2 |
-
|
| 3 |
-
__version__ = "0.1.0"
|
|
|
|
|
|
|
|
|
|
|
|
tests/fixtures/hostile_client.py
CHANGED
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
| 11 |
|
| 12 |
output = Path(sys.argv[1])
|
| 13 |
attempts = int(sys.argv[2])
|
| 14 |
-
socket_path = "/run/lumi-
|
| 15 |
successes = 0
|
| 16 |
replacement_successes = 0
|
| 17 |
for index in range(attempts):
|
|
@@ -22,7 +22,7 @@ for index in range(attempts):
|
|
| 22 |
successes += 1
|
| 23 |
except OSError:
|
| 24 |
pass
|
| 25 |
-
result = subprocess.run(["/usr/local/bin/
|
| 26 |
if result.returncode == 0:
|
| 27 |
replacement_successes += 1
|
| 28 |
output.write_text(json.dumps({"connection_successes": successes, "replacement_successes": replacement_successes, "uid": os.getuid()}) + "\n", encoding="utf-8")
|
|
|
|
| 11 |
|
| 12 |
output = Path(sys.argv[1])
|
| 13 |
attempts = int(sys.argv[2])
|
| 14 |
+
socket_path = "/run/lumi-eggcracker/control.sock"
|
| 15 |
successes = 0
|
| 16 |
replacement_successes = 0
|
| 17 |
for index in range(attempts):
|
|
|
|
| 22 |
successes += 1
|
| 23 |
except OSError:
|
| 24 |
pass
|
| 25 |
+
result = subprocess.run(["/usr/local/bin/eggcracker", "start", "--name", "replacement-hostile", "--max-pids", "4", "--", "/bin/sleep", "2"], capture_output=True, text=True, check=False)
|
| 26 |
if result.returncode == 0:
|
| 27 |
replacement_successes += 1
|
| 28 |
output.write_text(json.dumps({"connection_successes": successes, "replacement_successes": replacement_successes, "uid": os.getuid()}) + "\n", encoding="utf-8")
|
tests/test_cli.py
CHANGED
|
@@ -5,7 +5,7 @@ import unittest
|
|
| 5 |
from contextlib import redirect_stdout
|
| 6 |
from unittest.mock import patch
|
| 7 |
|
| 8 |
-
from
|
| 9 |
|
| 10 |
|
| 11 |
class CliTests(unittest.TestCase):
|
|
@@ -13,19 +13,17 @@ class CliTests(unittest.TestCase):
|
|
| 13 |
output = io.StringIO()
|
| 14 |
with redirect_stdout(output):
|
| 15 |
self.assertEqual(0, main(["version"]))
|
| 16 |
-
self.assertEqual("0.1.
|
| 17 |
|
| 18 |
def test_public_help_has_only_supported_commands(self) -> None:
|
| 19 |
-
from
|
| 20 |
-
|
| 21 |
help_text = _parser().format_help()
|
| 22 |
for command in ("start", "kill", "status", "list", "doctor", "version"):
|
| 23 |
self.assertIn(command, help_text)
|
| 24 |
self.assertNotIn("_supervisor", help_text)
|
| 25 |
self.assertNotIn("network" + "-deny", help_text)
|
| 26 |
-
self.assertNotIn("root" + "less", help_text)
|
| 27 |
|
| 28 |
def test_internal_supervisor_dispatch_remains_available(self) -> None:
|
| 29 |
-
with patch("
|
| 30 |
self.assertEqual(main(["_supervisor", "--policy", "/tmp/policy.json"]), 7)
|
| 31 |
supervisor.assert_called_once_with(["--policy", "/tmp/policy.json"])
|
|
|
|
| 5 |
from contextlib import redirect_stdout
|
| 6 |
from unittest.mock import patch
|
| 7 |
|
| 8 |
+
from lumi_eggcracker.cli import main
|
| 9 |
|
| 10 |
|
| 11 |
class CliTests(unittest.TestCase):
|
|
|
|
| 13 |
output = io.StringIO()
|
| 14 |
with redirect_stdout(output):
|
| 15 |
self.assertEqual(0, main(["version"]))
|
| 16 |
+
self.assertEqual("0.1.1", output.getvalue().strip())
|
| 17 |
|
| 18 |
def test_public_help_has_only_supported_commands(self) -> None:
|
| 19 |
+
from lumi_eggcracker.cli import _parser
|
|
|
|
| 20 |
help_text = _parser().format_help()
|
| 21 |
for command in ("start", "kill", "status", "list", "doctor", "version"):
|
| 22 |
self.assertIn(command, help_text)
|
| 23 |
self.assertNotIn("_supervisor", help_text)
|
| 24 |
self.assertNotIn("network" + "-deny", help_text)
|
|
|
|
| 25 |
|
| 26 |
def test_internal_supervisor_dispatch_remains_available(self) -> None:
|
| 27 |
+
with patch("lumi_eggcracker.cli.supervisor_main", return_value=7) as supervisor:
|
| 28 |
self.assertEqual(main(["_supervisor", "--policy", "/tmp/policy.json"]), 7)
|
| 29 |
supervisor.assert_called_once_with(["--policy", "/tmp/policy.json"])
|
tests/test_containment.py
CHANGED
|
@@ -5,14 +5,14 @@ import unittest
|
|
| 5 |
from pathlib import Path
|
| 6 |
from unittest.mock import patch
|
| 7 |
|
| 8 |
-
from
|
| 9 |
-
from
|
| 10 |
|
| 11 |
|
| 12 |
class ContainmentTests(unittest.TestCase):
|
| 13 |
def _root(self, temporary: Path) -> tuple[Path, str, str, str]:
|
| 14 |
run_id = "a" * 24
|
| 15 |
-
unit = f"lumi-
|
| 16 |
cgroup = f"/system.slice/{unit}"
|
| 17 |
path = temporary / "system.slice" / unit
|
| 18 |
path.mkdir(parents=True)
|
|
@@ -51,11 +51,10 @@ class ContainmentTests(unittest.TestCase):
|
|
| 51 |
self.assertTrue(direct.called)
|
| 52 |
self.assertTrue(proof.complete)
|
| 53 |
|
| 54 |
-
def
|
| 55 |
with tempfile.TemporaryDirectory() as raw, patch.object(containment, "boot_id", return_value="a" * 36):
|
| 56 |
-
|
| 57 |
identity = containment.capture_identity(cgroup, run_id, unit, root=Path(raw))
|
| 58 |
-
(
|
| 59 |
-
with patch.object(containment, "kill_path", side_effect=FileNotFoundError(2, "No such file")):
|
| 60 |
_, proof = containment.verify_empty(identity, root=Path(raw))
|
| 61 |
self.assertTrue(proof.complete)
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from unittest.mock import patch
|
| 7 |
|
| 8 |
+
from lumi_eggcracker import containment
|
| 9 |
+
from lumi_eggcracker.jsonio import JsonInputError
|
| 10 |
|
| 11 |
|
| 12 |
class ContainmentTests(unittest.TestCase):
|
| 13 |
def _root(self, temporary: Path) -> tuple[Path, str, str, str]:
|
| 14 |
run_id = "a" * 24
|
| 15 |
+
unit = f"lumi-eggcracker-workload-{run_id}.service"
|
| 16 |
cgroup = f"/system.slice/{unit}"
|
| 17 |
path = temporary / "system.slice" / unit
|
| 18 |
path.mkdir(parents=True)
|
|
|
|
| 51 |
self.assertTrue(direct.called)
|
| 52 |
self.assertTrue(proof.complete)
|
| 53 |
|
| 54 |
+
def test_collected_cgroup_wrapped_as_json_error_is_still_an_empty_proof(self) -> None:
|
| 55 |
with tempfile.TemporaryDirectory() as raw, patch.object(containment, "boot_id", return_value="a" * 36):
|
| 56 |
+
_, cgroup, run_id, unit = self._root(Path(raw))
|
| 57 |
identity = containment.capture_identity(cgroup, run_id, unit, root=Path(raw))
|
| 58 |
+
with patch.object(containment, "_events", side_effect=JsonInputError("cannot read cgroup.events: No such file")):
|
|
|
|
| 59 |
_, proof = containment.verify_empty(identity, root=Path(raw))
|
| 60 |
self.assertTrue(proof.complete)
|
tests/test_records.py
CHANGED
|
@@ -2,25 +2,29 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import unittest
|
| 4 |
|
| 5 |
-
from
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
|
| 9 |
|
| 10 |
def record() -> dict[str, object]:
|
| 11 |
run_id = "a" * 24
|
| 12 |
-
return {
|
| 13 |
|
| 14 |
|
| 15 |
class RecordTests(unittest.TestCase):
|
| 16 |
def test_receipt_requires_exact_empty_proof(self) -> None:
|
| 17 |
value = record()
|
| 18 |
-
receipt = make_receipt(record=value, trigger="OPERATOR", trigger_ns=10, kill_started_ns=11, kill_complete_ns=12, empty_ns=13, proof=EmptyProof(True, 1, 0, []), version="0.1.
|
| 19 |
self.assertEqual("TERMINATED", receipt["result"])
|
| 20 |
self.assertEqual("cgroup.kill", receipt["containment"]["primitive"])
|
| 21 |
|
| 22 |
-
def
|
| 23 |
value = record()
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
with self.assertRaises(JsonInputError):
|
| 26 |
validate_run(value)
|
|
|
|
| 2 |
|
| 3 |
import unittest
|
| 4 |
|
| 5 |
+
from lumi_eggcracker.containment import EmptyProof
|
| 6 |
+
from lumi_eggcracker.jsonio import JsonInputError
|
| 7 |
+
from lumi_eggcracker.records import RUN_SCHEMA, command_summary, make_receipt, validate_run
|
| 8 |
|
| 9 |
|
| 10 |
def record() -> dict[str, object]:
|
| 11 |
run_id = "a" * 24
|
| 12 |
+
return {**command_summary(["/bin/true", "--safe"]), "boot_id": "b" * 36, "cgroup": f"/system.slice/lumi-eggcracker-workload-{run_id}.service", "cgroup_device": 1, "cgroup_inode": 2, "created_monotonic_ns": 3, "max_pids": 8, "name": "demo", "operator_uid": 1001, "run_id": run_id, "schema_version": RUN_SCHEMA, "state": "RUNNING", "unit": f"lumi-eggcracker-workload-{run_id}.service", "workload_gid": 2001, "workload_uid": 2001}
|
| 13 |
|
| 14 |
|
| 15 |
class RecordTests(unittest.TestCase):
|
| 16 |
def test_receipt_requires_exact_empty_proof(self) -> None:
|
| 17 |
value = record()
|
| 18 |
+
receipt = make_receipt(record=value, trigger="OPERATOR", trigger_ns=10, kill_started_ns=11, kill_complete_ns=12, empty_ns=13, proof=EmptyProof(True, 1, 0, []), version="0.1.1", source_commit="c" * 40, event_id="d" * 24)
|
| 19 |
self.assertEqual("TERMINATED", receipt["result"])
|
| 20 |
self.assertEqual("cgroup.kill", receipt["containment"]["primitive"])
|
| 21 |
|
| 22 |
+
def test_durable_record_redacts_command_arguments(self) -> None:
|
| 23 |
value = record()
|
| 24 |
+
self.assertNotIn("argv", value)
|
| 25 |
+
self.assertNotIn("--safe", str(value))
|
| 26 |
+
|
| 27 |
+
def test_schema_rejects_unknown_field(self) -> None:
|
| 28 |
+
value = record(); value["extra"] = True
|
| 29 |
with self.assertRaises(JsonInputError):
|
| 30 |
validate_run(value)
|
tests/test_supervisor.py
CHANGED
|
@@ -1,45 +1,78 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import unittest
|
| 4 |
from pathlib import Path
|
| 5 |
from unittest.mock import patch
|
| 6 |
|
| 7 |
-
from
|
| 8 |
-
from
|
| 9 |
-
from
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def record() -> dict[str, object]:
|
| 13 |
run_id = "a" * 24
|
| 14 |
-
return {
|
| 15 |
|
| 16 |
|
| 17 |
class SupervisorTests(unittest.TestCase):
|
| 18 |
def _instance(self) -> Supervisor:
|
| 19 |
value = object.__new__(Supervisor)
|
| 20 |
-
value.policy = {"source_commit": "c" * 40}
|
| 21 |
value.runs = Path(".")
|
|
|
|
| 22 |
value.receipts = Path(".")
|
| 23 |
value.locks = {}
|
|
|
|
|
|
|
| 24 |
value.completed = {}
|
| 25 |
value.operations = []
|
| 26 |
return value
|
| 27 |
|
| 28 |
-
def
|
| 29 |
supervisor = self._instance()
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
with patch("lumi_nutcracker.supervisor.validate_identity", return_value=Path("/owned")), patch("lumi_nutcracker.supervisor.kill_path", return_value=(11, 12)), patch("lumi_nutcracker.supervisor.verify_empty", return_value=(13, EmptyProof(True, 1, 0, []))), patch.object(supervisor, "_cleanup", return_value={}), patch("lumi_nutcracker.supervisor.make_receipt", return_value=response), patch("lumi_nutcracker.supervisor.write_atomic", side_effect=lambda *_: saved.append("write")), patch.object(supervisor, "_store", side_effect=lambda *_: supervisor.operations.append("durable-state")):
|
| 33 |
result = supervisor._contain(record(), "OPERATOR", 10)
|
| 34 |
self.assertEqual("TERMINATED", result["result"])
|
| 35 |
self.assertEqual("cgroup.kill", supervisor.operations[0])
|
| 36 |
self.assertLess(supervisor.operations.index("cgroup.kill"), supervisor.operations.index("durable-receipt"))
|
| 37 |
-
self.assertLess(supervisor.operations.index("
|
| 38 |
-
self.assertEqual(["write"], saved)
|
| 39 |
|
| 40 |
-
def
|
| 41 |
supervisor = self._instance()
|
| 42 |
saved: list[dict[str, object]] = []
|
| 43 |
-
with patch
|
| 44 |
-
self.assertTrue(supervisor._complete_allowed(record()))
|
| 45 |
self.assertEqual("COMPLETED_ALLOWED", saved[0]["state"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import tempfile
|
| 4 |
+
import threading
|
| 5 |
import unittest
|
| 6 |
from pathlib import Path
|
| 7 |
from unittest.mock import patch
|
| 8 |
|
| 9 |
+
from lumi_eggcracker.containment import EmptyProof
|
| 10 |
+
from lumi_eggcracker.jsonio import JsonInputError
|
| 11 |
+
from lumi_eggcracker.records import RUN_SCHEMA, command_summary
|
| 12 |
+
from lumi_eggcracker.supervisor import Supervisor
|
| 13 |
|
| 14 |
|
| 15 |
def record() -> dict[str, object]:
|
| 16 |
run_id = "a" * 24
|
| 17 |
+
return {**command_summary(["/bin/true"]), "boot_id": "b" * 36, "cgroup": f"/system.slice/lumi-eggcracker-workload-{run_id}.service", "cgroup_device": 1, "cgroup_inode": 2, "created_monotonic_ns": 3, "max_pids": 8, "name": "demo", "operator_uid": 1001, "run_id": run_id, "schema_version": RUN_SCHEMA, "state": "RUNNING", "unit": f"lumi-eggcracker-workload-{run_id}.service", "workload_gid": 2001, "workload_uid": 2001}
|
| 18 |
|
| 19 |
|
| 20 |
class SupervisorTests(unittest.TestCase):
|
| 21 |
def _instance(self) -> Supervisor:
|
| 22 |
value = object.__new__(Supervisor)
|
| 23 |
+
value.policy = {"source_commit": "c" * 40, "workload_uid": 2001}
|
| 24 |
value.runs = Path(".")
|
| 25 |
+
value.names = Path(".")
|
| 26 |
value.receipts = Path(".")
|
| 27 |
value.locks = {}
|
| 28 |
+
value.lock_guard = threading.Lock()
|
| 29 |
+
value.start_lock = threading.Lock()
|
| 30 |
value.completed = {}
|
| 31 |
value.operations = []
|
| 32 |
return value
|
| 33 |
|
| 34 |
+
def test_containment_orders_direct_kill_before_receipt_state_and_cleanup(self) -> None:
|
| 35 |
supervisor = self._instance()
|
| 36 |
+
response = {"result": "TERMINATED", "cleanup": {"attempted": False}}
|
| 37 |
+
with patch("lumi_eggcracker.supervisor.validate_identity", return_value=Path("/owned")), patch("lumi_eggcracker.supervisor.kill_path", return_value=(11, 12)), patch("lumi_eggcracker.supervisor.verify_empty", return_value=(13, EmptyProof(True, 1, 0, []))), patch.object(supervisor, "_cleanup", side_effect=lambda _: supervisor.operations.append("cleanup") or {}), patch("lumi_eggcracker.supervisor.make_receipt", return_value=response), patch("lumi_eggcracker.supervisor.write_atomic"), patch.object(supervisor, "_store", side_effect=lambda *_: supervisor.operations.append("durable-state")):
|
|
|
|
| 38 |
result = supervisor._contain(record(), "OPERATOR", 10)
|
| 39 |
self.assertEqual("TERMINATED", result["result"])
|
| 40 |
self.assertEqual("cgroup.kill", supervisor.operations[0])
|
| 41 |
self.assertLess(supervisor.operations.index("cgroup.kill"), supervisor.operations.index("durable-receipt"))
|
| 42 |
+
self.assertLess(supervisor.operations.index("durable-receipt"), supervisor.operations.index("cleanup"))
|
|
|
|
| 43 |
|
| 44 |
+
def test_exact_empty_cgroup_allows_normal_completion_without_systemctl(self) -> None:
|
| 45 |
supervisor = self._instance()
|
| 46 |
saved: list[dict[str, object]] = []
|
| 47 |
+
with patch("lumi_eggcracker.supervisor.events_from_fd", return_value={"populated": 0}), patch.object(supervisor, "_store", side_effect=lambda value: saved.append(value.copy())):
|
| 48 |
+
self.assertTrue(supervisor._complete_allowed(record(), 42))
|
| 49 |
self.assertEqual("COMPLETED_ALLOWED", saved[0]["state"])
|
| 50 |
+
|
| 51 |
+
def test_status_is_read_only(self) -> None:
|
| 52 |
+
supervisor = self._instance()
|
| 53 |
+
item = record()
|
| 54 |
+
with patch.object(supervisor, "_load", return_value=item), patch.object(supervisor, "_store") as stored:
|
| 55 |
+
value = supervisor.handle({"action": "status", "args": {"name": "demo"}})
|
| 56 |
+
self.assertEqual("RUNNING", value["state"])
|
| 57 |
+
stored.assert_not_called()
|
| 58 |
+
|
| 59 |
+
def test_status_resolves_the_latest_terminal_run_after_name_reuse_is_enabled(self) -> None:
|
| 60 |
+
supervisor = self._instance()
|
| 61 |
+
item = record(); item["state"] = "COMPLETED_ALLOWED"
|
| 62 |
+
with patch.object(supervisor, "_load", side_effect=JsonInputError("gone")), patch.object(supervisor, "_latest_by_name", return_value=item), patch.object(supervisor, "_store") as stored:
|
| 63 |
+
value = supervisor.handle({"action": "status", "args": {"name": "demo"}})
|
| 64 |
+
self.assertEqual("COMPLETED_ALLOWED", value["state"])
|
| 65 |
+
stored.assert_not_called()
|
| 66 |
+
|
| 67 |
+
def test_run_records_are_keyed_by_run_id_and_name_pointer_is_removed_when_terminal(self) -> None:
|
| 68 |
+
supervisor = self._instance()
|
| 69 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 70 |
+
root = Path(raw)
|
| 71 |
+
supervisor.runs = root / "runs"; supervisor.names = root / "names"
|
| 72 |
+
item = record()
|
| 73 |
+
supervisor._store(item)
|
| 74 |
+
self.assertTrue((supervisor.runs / ("a" * 24 + ".json")).is_file())
|
| 75 |
+
self.assertTrue((supervisor.names / "demo.json").is_file())
|
| 76 |
+
item["state"] = "TERMINATED"
|
| 77 |
+
supervisor._store(item)
|
| 78 |
+
self.assertFalse((supervisor.names / "demo.json").exists())
|