Repository navigation
Expand file tree
/
Copy pathtest_bootstrap.py
More file actions
executable file
·2269 lines (1986 loc) · 103 KB
/
Copy pathtest_bootstrap.py
File metadata and controls
executable file
·2269 lines (1986 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Tests for the host bootstraps: the loader invariant, and that the tooling covers what the spec requires.
Two properties, both of which fail silently rather than loudly if nobody checks them.
The loader invariant is what keeps `host-setup/bootstrap.sh` and `host-setup/bootstrap.ps1` outside
the reach of the `Hub-Hosted Tooling` rule rather than exempt from it. A loader obtains a tree and
hands control to one entry point inside it. The moment it reads a second path in that tree it has
become a tool that reads hub content, and the rule applies to it in full. That boundary is a property
of each file, so it is asserted here rather than promised in prose.
The coverage assertion is the only connection between the floors in `spec/host-tools.json` and the
tooling that installs them. Nothing joins the two at runtime, deliberately: the gate measures a host
and the tooling changes one, and neither calls the other. Without a check at this level a tool could
be declared required and be one nothing here can install, which a host would discover as a gate it
cannot satisfy.
That assertion runs once per platform, because the two installers do not manage the same set and the
difference is a decision rather than an accident. `git-restore-mtime` serves a Linux deploy path and
the spec declares it not applicable on Windows.
Run as `python3 tests/test_bootstrap.py`, or under `python3 -m unittest discover -s tests`.
"""
from __future__ import annotations
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import unittest
from collections.abc import Callable
from pathlib import Path
from typing import TextIO, cast
from host_capability import bash_or_skip
ROOT = Path(__file__).resolve().parent.parent
BOOTSTRAP = ROOT / "host-setup" / "bootstrap.sh"
BOOTSTRAP_PS = ROOT / "host-setup" / "bootstrap.ps1"
MENU = ROOT / "host-setup" / "menu.sh"
LINUX = ROOT / "host-setup" / "linux"
WINDOWS = ROOT / "host-setup" / "windows"
HOST_TOOLS = ROOT / "spec" / "host-tools.json"
# The tools the linux tooling manages, read from the script rather than restated here, so the two cannot drift while both look correct.
TOOLS_DECLARATION = re.compile(r"^readonly TOOLS=\(([^)]*)\)", re.MULTILINE)
# The same, for the windows tooling, whose registry is a list of records rather than a flat array.
# The names are read out of the records themselves rather than from a second list beside them, so there is one declaration to keep true rather than two that can agree wrongly.
PS_TOOLS_OPEN = re.compile(r"^\$TOOLS\s*=\s*@\(", re.MULTILINE)
PS_TOOLS_CLOSE = re.compile(r"^\)", re.MULTILINE)
PS_TOOL_NAME = re.compile(r"^\s*@\{\s*Name\s*=\s*'([^']+)'", re.MULTILINE)
# A spec tool whose name differs from the name the installer knows it by, and why.
ALIASES = {
"linux": {"python3": "python"},
"windows": {"python3": "python"},
}
# A platform a floored tool's remedy deliberately omits, and the reason, so an omission is a decision rather than a hole in the mapping.
REMEDY_NOT_APPLICABLE = {
"git-restore-mtime": {
"windows": "The tool serves a Linux deploy path, which its source states."
},
}
# The remedy commands that hand back into the host-setup installers, read so the tool each names can be checked against what that installer manages.
LINUX_INSTALLER_REMEDY = re.compile(r"^host-setup/linux/install-tools\.sh --upgrade (\S+)$")
WINDOWS_INSTALLER_REMEDY = re.compile(r"^host-setup/windows/install-tools\.ps1 -Upgrade (\S+)$")
RUNS_LINUX_INSTALLER = unittest.skipUnless(
sys.platform == "linux", "executes install-tools.sh, which only a Linux host can run"
)
# A spec tool an installer deliberately does not manage, and the reason, recorded so an omission is a decision somebody made rather than one nobody noticed.
# Both sets are empty, which is itself the assertion: docker installs the same way on a hypervisor and a workstation on both platforms now, and the one case that differs, a WSL distribution, is handled inside install-tools.sh itself (it skips the native install and points at Docker Desktop's own WSL integration) rather than by leaving docker unmanaged on Linux entirely.
NOT_MANAGED: dict[str, dict[str, str]] = {
"linux": {},
"windows": {},
}
def tree_references(path: Path) -> set[str]:
"""The `$TREE/...` references a loader makes, deduplicated.
Every reference to the fetched tree goes through the variable holding its location, so the
paths a loader names are countable rather than scattered.
"""
text = path.read_text(encoding="utf-8")
references = re.findall(r'\$TREE(?:/[^"\'\s]*)?', text)
return {reference for reference in references if "/" in reference}
class TestLoaderInvariant(unittest.TestCase):
"""`bootstrap.sh` and `bootstrap.ps1` each read exactly one path into the fetched tree.
Both loaders write that one reference as a single interpolated, forward-slashed string
(`$TREE/host-setup/<platform>/$tool` in each), rather than building it from parts, which is
what lets one pattern check either file unmodified.
"""
def _assert_reads_one_path(self, path: Path, expected: str) -> None:
self.assertEqual(
{expected},
tree_references(path),
f"{path.name} reads more than its one entry point into the fetched tree",
)
# A payload or a table read from the tree is what makes a file a tool rather than a loader.
text = path.read_text(encoding="utf-8")
for forbidden in ("spec/", "registry/", "repo-config/", "catalog/"):
with self.subTest(forbidden=forbidden):
self.assertNotIn(
f"$TREE/{forbidden}",
text,
f"{path.name} reads {forbidden} from the fetched tree, which makes it a tool",
)
def test_linux_loader_reads_one_path_into_the_tree(self) -> None:
"""`bootstrap.sh` references exactly one directory inside the tree it fetches."""
self._assert_reads_one_path(BOOTSTRAP, "$TREE/host-setup/linux/$tool")
def test_windows_loader_reads_one_path_into_the_tree(self) -> None:
"""`bootstrap.ps1` references exactly one directory inside the tree it fetches.
`$Tool`, PascalCase, because that is the parameter name PowerShell convention wants, where
bash's equivalent is the lowercase local `$tool`. The pattern this shares with the Linux
check is the shape of the path, one interpolated `$TREE/host-setup/<platform>/<name>`
string, not the exact casing.
"""
self._assert_reads_one_path(BOOTSTRAP_PS, "$TREE/host-setup/windows/$Tool")
def _assert_needs_no_python(self, path: Path) -> None:
text = path.read_text(encoding="utf-8")
for interpreter in ("python3 ", "python ", "uv run", "py -3"):
with self.subTest(interpreter=interpreter):
self.assertNotIn(
interpreter,
text,
f"{path.name} invokes {interpreter.strip()}, which a host being bootstrapped may not have",
)
def test_linux_loader_needs_no_python(self) -> None:
"""A host `bootstrap.sh` stands up must not be made to install an interpreter first."""
self._assert_needs_no_python(BOOTSTRAP)
def test_windows_loader_needs_no_python(self) -> None:
"""A host `bootstrap.ps1` stands up must not be made to install an interpreter first."""
self._assert_needs_no_python(BOOTSTRAP_PS)
class TestSpecCoverage(unittest.TestCase):
"""Every tool `spec/host-tools.json` requires is one the platform installers can provide."""
def declared_tools(self) -> set[str]:
"""The tool names `install-tools.sh` manages."""
text = (LINUX / "install-tools.sh").read_text(encoding="utf-8")
match = TOOLS_DECLARATION.search(text)
if match is None:
self.fail("install-tools.sh declares no TOOLS array, so coverage cannot be checked")
return {name.strip() for name in match.group(1).split() if name.strip()}
def declared_windows_tools(self) -> set[str]:
"""The tool names `install-tools.ps1` manages."""
text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
opened = PS_TOOLS_OPEN.search(text)
if opened is None:
self.fail(
"install-tools.ps1 declares no $TOOLS registry, so coverage cannot be checked"
)
# A missing close marker is a failure rather than a scan to end of file.
# Reading on past the registry collects every later `Name = '...'` in the script, so a broken registry answers with a larger tool set than it declares and the coverage check below passes on it.
closed = PS_TOOLS_CLOSE.search(text, opened.end())
if closed is None:
self.fail(
"install-tools.ps1 opens a $TOOLS registry this cannot find the end of, so coverage cannot be checked"
)
body = text[opened.end() : closed.start()]
names = set(PS_TOOL_NAME.findall(body))
if not names:
self.fail(
"install-tools.ps1 declares a $TOOLS registry with no Name fields this can read"
)
return names
def spec_tools(self) -> list[dict]:
"""The tools the spec declares."""
# A malformed or unreadable spec is a finding this reports beside the others, rather than a traceback that ends the run and takes the checks after it with it.
try:
return json.loads(HOST_TOOLS.read_text(encoding="utf-8"))["tools"]
except (OSError, ValueError, KeyError) as error:
self.fail(f"{HOST_TOOLS.name} could not be read as a tool declaration: {error}")
def _assert_coverage(self, platform: str, managed: set[str], installer: str) -> None:
"""A tool the spec requires is one the named installer can provide, or a recorded exception."""
if not managed:
return
for tool in self.spec_tools():
name = tool["name"]
if not tool.get("required", False):
continue
# Every required tool is checked, including one whose declaration names no source for this platform.
# A tool is in scope because the spec requires it, never because its declaration happens to describe where this platform gets it.
# Reading a missing `source.linux` as "not a Linux tool" skipped docker, git and uv, which is half the required set and the whole of what NOT_MANAGED exists to record.
expected = ALIASES[platform].get(name, name)
with self.subTest(tool=name):
if name in NOT_MANAGED[platform]:
self.assertNotIn(
expected,
managed,
f"{name} is recorded as not managed on {platform}, but {installer} manages it, so the record is stale",
)
continue
self.assertIn(
expected,
managed,
f"the spec requires {name} on {platform} and {installer} does not manage it, "
f"so a host cannot satisfy the gate by running the tooling",
)
def test_every_required_linux_tool_is_installable(self) -> None:
"""A tool the spec requires on Linux is one the tooling can provide, or a recorded exception."""
self._assert_coverage("linux", self.declared_tools(), "install-tools.sh")
def test_every_required_windows_tool_is_installable(self) -> None:
"""A tool the spec requires on Windows is one the tooling can provide, or a recorded exception."""
self._assert_coverage("windows", self.declared_windows_tools(), "install-tools.ps1")
def test_ripgrep_uses_the_platform_package_managers(self) -> None:
"""Ripgrep uses apt on Linux and the upstream project's documented winget package."""
linux = (LINUX / "install-tools.sh").read_text(encoding="utf-8")
windows = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
install = re.search(
r"ripgrep_install\(\) \{(?P<body>.*?)^\}", linux, re.MULTILINE | re.DOTALL
)
self.assertIsNotNone(install)
body = install.group("body") if install else ""
self.assertLess(body.index("ripgrep_remove_download"), body.index("apt_install ripgrep"))
self.assertRegex(
windows,
r"Name = 'ripgrep'; Package = 'BurntSushi\.ripgrep\.MSVC'; Probe = 'rg'",
)
@RUNS_LINUX_INSTALLER
def test_linux_installer_lists_a_repository_apt_package(self) -> None:
"""A repository package joins the managed set without becoming executable text."""
declaration = {
"tools": [
{
"name": "virt-customize",
"install": {"linux": {"manager": "apt", "package": "libguestfs-tools"}},
}
]
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(json.dumps(declaration), encoding="utf-8")
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("virt-customize", result.stdout)
self.assertIn("apt:libguestfs-tools (repository)", result.stdout)
@RUNS_LINUX_INSTALLER
def test_linux_installer_rejects_a_repository_collision(self) -> None:
"""Repository metadata cannot replace a fleet tool's specialized installer."""
declaration = {
"tools": [
{
"name": "node",
"install": {"linux": {"manager": "apt", "package": "nodejs"}},
}
]
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(json.dumps(declaration), encoding="utf-8")
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("cannot replace it", result.stderr)
@RUNS_LINUX_INSTALLER
def test_linux_installer_rejects_duplicate_repository_names(self) -> None:
"""Duplicate overlay names are ambiguous and fail before selection."""
entry = {
"name": "virt-customize",
"install": {"linux": {"manager": "apt", "package": "libguestfs-tools"}},
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(
json.dumps({"tools": [entry, entry]}), encoding="utf-8"
)
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("more than once", result.stderr)
@RUNS_LINUX_INSTALLER
def test_linux_installer_rejects_an_unnamed_repository_tool(self) -> None:
"""Malformed applicable metadata fails instead of disappearing from the catalog."""
declaration = {
"tools": [{"install": {"linux": {"manager": "apt", "package": "libguestfs-tools"}}}]
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(json.dumps(declaration), encoding="utf-8")
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cannot read constrained Linux install metadata", result.stderr)
@RUNS_LINUX_INSTALLER
def test_linux_installer_rejects_a_non_string_package(self) -> None:
"""JSON scalars do not become package identifiers through jq stringification."""
declaration = {
"tools": [
{
"name": "virt-customize",
"install": {"linux": {"manager": "apt", "package": 7}},
}
]
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(json.dumps(declaration), encoding="utf-8")
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cannot read constrained Linux install metadata", result.stderr)
@RUNS_LINUX_INSTALLER
def test_linux_installer_rejects_extra_install_metadata(self) -> None:
"""The runtime trust boundary matches the schema's closed package object."""
declaration = {
"tools": [
{
"name": "virt-customize",
"install": {
"linux": {
"manager": "apt",
"package": "libguestfs-tools",
"command": "do something",
}
},
}
]
}
with tempfile.TemporaryDirectory() as directory:
Path(directory, "host-tools.json").write_text(json.dumps(declaration), encoding="utf-8")
result = subprocess.run(
[str(LINUX / "install-tools.sh"), "--list", "--repo", directory],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Cannot read constrained Linux install metadata", result.stderr)
def test_sudo_timestamp_does_not_load_repository_tools(self) -> None:
"""The sudo-only action bypasses repository metadata before selection."""
text = (LINUX / "install-tools.sh").read_text(encoding="utf-8")
main = re.search(r"main\(\) \{(?P<body>.*?)^\}", text, re.MULTILINE | re.DOTALL)
self.assertIsNotNone(main)
body = main.group("body") if main else ""
self.assertRegex(body, r'if \[\[ \$MODE != "sudo-timestamp" \]\]; then\s+load_repo_tools')
def test_windows_installer_requires_a_repository_tools_array(self) -> None:
"""The Windows trust-boundary reader rejects an object-shaped tool catalog."""
text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
self.assertIn("$overlay.tools -isnot [System.Array]", text)
def test_windows_installer_requires_string_package_metadata(self) -> None:
"""PowerShell cannot stringify JSON values before package-ID validation."""
text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
self.assertIn("$metadata.manager -isnot [string]", text)
self.assertIn("$metadata.package -isnot [string]", text)
self.assertIn("$metadataKeys.Count -ne 2", text)
def test_windows_installer_supplies_the_python3_name(self) -> None:
"""Windows has no `python3` of its own, so the installer both clears the lie and supplies the name.
Two halves, and neither is sufficient alone. Windows ships app-execution alias stubs that
answer to `python` and `python3` and only offer to open the Microsoft Store. They fail
loudly, on stderr with exit 9009, so the harm is not a false pass but an occupied name: the
alias directory is on `PATH` by default, so the stub answers wherever it sits ahead of the
install directory. Removing them alone would leave `python3` simply absent on a host that
does have Python, so the installer also puts a real one beside the interpreter it manages.
The reparse-tag guard is what keeps the removal from deleting a real executable someone put
in that directory.
"""
text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
self.assertIn("function Repair-PythonName", text)
self.assertIn("function Test-AppExecutionAlias", text)
self.assertIn("0x8000001b", text)
self.assertIn("$PYTHON_ALIAS_NAMES = @('python.exe', 'python3.exe')", text)
self.assertIn("Repair-PythonName -Installed", text)
def test_windows_python3_shim_names_no_version(self) -> None:
"""The shim follows whatever line the managed package installs, so its code hardcodes none.
The package ID in `$TOOLS` is the one place a Python version is written down. A literal
anywhere in the repair path would silently keep targeting the old line the day that ID moves,
which is the failure mode this asserts against rather than merely documents.
Comments are stripped before the match, because the invariant is about what the code
resolves and not about what the prose may use as an example. The architecture-qualified tag
rule is far clearer written as `3.13` beside `3.13-arm64` than described in the abstract, and
a version named there cannot make a lookup target the wrong line.
"""
text = (WINDOWS / "install-tools.ps1").read_text(encoding="utf-8")
section = text.split("# --- Python ---", 1)
self.assertEqual(len(section), 2, "install-tools.ps1 carries no Python section")
body = section[1].split("# --- WSL ---", 1)[0]
code = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
self.assertNotRegex(code, r"Python3?\d\d|\d+\.\d+")
self.assertIn("Get-PythonLine -Version $Installed", code)
def test_every_declared_floor_carries_a_total_remedy_mapping(self) -> None:
"""Each floored tool names a runnable remedy on every platform, or carries a recorded exception.
The gate prints the remedy under a below-floor failure, so a missing platform key is a
failure that tells the operator to upgrade and not how. A remedy that hands back into an
installer here is also checked to name a tool that installer manages, so the command it
prints can actually run.
"""
linux_managed = self.declared_tools()
windows_managed = self.declared_windows_tools()
for tool in self.spec_tools():
name = tool["name"]
if tool.get("minimum") is None:
continue
remedy = tool.get("remedy")
with self.subTest(tool=name):
if not isinstance(remedy, dict) or not remedy:
self.fail(
f"{name} declares a floor and no remedy, so its failure names no command"
)
for platform in ("linux", "macos", "windows"):
if platform in REMEDY_NOT_APPLICABLE.get(name, {}):
self.assertNotIn(
platform,
remedy,
f"{name} is recorded as not applicable on {platform} and carries a remedy there, so the record is stale",
)
continue
command = remedy.get(platform)
self.assertTrue(
isinstance(command, str) and bool(command),
f"{name} declares a floor and no {platform} remedy, so a below-floor host there is told to upgrade and not how",
)
installer_cases = (
("linux", LINUX_INSTALLER_REMEDY, linux_managed, "install-tools.sh"),
("windows", WINDOWS_INSTALLER_REMEDY, windows_managed, "install-tools.ps1"),
)
for platform, pattern, managed, installer in installer_cases:
command = remedy.get(platform)
if not isinstance(command, str):
continue
match = pattern.match(command)
if match and managed:
self.assertIn(
match.group(1),
managed,
f"{name} remedy.{platform} names {match.group(1)}, which {installer} does not manage, so the printed command fails",
)
class TestScriptPresence(unittest.TestCase):
"""Every script a loader hands control to is present and, on Linux, executable."""
def indexed_modes(self) -> dict[str, str]:
"""The file modes git records, keyed by repo-relative path.
Git's mode is read rather than the filesystem's, because the filesystem does not carry one
on every platform this runs on. NTFS has no exec bit, so `st_mode` reports every file as
non-executable and the assertion below fails on Windows against a tree that is correct.
What the loader actually depends on is the mode a Linux checkout gets, and that is the one
git stores.
"""
try:
listing = subprocess.run(
["git", "ls-files", "-s", "--", "host-setup"],
capture_output=True,
text=True,
encoding="utf-8",
errors="surrogateescape",
check=True,
cwd=ROOT,
).stdout
except (OSError, subprocess.CalledProcessError) as error:
self.fail(
f"git could not report the recorded file modes, so executability is unchecked: {error}"
)
modes: dict[str, str] = {}
for line in listing.splitlines():
# Each row is "<mode> <object> <stage>\t<path>", so the tab is what separates the fields from the path.
fields, _, path = line.partition("\t")
if path:
modes[path] = fields.split()[0]
return modes
def test_every_managed_tool_is_executable(self) -> None:
"""Each script the loader hands control to is present and executable."""
modes = self.indexed_modes()
for name in ("install-skills.sh", "install-tools.sh", "upgrade-host.sh", "setup-github.sh"):
path = LINUX / name
with self.subTest(script=name):
self.assertTrue(path.is_file(), f"{name} is missing from host-setup/linux")
if path.is_file():
mode = modes.get(f"host-setup/linux/{name}", "")
self.assertEqual(
"100755",
mode,
f"{name} is recorded as {mode or 'untracked'} rather than 100755, "
f"so a fresh checkout cannot run it",
)
def test_every_windows_script_is_present(self) -> None:
"""Each Windows script is present, and none opens with a shebang.
The exec bit is the Linux form of "this will run", and on Windows the equivalent property
is the absence of a shebang: `scripts/repo_gate.py --check eol-coverage` requires git to
resolve any tracked file opening `#!` to `eol=lf` via a `.gitattributes` pin, and these
files carry none of their own. A shebang added later would fail that gate from a file
nobody would think to look at.
"""
scripts = (
"install-skills.ps1",
"install-tools.ps1",
"upgrade-host.ps1",
"setup-github.ps1",
"setup-wsl.ps1",
)
for name in scripts + ("README.md",):
with self.subTest(file=name):
self.assertTrue(
(WINDOWS / name).is_file(), f"{name} is missing from host-setup/windows"
)
for name in scripts:
path = WINDOWS / name
if path.is_file():
with self.subTest(script=name):
self.assertFalse(
path.read_bytes().startswith(b"#!"),
f"{name} opens with a shebang, which the eol-coverage gate then requires "
f"a `.gitattributes` pin for",
)
def test_bootstrap_ps1_is_present_and_unmarked(self) -> None:
"""`bootstrap.ps1` is present, and does not open with a shebang.
Kept apart from `test_every_windows_script_is_present` rather than folded into it, because
`bootstrap.ps1` deliberately sits beside `bootstrap.sh` at `host-setup/`, not inside
`host-setup/windows/` with the four scripts that test checks. Same reasoning as that test:
the `eol-coverage` gate requires a `.gitattributes` pin for any tracked file opening `#!`,
and this file carries none of its own.
"""
self.assertTrue(BOOTSTRAP_PS.is_file(), "bootstrap.ps1 is missing from host-setup")
if BOOTSTRAP_PS.is_file():
self.assertFalse(
BOOTSTRAP_PS.read_bytes().startswith(b"#!"),
"bootstrap.ps1 opens with a shebang, which the eol-coverage gate then requires a "
"`.gitattributes` pin for",
)
def test_bootstrap_ps1_names_the_system32_tar(self) -> None:
"""`bootstrap.ps1` reaches tar only through `Get-TarPath`, which names the System32 copy.
A pwsh launched from Git Bash finds MSYS tar first on `PATH`, and that tar reads the drive
prefix of a Windows archive path as a remote host, so a bare `tar` makes the extraction
depend on the shell the loader was started from. A path to any other tar, Git's own
included, reaches the same MSYS tar without `PATH`. The check is an allow-list over every
code line that names tar, so it holds whatever form the invocation on such a line takes.
A `tar.gz` is not tar named only as an archive extension (`name.tar.gz`) or as a URL path
segment (`/tar.gz/`), neither of which can name an executable.
"""
text = BOOTSTRAP_PS.read_text(encoding="utf-8")
definition = "function Get-TarPath { Join-Path $env:SystemRoot 'System32\\tar.exe' }"
self.assertIn(definition, text.splitlines())
names_tar = re.compile(
r"(?<=\.)tar\b(?!\.gz(?![\w.]))|(?<!\.)\btar\b(?!\.gz/)", re.IGNORECASE
)
message = re.compile(r"^die '[^']*'$")
strays = [
line.strip()
for line in text.splitlines()
if not line.lstrip().startswith("#")
and names_tar.search(line)
and line.strip() not in (definition, "")
and not message.match(line.strip())
]
self.assertEqual(strays, [], "bootstrap.ps1 names a tar other than through Get-TarPath")
def bootstrap_functions() -> str:
"""`bootstrap.sh` without its closing `main "$@"`, so a test can source its functions alone."""
lines = BOOTSTRAP.read_text(encoding="utf-8").rstrip("\n").split("\n")
if lines[-1] != 'main "$@"':
raise AssertionError(f"bootstrap.sh no longer ends with its main call: {lines[-1]!r}")
return "\n".join(lines[:-1]) + "\n"
REGISTRATION_RECORDER = """\
import os
from pathlib import Path
Path(os.environ["REGISTERED_RECORD"]).write_text(str(Path(__file__).resolve().parent.parent), encoding="utf-8")
"""
def installer_tree(root: Path, platform: str, wrapper: str) -> Path:
"""A tree holding the real skills wrapper for `platform`, its installer replaced by one recording the ROOT it would register."""
target = root / "host-setup" / platform / wrapper
target.parent.mkdir(parents=True)
shutil.copy2(ROOT / "host-setup" / platform / wrapper, target)
recorder = root / "scripts" / "skills_install.py"
recorder.parent.mkdir()
recorder.write_text(REGISTRATION_RECORDER, encoding="utf-8")
return root
class TestDirectoryLockOrder(unittest.TestCase):
"""A run refused the directory lock stops before anything that removes a tree.
Each loader's cleanup removes trees by their fixed names, so a refused run that reached it would
delete the trees of the very run holding the lock.
"""
def test_the_linux_loader_locks_before_setting_its_exit_trap(self) -> None:
text = BOOTSTRAP.read_text(encoding="utf-8")
main = text[text.index("\nmain() {") :]
self.assertLess(main.index("\n lock_dir\n"), main.index("trap cleanup EXIT"))
def test_the_windows_loader_locks_before_the_try_its_cleanup_runs_from(self) -> None:
text = BOOTSTRAP_PS.read_text(encoding="utf-8")
main = text[text.index("\nfunction main {") :]
self.assertLess(main.index("\n Lock-Directory\n"), main.index("\n try {\n"))
def hold_flock(path: Path) -> TextIO:
"""Holds `path` the way `bootstrap.sh` does, through the flock(2) its flock command takes."""
import fcntl
handle = path.open("a", encoding="utf-8")
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
return handle
@unittest.skipUnless(sys.platform == "linux", "drives the Linux loader's own functions")
class TestKeptTreeHandling(unittest.TestCase):
"""The Linux loader's removal and swap of the trees it owns, driven through its own functions."""
def setUp(self) -> None:
self.dir = Path(self.enterContext(tempfile.TemporaryDirectory()))
self.functions = self.dir / "functions.sh"
self.functions.write_text(bootstrap_functions(), encoding="utf-8")
self.addCleanup(self._unlock_all)
def _unlock_all(self) -> None:
for path in self.dir.rglob("*"):
if path.is_dir() and not path.is_symlink():
path.chmod(0o755)
def run_loader(self, body: str) -> subprocess.CompletedProcess[str]:
script = f'source "{self.functions}"\nDIR="{self.dir}"\nMODE=skills\n{body}\n'
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
encoding="utf-8",
check=False,
timeout=30,
)
def owned_tree(self, name: str, content: str) -> Path:
tree = self.dir / name
tree.mkdir()
(tree / ".bootstrap-owned").touch()
(tree / "content").write_text(content, encoding="utf-8")
return tree
def locked_entry(self, tree: Path) -> None:
"""An entry `rm` cannot remove, standing in for a removal that stops part way."""
if os.geteuid() == 0:
self.skipTest("root removes a read-only directory's entries regardless of its mode")
locked = tree / "locked"
locked.mkdir()
(locked / "file").touch()
locked.chmod(0o555)
def test_a_removal_that_stops_part_way_keeps_the_ownership_marker(self) -> None:
"""A leftover without its marker is refused by every later swap, with a remedy that is wrong."""
tree = self.owned_tree("skills-tree.new", "new")
self.locked_entry(tree)
result = self.run_loader(f'remove_tree "{tree}"')
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertFalse((tree / "content").exists(), result.stderr)
self.assertTrue((tree / ".bootstrap-owned").exists(), result.stderr)
def test_a_dangling_symlink_is_refused_with_the_loaders_own_message(self) -> None:
(self.dir / "skills-tree.new").symlink_to(self.dir / "nowhere")
result = self.run_loader('remove_owned "$(staging_path)"')
self.assertNotEqual(result.returncode, 0)
self.assertIn("this loader did not create it", result.stderr)
def test_an_old_tree_beside_an_empty_name_does_not_stop_the_new_one_landing(self) -> None:
self.owned_tree("skills-tree.new", "new")
self.locked_entry(self.owned_tree("skills-tree.old", "old"))
result = self.run_loader("swap_in")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "new")
def test_a_leftover_old_tree_that_will_not_go_stops_the_swap_naming_its_path(self) -> None:
self.owned_tree("skills-tree.new", "new")
self.owned_tree("skills-tree", "live")
self.locked_entry(self.owned_tree("skills-tree.old", "old"))
result = self.run_loader("swap_in")
self.assertNotEqual(result.returncode, 0)
self.assertIn(
f"Could not remove the previous tree at {self.dir / 'skills-tree.old'}", result.stderr
)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "live")
def test_a_failed_swap_never_moves_a_foreign_old_tree_into_place(self) -> None:
self.owned_tree("skills-tree.new", "new")
foreign = self.dir / "skills-tree.old"
foreign.mkdir()
result = self.run_loader(
'mv() { [[ $1 == *.new ]] && return 1; command mv "$@"; }\nswap_in'
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(foreign.is_dir())
self.assertFalse((self.dir / "skills-tree").exists())
def test_cleanup_restores_the_old_tree_even_where_the_staging_tree_will_not_go(self) -> None:
self.locked_entry(self.owned_tree("skills-tree.new", "new"))
self.owned_tree("skills-tree.old", "old")
result = self.run_loader("cleanup")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "old")
def test_cleanup_from_a_successful_runs_exit_trap_warns_where_a_leftover_old_tree_will_not_go(
self,
) -> None:
self.owned_tree("skills-tree", "live")
self.locked_entry(self.owned_tree("skills-tree.old", "old"))
result = self.run_loader("trap cleanup EXIT\nexit 0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Could not remove the previous tree", result.stderr)
def test_a_transient_tree_that_will_not_go_does_not_fail_a_successful_run(self) -> None:
self.locked_entry(self.owned_tree("tree", "fetched"))
result = self.run_loader("MODE=report\ntrap cleanup EXIT\nexit 0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Could not remove the fetched tree", result.stderr)
def test_cleanup_keeps_the_old_tree_where_the_name_holds_something_not_ours(self) -> None:
(self.dir / "skills-tree").symlink_to(self.dir / "elsewhere")
self.owned_tree("skills-tree.old", "old")
result = self.run_loader("cleanup")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(self.dir / "skills-tree.old" / "content").read_text(encoding="utf-8"), "old"
)
def test_cleanup_puts_the_old_tree_back_where_a_swap_left_the_name_empty(self) -> None:
self.owned_tree("skills-tree.old", "old")
result = self.run_loader("cleanup")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "old")
def test_a_swap_that_cannot_land_the_new_tree_puts_the_live_one_back(self) -> None:
self.owned_tree("skills-tree.new", "new")
self.owned_tree("skills-tree", "live")
result = self.run_loader(
'mv() { [[ $1 == *.new ]] && return 1; command mv "$@"; }\nswap_in'
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Could not move the extracted tree into place", result.stderr)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "live")
self.assertFalse((self.dir / "skills-tree.old").exists())
def test_a_swap_that_cannot_put_the_live_tree_back_names_where_it_is(self) -> None:
self.owned_tree("skills-tree.new", "new")
self.owned_tree("skills-tree", "live")
result = self.run_loader(
'mv() { [[ $1 == *.new || $1 == *.old ]] && return 1; command mv "$@"; }\nswap_in'
)
self.assertNotEqual(result.returncode, 0)
self.assertIn(
f"could not put the previous tree back from {self.dir / 'skills-tree.old'}",
result.stderr,
)
self.assertEqual(
(self.dir / "skills-tree.old" / "content").read_text(encoding="utf-8"), "live"
)
def test_a_run_is_refused_while_another_holds_the_directory_lock(self) -> None:
handle = hold_flock(self.dir / "skills-tree.lock")
self.addCleanup(handle.close)
result = self.run_loader("lock_dir")
self.assertNotEqual(result.returncode, 0)
self.assertIn("Another bootstrap run is using", result.stderr)
def test_the_directory_lock_is_free_once_the_run_holding_it_ends(self) -> None:
for _ in range(2):
result = self.run_loader("lock_dir")
self.assertEqual(result.returncode, 0, result.stderr)
hold_flock(self.dir / "skills-tree.lock").close()
def test_a_tool_the_loader_runs_does_not_inherit_the_lock(self) -> None:
"""A daemon a tool starts would otherwise hold the lock, and refuse every later run, long after this one."""
tool = self.dir / "tree" / "host-setup" / "linux" / "probe.sh"
tool.parent.mkdir(parents=True)
tool.write_text(
"#!/usr/bin/env bash\nif [[ -e /proc/self/fd/$1 ]]; then echo inherited; else echo closed; fi\n",
encoding="utf-8",
)
tool.chmod(0o755)
result = self.run_loader(
f'lock_dir\nTREE="{self.dir / "tree"}"\nrun_tool probe.sh "$LOCK_FD"'
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "closed")
def test_an_empty_directory_at_a_managed_name_is_removed_rather_than_refused(self) -> None:
"""What a removal leaves where only the directory itself would not go, its marker already gone."""
(self.dir / "skills-tree.new").mkdir()
result = self.run_loader('remove_owned "$(staging_path)"')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.dir / "skills-tree.new").exists())
def test_an_empty_directory_at_the_trees_name_is_replaced_by_the_new_tree(self) -> None:
(self.dir / "skills-tree").mkdir()
self.owned_tree("skills-tree.new", "new")
result = self.run_loader("swap_in")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.dir / "skills-tree" / "content").read_text(encoding="utf-8"), "new")
self.assertFalse((self.dir / "skills-tree.old").exists())
def test_a_directory_that_cannot_be_listed_is_not_taken_for_an_empty_one(self) -> None:
"""Its contents are unknown, so it is refused as somebody else's rather than moved aside as ours."""
if os.geteuid() == 0:
self.skipTest("root lists a directory regardless of its mode")
foreign = self.dir / "skills-tree"
foreign.mkdir()
(foreign / "theirs").write_text("theirs", encoding="utf-8")
foreign.chmod(0o000)
self.owned_tree("skills-tree.new", "new")
result = self.run_loader("swap_in")
self.assertNotEqual(result.returncode, 0)
self.assertIn("this loader did not create it", result.stderr)
self.assertFalse((self.dir / "skills-tree.old").exists())
def test_an_unmarked_directory_holding_anything_is_still_refused(self) -> None:
foreign = self.dir / "skills-tree.new"
foreign.mkdir()
(foreign / "theirs").write_text("theirs", encoding="utf-8")
result = self.run_loader('remove_owned "$(staging_path)"')
self.assertNotEqual(result.returncode, 0)
self.assertIn("this loader did not create it", result.stderr)
self.assertTrue((foreign / "theirs").exists())
def test_the_directory_the_skills_installer_registers_outlives_the_run(self) -> None:
"""The marketplace loads the directory the installer ran from, so that has to be the tree the run keeps."""
work = Path(self.enterContext(tempfile.TemporaryDirectory()))
top = installer_tree(work / "ProjectTemplate-0123456", "linux", "install-skills.sh")
archive = work / "archive.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
tar.add(top, arcname=top.name)
record = work / "registered"
result = self.run_loader(
f'export REGISTERED_RECORD="{record}"\n'
f'fetch() {{ cp "{archive}" "$2"; }}\n'
"RESOLVED=0123456789abcdef0123456789abcdef01234567\n"
"lock_dir\ntrap cleanup EXIT\ndownload_tree\ninstall_skills"
)
self.assertEqual(result.returncode, 0, result.stderr)
registered = Path(record.read_text(encoding="utf-8"))
self.assertEqual(registered, (self.dir / "skills-tree").resolve())
self.assertTrue((registered / "scripts" / "skills_install.py").is_file())
self.assertFalse((self.dir / "skills-tree.new").exists())
self.assertFalse((self.dir / "skills-tree.old").exists())
POWERSHELL_TREE_HARNESS = r"""
param([string]$Loader, [string]$Dir, [string]$BodyFile)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Loader, [ref]$tokens, [ref]$errors)
$wanted = @(
'log', 'info', 'step', 'warn', 'die',
'Test-KeepsTree', 'Get-TreeName', 'Get-TreePath', 'Get-StagingPath', 'Get-RetiredPath', 'Get-ArchivePath',
'Get-LockPath', 'Lock-Directory', 'Test-Ownership', 'Remove-Owned', 'Get-Tree', 'Remove-Tree', 'Move-Tree', 'Invoke-SwapIn', 'Invoke-Cleanup',
'Resolve-Directory', 'Invoke-Tool', 'Invoke-SkillsInstall'
)
foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) {
. ([scriptblock]::Create($definition.Extent.Text))
}
$script:DIR = $Dir
$script:MODE = 'skills'
$script:DRY_RUN = $false
$script:ASSUME_YES = $false
$script:KEEP = $false
$script:PWSH_PATH = (Get-Process -Id $PID).Path
$script:REPO = 'ptr727/ProjectTemplate'
$script:REF = 'main'
$script:RESOLVED = ''
$script:TREE = ''
$script:LOCK = $null
$script:HELD = [Collections.Generic.List[object]]::new()
function Get-Named { param([string]$Name) Join-Path $script:DIR $Name }
# Holds an entry the way a process using the tree does: an open handle on Windows, where that alone stops a rename and a delete, and a read-only directory elsewhere, where only the delete stops.
function Lock-Entry {
param([string]$Name)
$locked = Join-Path (Get-Named $Name) 'locked'
New-Item -ItemType Directory -Path $locked -Force | Out-Null
New-Item -ItemType File -Path (Join-Path $locked 'file') -Force | Out-Null
if ($IsWindows) {
$script:HELD.Add([IO.File]::Open((Join-Path $locked 'file'), 'Open', 'Read', 'None'))
} else {
& chmod 555 $locked
}
}
# Holds one file directly in a tree open, which only Windows can do without also locking the tree's own entries, the marker among them.
function Hold-File {
param([string]$Name, [string]$File)
$path = Join-Path (Get-Named $Name) $File
New-Item -ItemType File -Path $path -Force | Out-Null
$script:HELD.Add([IO.File]::Open($path, 'Open', 'Read', 'None'))
}
# A link at a tree's name, a junction on Windows since a symbolic link there needs a privilege a test host may not hold.
function New-Link {
param([string]$Name, [string]$Target)
$type = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' }
New-Item -ItemType $type -Path (Get-Named $Name) -Target (Get-Named $Target) | Out-Null
}
# Fails the move of any tree whose name ends in one of $Suffixes, and moves every other one as the loader does.
function Set-MoveFailure {
param([string[]]$Suffixes)
$script:FAIL_MOVE = $Suffixes
function script:Move-Tree {
param([string]$Path, [string]$Destination)
foreach ($suffix in $script:FAIL_MOVE) { if ($Path.EndsWith($suffix)) { throw "refused to move $Path" } }
[IO.Directory]::Move($Path, $Destination)
}
}
. ([scriptblock]::Create((Get-Content -Raw -LiteralPath $BodyFile)))
"""
@unittest.skipUnless(shutil.which("pwsh"), "needs pwsh to drive the Windows loader's own functions")
class TestPowerShellKeptTreeHandling(unittest.TestCase):
"""The Windows loader's removal and swap of the trees it owns, driven through its own functions.
Each case runs `bootstrap.ps1`'s own functions, extracted by AST, against a scratch directory.
An entry a case locks is held for real, by an open handle on Windows, so the rename and delete
failures the loader is shaped around are the host's own rather than a stub's.
"""
def setUp(self) -> None:
self.dir = Path(self.enterContext(tempfile.TemporaryDirectory()))