@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.
Files changed (59) hide show
  1. package/README.md +13 -4
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/verify-controls.py +5 -5
  4. package/deploy/verify-features.py +35 -2
  5. package/deploy/verify-jobs.py +264 -0
  6. package/deploy/verify-limits.py +20 -2
  7. package/deploy/verify-live.py +12 -3
  8. package/deploy/verify-output.py +208 -0
  9. package/deploy/verify-release.py +33 -4
  10. package/deploy/verify-run.py +571 -0
  11. package/deploy/verify-self-update.py +9 -1
  12. package/deploy/verify-sync.py +122 -0
  13. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  14. package/docs/boat-capabilities.md +60 -7
  15. package/docs/boat-parity-design.md +1 -0
  16. package/docs/boot-performance.mdx +21 -0
  17. package/docs/contract.md +76 -2
  18. package/docs/first-box.mdx +22 -19
  19. package/docs/guides/background-jobs.mdx +38 -31
  20. package/docs/guides/backup-recovery.mdx +3 -2
  21. package/docs/guides/ci-jobs.mdx +209 -0
  22. package/docs/guides/files.mdx +2 -2
  23. package/docs/guides/index.mdx +31 -0
  24. package/docs/guides/long-running-services.mdx +1 -1
  25. package/docs/guides/meta.json +7 -2
  26. package/docs/guides/project-setup.mdx +1 -1
  27. package/docs/guides/scripting-ci.mdx +28 -9
  28. package/docs/guides/snapshots-forks.mdx +3 -1
  29. package/docs/guides/upgrades.mdx +7 -7
  30. package/docs/index.mdx +26 -28
  31. package/docs/maintainers/index.mdx +18 -0
  32. package/docs/maintainers/meta.json +12 -0
  33. package/docs/meta.json +5 -9
  34. package/docs/operations.mdx +7 -0
  35. package/docs/publishing.md +14 -4
  36. package/docs/reference/capacity.mdx +2 -1
  37. package/docs/reference/cli.md +85 -19
  38. package/docs/reference/meta.json +3 -1
  39. package/docs/releases/0.2.0.md +1 -0
  40. package/docs/releases/0.2.1.md +1 -0
  41. package/docs/releases/0.3.0.md +1 -0
  42. package/docs/releases/0.4.0.md +1 -0
  43. package/docs/releases/0.5.0.md +1 -0
  44. package/docs/releases/0.6.0.md +1 -0
  45. package/docs/releases/0.7.0.md +23 -0
  46. package/docs/releases/index.mdx +21 -0
  47. package/docs/releases/meta.json +15 -0
  48. package/docs/requirements.mdx +1 -1
  49. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  50. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  51. package/docs/site-development.md +1 -0
  52. package/docs/verification.mdx +21 -3
  53. package/package.json +3 -1
  54. package/raft.py +380 -179
  55. package/raft_files.py +13 -1
  56. package/raft_jobs.py +390 -0
  57. package/raft_output.py +166 -0
  58. package/raft_run.py +330 -0
  59. 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`, `--memory` and `--disk` take any whole number
73
- up to the host. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
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), and
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 |
@@ -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", "/tmp/raft-build.sh", "755"),
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 /tmp/raft-build.sh")
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 "ActiveState=active" in live.raft("status", box, job).stdout
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 "ActiveState=active" in live.raft("status", box, job).stdout
99
- # Exercise journal retention without changing any host service or other box.
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 "ActiveState=active" in live.raft("status", box, job).stdout
101
+ assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
102
102
  live.raft("cancel", box, job)
103
- assert "ActiveState=inactive" in live.raft("status", box, job).stdout
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(json.dumps({location: settings(location)}))
698
- response = live.raft("doctor", check=False, env={**os.environ, "HOME": str(home)})
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)
@@ -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.execute(box, "systemctl", "restart", "docker")
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"]
@@ -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
- time.sleep(2)
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 "ActiveState=active" in raft("status", box, server).stdout
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