@microck/raft 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/deploy/build-image.py +4 -2
- package/deploy/verify-controls.py +5 -5
- package/deploy/verify-features.py +35 -2
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +20 -2
- package/deploy/verify-live.py +12 -3
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +76 -2
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +3 -2
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +1 -1
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +28 -9
- package/docs/guides/snapshots-forks.mdx +3 -1
- package/docs/guides/upgrades.mdx +7 -7
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +7 -0
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +85 -19
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +1 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +21 -3
- package/package.json +3 -1
- package/raft.py +380 -179
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- package/skills/raft-cli/SKILL.md +78 -197
package/README.md
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
control, with command execution, Docker, file transfer, snapshots, forks and a
|
|
17
17
|
private desktop. Raft is not affiliated with Boat.
|
|
18
18
|
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
https://github.com/user-attachments/assets/ad6b0e67-1e33-4e36-a51b-401f29901eae
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
19
25
|
[setup](docs/operations.mdx) | [feature parity](docs/boat-capabilities.md) | [verification](docs/verification.mdx) | [agent skill](skills/raft-cli/SKILL.md) | [docs site](https://raft.micr.dev/)
|
|
20
26
|
|
|
21
27
|
## quickstart
|
|
@@ -55,7 +61,7 @@ once your host and image are configured, run the commands below. replace `lab`
|
|
|
55
61
|
with a location from your configuration:
|
|
56
62
|
|
|
57
63
|
```sh
|
|
58
|
-
box=$(raft new --location lab --ttl 600)
|
|
64
|
+
box=$(raft new --location lab --ttl 600 --persistent --purpose "Explore Raft" --owner "local operator" --next "Destroy after exploring")
|
|
59
65
|
raft exec "$box" -- node --version
|
|
60
66
|
raft ssh "$box"
|
|
61
67
|
raft stop "$box"
|
|
@@ -69,8 +75,9 @@ stopped for 48 hours, and logs each deletion in its journal. disposable
|
|
|
69
75
|
creation, prune and retention require the current host worker; redeploy from
|
|
70
76
|
the current checkout before using them.
|
|
71
77
|
|
|
72
|
-
size boxes for the job: `--cpu
|
|
73
|
-
|
|
78
|
+
size boxes for the job: `--cpu` takes a positive whole CPU count and
|
|
79
|
+
`--memory` takes whole GiB, each up to the host total. `--disk` takes whole
|
|
80
|
+
GiB bounded by the own-disk pool reservation, or `shared`. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
|
|
74
81
|
box its own disk that it, and Docker inside it, cannot grow past; forks and
|
|
75
82
|
templates of it are copy-on-write. raft does not cap how many boxes you keep.
|
|
76
83
|
`raft capacity` prints an advisory estimate of what fits; it never blocks a box.
|
|
@@ -102,7 +109,8 @@ and [Docker](docs/guides/docker.mdx).
|
|
|
102
109
|
|
|
103
110
|
See [project setup and templates](docs/guides/project-setup.mdx),
|
|
104
111
|
[persistent services](docs/guides/long-running-services.mdx),
|
|
105
|
-
[scripting and CI](docs/guides/scripting-ci.mdx),
|
|
112
|
+
[scripting and CI](docs/guides/scripting-ci.mdx),
|
|
113
|
+
[disposable CI jobs](docs/guides/ci-jobs.mdx), and
|
|
106
114
|
[upgrades](docs/guides/upgrades.mdx) for complete operator workflows.
|
|
107
115
|
|
|
108
116
|
stop the source box before taking a snapshot, restoring one or making a fork.
|
|
@@ -150,6 +158,7 @@ records differences, sources and test coverage.
|
|
|
150
158
|
| CPU/memory sizing and usage | ✅ | ✅ | Any whole CPU count and GiB size up to the host. Usage covers the shared pool or the box's own disk |
|
|
151
159
|
| Host capacity estimates | ❌ | ✅ | Advisory only; Raft has no box count limit. Boat reports plan limits |
|
|
152
160
|
| Root terminal and command execution | ✅ | ✅ | Host SSH transport |
|
|
161
|
+
| Disposable foreground CI jobs | ✅ | Preview | Unreleased 0.7.0 `raft run`: fixed timeout, Git-aware upload, artifact collection and guarded cleanup; absent from published 0.6.0 |
|
|
153
162
|
| Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
|
|
154
163
|
| File upload/download | ✅ | ✅ | Files and recursive trees; new directory destinations only |
|
|
155
164
|
| Snapshots and same-host forks | ✅ | ✅ | Stopped source, checked under the lifecycle lock |
|
package/deploy/build-image.py
CHANGED
|
@@ -41,15 +41,17 @@ def build(host, alias):
|
|
|
41
41
|
host,
|
|
42
42
|
prefix + " launch " + fingerprint + " raft-builder -c limits.cpu=2 -c limits.memory=4GiB",
|
|
43
43
|
)
|
|
44
|
+
# Stage outside /tmp: its boot-time tmpfs mount can hide an early file push.
|
|
44
45
|
for source, guest, mode in [
|
|
45
|
-
("images/incus/build.sh", "/
|
|
46
|
+
("images/incus/build.sh", "/root/raft-build.sh", "755"),
|
|
46
47
|
("images/incus/desktop.sh", "/usr/local/bin/raft-desktop", "755"),
|
|
47
48
|
("images/incus/raft-desktop.service", "/etc/systemd/system/raft-desktop.service", "644"),
|
|
48
49
|
]:
|
|
49
50
|
staged = "/tmp/" + Path(guest).name
|
|
50
51
|
copy(host, ROOT / source, staged)
|
|
51
52
|
remote(host, prefix + f" file push {staged} raft-builder{guest} --mode {mode}")
|
|
52
|
-
remote(host, prefix + " exec raft-builder -- bash /
|
|
53
|
+
remote(host, prefix + " exec raft-builder -- bash /root/raft-build.sh")
|
|
54
|
+
remote(host, prefix + " exec raft-builder -- rm /root/raft-build.sh")
|
|
53
55
|
remote(host, prefix + " stop raft-builder --timeout 30")
|
|
54
56
|
print(
|
|
55
57
|
remote(
|
|
@@ -88,19 +88,19 @@ def verify(source, target, directory, development_image=False):
|
|
|
88
88
|
raise RuntimeError("Detached job did not start")
|
|
89
89
|
time.sleep(0.2)
|
|
90
90
|
child = live.execute(box, "cat", "/workspace/child-pid")
|
|
91
|
-
assert
|
|
91
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
92
92
|
live.raft("extend", box, "--ttl", "1200")
|
|
93
93
|
extended = json.loads(live.raft("info", box).stdout)
|
|
94
94
|
assert (
|
|
95
95
|
int(extended["config"]["user.raft.expires"])
|
|
96
96
|
> int(live.execute(box, "date", "+%s")) + 1100
|
|
97
97
|
)
|
|
98
|
-
assert
|
|
99
|
-
#
|
|
98
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
99
|
+
# Guest journal rotation cannot erase host-retained job state.
|
|
100
100
|
live.execute(box, "journalctl", "--rotate", "--vacuum-size=1")
|
|
101
|
-
assert
|
|
101
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
102
102
|
live.raft("cancel", box, job)
|
|
103
|
-
assert
|
|
103
|
+
assert live.wait_job(box, job)["state"] == "cancelled"
|
|
104
104
|
assert live.raft("logs", box, job).returncode == 0
|
|
105
105
|
assert (
|
|
106
106
|
live.raft("exec", box, "--", "test", "-e", "/proc/" + child, check=False).returncode
|
|
@@ -32,6 +32,8 @@ from raft import ( # noqa: E402
|
|
|
32
32
|
RETENTION_SINCE_KEY,
|
|
33
33
|
disk_origin,
|
|
34
34
|
host_problems,
|
|
35
|
+
host_inventory,
|
|
36
|
+
host_policy,
|
|
35
37
|
incus,
|
|
36
38
|
inventory,
|
|
37
39
|
parse_handle,
|
|
@@ -694,8 +696,12 @@ def verify_readiness(location, expect_stale=False):
|
|
|
694
696
|
(home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
|
|
695
697
|
config = home / ".config/raft/incus.json"
|
|
696
698
|
config.parent.mkdir(parents=True)
|
|
697
|
-
config.write_text(
|
|
698
|
-
|
|
699
|
+
config.write_text(
|
|
700
|
+
json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
|
|
701
|
+
)
|
|
702
|
+
response = live.raft(
|
|
703
|
+
"doctor", "--location", location, check=False, env={**os.environ, "HOME": str(home)}
|
|
704
|
+
)
|
|
699
705
|
assert location + ": " in response.stdout, response
|
|
700
706
|
if expect_stale:
|
|
701
707
|
assert response.returncode == 1, response
|
|
@@ -709,6 +715,32 @@ def verify_readiness(location, expect_stale=False):
|
|
|
709
715
|
print(f"{location}: doctor {'rejected stale worker' if expect_stale else 'passed'}", flush=True)
|
|
710
716
|
|
|
711
717
|
|
|
718
|
+
def verify_read_reports(location):
|
|
719
|
+
"""Compare batched reads with independent native reads on a quiescent host."""
|
|
720
|
+
policy, boxes = host_inventory(location)
|
|
721
|
+
assert policy == host_policy(location)
|
|
722
|
+
assert sorted(boxes, key=lambda box: box["name"]) == sorted(
|
|
723
|
+
inventory(location), key=lambda box: box["name"]
|
|
724
|
+
)
|
|
725
|
+
with tempfile.TemporaryDirectory(prefix="raft-config-test-") as work:
|
|
726
|
+
home = Path(work)
|
|
727
|
+
(home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
|
|
728
|
+
config = home / ".config/raft/incus.json"
|
|
729
|
+
config.parent.mkdir(parents=True)
|
|
730
|
+
config.write_text(
|
|
731
|
+
json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
|
|
732
|
+
)
|
|
733
|
+
env = {**os.environ, "HOME": str(home), "RAFT_NO_UPDATE_CHECK": "1"}
|
|
734
|
+
report = live.raft("config", "--location", location, env=env)
|
|
735
|
+
assert json.loads(report.stdout)["policy"] == {location: policy}
|
|
736
|
+
rejected = live.raft(
|
|
737
|
+
"config", "--location", location, "set", "cpu", "2", env=env, check=False
|
|
738
|
+
)
|
|
739
|
+
assert rejected.returncode == 1 and "controller-wide" in rejected.stderr
|
|
740
|
+
assert not (config.parent / "config.json").exists()
|
|
741
|
+
print(f"{location}: batched metadata and offline-host isolation passed", flush=True)
|
|
742
|
+
|
|
743
|
+
|
|
712
744
|
def verify_transaction_stdin(location):
|
|
713
745
|
program = """import sys, tempfile
|
|
714
746
|
from raft import transaction
|
|
@@ -1276,4 +1308,5 @@ if __name__ == "__main__":
|
|
|
1276
1308
|
verify_host_preflight()
|
|
1277
1309
|
verify_archives()
|
|
1278
1310
|
if args.location:
|
|
1311
|
+
verify_read_reports(args.location)
|
|
1279
1312
|
verify(args.location, args.isolated_worker)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Real process/store checks and optional disposable-host retained-job verification."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
sys.path.insert(0, str(ROOT))
|
|
14
|
+
import raft_jobs as jobs # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stored(job, *, state="completed", finished=None):
|
|
18
|
+
return {
|
|
19
|
+
"job": job,
|
|
20
|
+
"box": "rf-" + "a" * 16,
|
|
21
|
+
"state": state,
|
|
22
|
+
"exitCode": 7,
|
|
23
|
+
"finishedAt": int(time.time()) if finished is None else finished,
|
|
24
|
+
"bootId": "old-boot",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify():
|
|
29
|
+
with tempfile.TemporaryDirectory(prefix="raft-jobs-") as directory:
|
|
30
|
+
root = Path(directory)
|
|
31
|
+
with jobs.private_store(root):
|
|
32
|
+
pass
|
|
33
|
+
status, dropped = jobs.capture(
|
|
34
|
+
root,
|
|
35
|
+
[
|
|
36
|
+
sys.executable,
|
|
37
|
+
"-c",
|
|
38
|
+
"import os,sys; os.write(1,b'x'*(2*1024*1024)); os.write(2,b'err'); sys.exit(7)",
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
assert status == 7 and dropped == jobs.LOG_LIMIT + 3
|
|
42
|
+
assert (root / "output").read_bytes() == b"x" * jobs.LOG_LIMIT
|
|
43
|
+
(root / "output").unlink()
|
|
44
|
+
# A real file-size limit fails the first record write before any service launch.
|
|
45
|
+
rejected = subprocess.run(
|
|
46
|
+
[
|
|
47
|
+
sys.executable,
|
|
48
|
+
"-c",
|
|
49
|
+
"import resource,signal,sys; from pathlib import Path; import raft_jobs; "
|
|
50
|
+
"signal.signal(signal.SIGXFSZ, signal.SIG_IGN); "
|
|
51
|
+
"resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)); "
|
|
52
|
+
"raft_jobs.start(Path(sys.argv[1]), 'rf-'+'a'*16, 'rfcmd-'+'d'*32, ['true'], '')",
|
|
53
|
+
str(root),
|
|
54
|
+
],
|
|
55
|
+
cwd=ROOT,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
)
|
|
59
|
+
assert rejected.returncode != 0 and "File too large" in rejected.stderr
|
|
60
|
+
assert not list(root.iterdir()), "Failed allocation consumed a record slot"
|
|
61
|
+
old = root / ("rfcmd-" + "a" * 32)
|
|
62
|
+
old.mkdir()
|
|
63
|
+
jobs.save(old, stored(old.name, finished=int(time.time()) - jobs.RETENTION - 1))
|
|
64
|
+
current = root / ("rfcmd-" + "b" * 32)
|
|
65
|
+
current.mkdir()
|
|
66
|
+
jobs.save(current, stored(current.name))
|
|
67
|
+
interrupted = root / ("rfcmd-" + "c" * 32)
|
|
68
|
+
interrupted.mkdir()
|
|
69
|
+
jobs.save(interrupted, stored(interrupted.name, state="running"))
|
|
70
|
+
incomplete = root / ("rfcmd-" + "e" * 32)
|
|
71
|
+
incomplete.mkdir()
|
|
72
|
+
damaged = root / ("rfcmd-" + "f" * 32)
|
|
73
|
+
damaged.mkdir()
|
|
74
|
+
(damaged / "result.json").write_text('{"state":')
|
|
75
|
+
(damaged / "output").write_bytes(b"retained evidence")
|
|
76
|
+
jobs.prune(root)
|
|
77
|
+
assert incomplete.exists() and (damaged / "output").read_bytes() == b"retained evidence"
|
|
78
|
+
for unreadable in (incomplete, damaged):
|
|
79
|
+
try:
|
|
80
|
+
jobs.load(unreadable)
|
|
81
|
+
except ValueError as error:
|
|
82
|
+
assert str(unreadable / "result.json") in str(error)
|
|
83
|
+
else:
|
|
84
|
+
raise AssertionError("Unreadable record accepted")
|
|
85
|
+
inspected = subprocess.run(
|
|
86
|
+
[
|
|
87
|
+
sys.executable,
|
|
88
|
+
"-c",
|
|
89
|
+
"from pathlib import Path; import sys,raft_jobs; "
|
|
90
|
+
"root=Path(sys.argv.pop(1)); raft_jobs.main(root)",
|
|
91
|
+
str(root),
|
|
92
|
+
"status",
|
|
93
|
+
"rf-" + "a" * 16,
|
|
94
|
+
current.name,
|
|
95
|
+
],
|
|
96
|
+
cwd=ROOT,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
check=True,
|
|
100
|
+
)
|
|
101
|
+
assert json.loads(inspected.stdout)["exitCode"] == 7
|
|
102
|
+
for malformed in (None, {}, {**stored(damaged.name), "finishedAt": "invalid"}):
|
|
103
|
+
(damaged / "result.json").write_text(json.dumps(malformed))
|
|
104
|
+
jobs.prune(root)
|
|
105
|
+
assert jobs.load(current)["exitCode"] == 7
|
|
106
|
+
try:
|
|
107
|
+
jobs.load(damaged)
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
else:
|
|
111
|
+
raise AssertionError("Malformed record accepted")
|
|
112
|
+
assert not old.exists() and current.exists()
|
|
113
|
+
assert jobs.load(interrupted)["state"] == "interrupted"
|
|
114
|
+
assert jobs.load(interrupted)["exitCode"] is None
|
|
115
|
+
root.chmod(0o755)
|
|
116
|
+
try:
|
|
117
|
+
with jobs.private_store(root):
|
|
118
|
+
raise AssertionError("World-readable job store accepted")
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
finally:
|
|
122
|
+
root.chmod(0o700)
|
|
123
|
+
for index in range(jobs.RECORD_LIMIT - len(list(root.iterdir()))):
|
|
124
|
+
(root / f"slot-{index}").mkdir()
|
|
125
|
+
try:
|
|
126
|
+
jobs.start(root, "rf-" + "a" * 16, "rfcmd-" + "d" * 32, ["true"], "")
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
assert "full" in str(error)
|
|
129
|
+
else:
|
|
130
|
+
raise AssertionError("Full store accepted a new job")
|
|
131
|
+
print(
|
|
132
|
+
"Real process capture, damaged-record isolation, retention, reboot interruption and store limits passed"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def native(location):
|
|
137
|
+
spec = importlib.util.spec_from_file_location("live", ROOT / "deploy/verify-live.py")
|
|
138
|
+
live = importlib.util.module_from_spec(spec)
|
|
139
|
+
spec.loader.exec_module(live)
|
|
140
|
+
box = live.raft(
|
|
141
|
+
"new",
|
|
142
|
+
"--location",
|
|
143
|
+
location,
|
|
144
|
+
"--persistent",
|
|
145
|
+
"--ttl",
|
|
146
|
+
"600",
|
|
147
|
+
"--purpose",
|
|
148
|
+
"Retained job verification",
|
|
149
|
+
"--owner",
|
|
150
|
+
"verify-jobs",
|
|
151
|
+
).stdout.strip()
|
|
152
|
+
destroyed = False
|
|
153
|
+
try:
|
|
154
|
+
deadline = time.monotonic() + 40
|
|
155
|
+
while live.raft(
|
|
156
|
+
"exec", box, "--", "systemctl", "show", "-p", "Version", check=False
|
|
157
|
+
).returncode:
|
|
158
|
+
assert time.monotonic() < deadline, "Guest systemd did not become ready"
|
|
159
|
+
time.sleep(0.2)
|
|
160
|
+
completed = live.raft(
|
|
161
|
+
"exec", box, "--detach", "--", "sh", "-c", "printf retained-output; exit 23"
|
|
162
|
+
).stdout.strip()
|
|
163
|
+
record = live.wait_job(box, completed)
|
|
164
|
+
output = live.raft("logs", box, completed).stdout
|
|
165
|
+
if record["exitCode"] != 23 or record["state"] != "completed":
|
|
166
|
+
unit = live.raft("exec", box, "--", "systemctl", "show", completed, check=False)
|
|
167
|
+
raise AssertionError(
|
|
168
|
+
{"record": record, "output": output, "unit": unit.stdout, "error": unit.stderr}
|
|
169
|
+
)
|
|
170
|
+
assert output == "retained-output"
|
|
171
|
+
for signum in (1, 2, 13, 15):
|
|
172
|
+
signalled = live.raft(
|
|
173
|
+
"exec",
|
|
174
|
+
box,
|
|
175
|
+
"--detach",
|
|
176
|
+
"--",
|
|
177
|
+
"python3",
|
|
178
|
+
"-c",
|
|
179
|
+
f"import os,signal; signal.signal({signum},signal.SIG_DFL); "
|
|
180
|
+
f"os.kill(os.getpid(),{signum})",
|
|
181
|
+
).stdout.strip()
|
|
182
|
+
record = live.wait_job(box, signalled)
|
|
183
|
+
assert record["state"] == "completed" and record["exitCode"] == 128 + signum, (
|
|
184
|
+
record,
|
|
185
|
+
live.raft("logs", box, signalled).stdout,
|
|
186
|
+
)
|
|
187
|
+
active = live.raft(
|
|
188
|
+
"exec", box, "--detach", "--", "sh", "-c", "echo ready; sleep 600"
|
|
189
|
+
).stdout.strip()
|
|
190
|
+
deadline = time.monotonic() + 20
|
|
191
|
+
while "ready" not in live.raft("logs", box, active).stdout:
|
|
192
|
+
assert time.monotonic() < deadline
|
|
193
|
+
time.sleep(0.2)
|
|
194
|
+
live.raft("cancel", box, active)
|
|
195
|
+
record = live.wait_job(box, active)
|
|
196
|
+
assert record["state"] == "cancelled", (record, live.raft("logs", box, active).stdout)
|
|
197
|
+
orphan = live.raft(
|
|
198
|
+
"exec", box, "--detach", "--", "sh", "-c", "echo orphan-ready; exec sleep 600"
|
|
199
|
+
).stdout.strip()
|
|
200
|
+
deadline = time.monotonic() + 20
|
|
201
|
+
while "orphan-ready" not in live.raft("logs", box, orphan).stdout:
|
|
202
|
+
assert time.monotonic() < deadline, "Orphan test payload did not start"
|
|
203
|
+
time.sleep(0.2)
|
|
204
|
+
live.transaction(location, 'systemctl kill --signal=SIGKILL "$1"', "raft-" + orphan)
|
|
205
|
+
record = live.wait_job(box, orphan)
|
|
206
|
+
assert record["state"] == "interrupted", record
|
|
207
|
+
assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) > 0
|
|
208
|
+
live.raft("cancel", box, orphan)
|
|
209
|
+
record = json.loads(live.raft("status", box, orphan).stdout)
|
|
210
|
+
assert record["state"] == "cancelled" and record["exitCode"] is None, record
|
|
211
|
+
assert record["finishedAt"] is not None
|
|
212
|
+
# ExecMainPID retains the historical PID after exit; MainPID tracks the live process.
|
|
213
|
+
assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) == 0
|
|
214
|
+
live.raft("cancel", box, orphan)
|
|
215
|
+
assert json.loads(live.raft("status", box, orphan).stdout) == record
|
|
216
|
+
interrupted = live.raft("exec", box, "--detach", "--", "sleep", "600").stdout.strip()
|
|
217
|
+
zero_on_stop = live.raft(
|
|
218
|
+
"exec",
|
|
219
|
+
box,
|
|
220
|
+
"--detach",
|
|
221
|
+
"--",
|
|
222
|
+
"python3",
|
|
223
|
+
"-u",
|
|
224
|
+
"-c",
|
|
225
|
+
"import signal,sys,time; signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)); "
|
|
226
|
+
"print('shutdown-handler-ready'); time.sleep(600)",
|
|
227
|
+
).stdout.strip()
|
|
228
|
+
deadline = time.monotonic() + 20
|
|
229
|
+
while "shutdown-handler-ready" not in live.raft("logs", box, zero_on_stop).stdout:
|
|
230
|
+
assert time.monotonic() < deadline, "Shutdown handler did not start"
|
|
231
|
+
time.sleep(0.2)
|
|
232
|
+
live.raft("stop", box)
|
|
233
|
+
for stopped_job in (interrupted, zero_on_stop):
|
|
234
|
+
record = live.wait_job(box, stopped_job)
|
|
235
|
+
assert record["state"] == "interrupted" and record["exitCode"] is None, (
|
|
236
|
+
record,
|
|
237
|
+
live.raft("logs", box, stopped_job).stdout,
|
|
238
|
+
)
|
|
239
|
+
assert live.raft("logs", box, completed).stdout == "retained-output"
|
|
240
|
+
count_records = "find /var/lib/raft-jobs -mindepth 1 -maxdepth 1 -type d | wc -l"
|
|
241
|
+
before = live.transaction(location, count_records)
|
|
242
|
+
refused = live.raft("exec", box, "--detach", "--", "true", check=False)
|
|
243
|
+
assert refused.returncode == 1 and not refused.stdout, refused
|
|
244
|
+
assert "no job was created" in refused.stderr, refused.stderr
|
|
245
|
+
assert live.transaction(location, count_records) == before
|
|
246
|
+
live.raft("destroy", box)
|
|
247
|
+
destroyed = True
|
|
248
|
+
assert json.loads(live.raft("status", box, completed).stdout)["exitCode"] == 23
|
|
249
|
+
assert live.raft("logs", box, completed).stdout == "retained-output"
|
|
250
|
+
wrong = location + ":rf-" + "0" * 16
|
|
251
|
+
assert live.raft("logs", wrong, completed, check=False).returncode == 1
|
|
252
|
+
print(f"{location}: completed, cancelled and interrupted results survive stop and deletion")
|
|
253
|
+
finally:
|
|
254
|
+
if not destroyed:
|
|
255
|
+
live.raft("destroy", box)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
260
|
+
cli.add_argument("--location")
|
|
261
|
+
args = cli.parse_args()
|
|
262
|
+
verify()
|
|
263
|
+
if args.location:
|
|
264
|
+
native(args.location)
|
package/deploy/verify-limits.py
CHANGED
|
@@ -299,8 +299,26 @@ def disk_checks(location):
|
|
|
299
299
|
time.sleep(2)
|
|
300
300
|
assert disk_size(box)[0] == total, disk_size(box)
|
|
301
301
|
# Removing the container frees the space; restart Docker in case it lost state.
|
|
302
|
-
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
303
|
-
live.
|
|
302
|
+
removed = live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
303
|
+
restarted = live.raft("exec", box, "--", "systemctl", "restart", "docker", check=False)
|
|
304
|
+
if restarted.returncode:
|
|
305
|
+
print(
|
|
306
|
+
"Docker removal after disk exhaustion:", removed.stdout, removed.stderr, flush=True
|
|
307
|
+
)
|
|
308
|
+
journal = live.raft(
|
|
309
|
+
"exec",
|
|
310
|
+
box,
|
|
311
|
+
"--",
|
|
312
|
+
"journalctl",
|
|
313
|
+
"--no-pager",
|
|
314
|
+
"-n",
|
|
315
|
+
"100",
|
|
316
|
+
"-u",
|
|
317
|
+
"docker",
|
|
318
|
+
check=False,
|
|
319
|
+
)
|
|
320
|
+
print(journal.stdout, journal.stderr, flush=True)
|
|
321
|
+
raise RuntimeError("Docker restart after disk exhaustion failed: " + restarted.stderr)
|
|
304
322
|
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
305
323
|
assert disk_size(box)[1] > GIB, disk_size(box)
|
|
306
324
|
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
package/deploy/verify-live.py
CHANGED
|
@@ -57,6 +57,16 @@ def wait_running(box):
|
|
|
57
57
|
raise RuntimeError("Guest Docker and SSH did not become ready")
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def wait_job(box, job):
|
|
61
|
+
deadline = time.monotonic() + 30
|
|
62
|
+
while time.monotonic() < deadline:
|
|
63
|
+
record = json.loads(raft("status", box, job).stdout)
|
|
64
|
+
if record["state"] in {"completed", "cancelled", "interrupted", "failed"}:
|
|
65
|
+
return record
|
|
66
|
+
time.sleep(0.2)
|
|
67
|
+
raise RuntimeError("Detached job did not finish: " + job)
|
|
68
|
+
|
|
69
|
+
|
|
60
70
|
def terminal_session(box):
|
|
61
71
|
master, slave = pty.openpty()
|
|
62
72
|
process = subprocess.Popen([*CLI, "ssh", box], stdin=slave, stdout=slave, stderr=slave)
|
|
@@ -796,8 +806,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
796
806
|
job = raft(
|
|
797
807
|
"exec", box, "--detach", "--", "bash", "-lc", "echo detached-ok; exit 23"
|
|
798
808
|
).stdout.strip()
|
|
799
|
-
|
|
800
|
-
assert "ExecMainStatus=23" in raft("status", box, job).stdout
|
|
809
|
+
assert wait_job(box, job)["exitCode"] == 23
|
|
801
810
|
assert "detached-ok" in raft("logs", box, job).stdout
|
|
802
811
|
execute(box, "bash", "-lc", "echo saved > /workspace/persistent")
|
|
803
812
|
raft("stop", box)
|
|
@@ -824,7 +833,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
824
833
|
fetch_through_tunnel(["forward", box, "--remote", "8080"], "/persistent").strip()
|
|
825
834
|
== b"saved"
|
|
826
835
|
)
|
|
827
|
-
assert
|
|
836
|
+
assert json.loads(raft("status", box, server).stdout)["state"] == "running"
|
|
828
837
|
assert b"noVNC" in fetch_through_tunnel(["desktop", box], "/vnc.html", timeout=20)
|
|
829
838
|
assert b"noVNC" in fetch_through_tunnel(
|
|
830
839
|
["desktop", box], "/vnc.html", timeout=20, default_port=6080
|