Skip to content

Commit b9ef8cb

Browse files
authored
Report actionable runtime bootstrap failures (#216)
* Report actionable runtime bootstrap failures * Make bootstrap recovery test platform neutral * Bind runtime hydration to exact release evidence
1 parent 9ec66c4 commit b9ef8cb

15 files changed

Lines changed: 722 additions & 42 deletions

File tree

.github/tests/test_repository_contract.py

Lines changed: 208 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ def setUpClass(cls) -> None:
102102
"boatstack-helper-old.exe" if os.name == "nt" else "boatstack-helper-old"
103103
)
104104
for output, version, source in (
105-
(cls.helper, "v0.7.contract-new", "b" * 40),
106-
(cls.old_helper, "v0.7.contract-old", "a" * 40),
105+
(cls.helper, "v9.9.10", "b" * 40),
106+
(cls.old_helper, "v9.9.9", "a" * 40),
107107
):
108108
ldflags = " ".join(
109109
(
@@ -884,7 +884,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
884884
)
885885
self.assertTrue(updated["doctor"]["healthy"])
886886
pin = json.loads((repository / ".boatstack" / "runtime.json").read_text())
887-
self.assertEqual(pin["version"], "v0.7.contract-new")
887+
self.assertEqual(pin["version"], "v9.9.10")
888888
self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest())
889889
self.assertNotIn("path", pin)
890890
events = [json.loads(line) for line in self.run_command(
@@ -900,6 +900,210 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
900900
self.assertTrue(event["committed_effects"])
901901
self.assertEqual(event["verification"]["result"], "satisfied")
902902

903+
def test_installer_download_and_checksum_failures_are_actionable_and_non_mutating(self) -> None:
904+
# control-law: installer-failure-identifies-exact-artifact-and-does-not-mutate-repository
905+
if os.name == "nt":
906+
self.skipTest("the repository contract job exercises the POSIX installer")
907+
with tempfile.TemporaryDirectory() as temp:
908+
root = Path(temp)
909+
repository = root / "repo"
910+
repository.mkdir()
911+
self.init_repository(repository)
912+
fake_bin = root / "fake-bin"
913+
fake_bin.mkdir()
914+
curl = fake_bin / "curl"
915+
curl.write_text("#!/bin/sh\nexit 22\n")
916+
curl.chmod(0o755)
917+
env = dict(os.environ)
918+
env.update(
919+
{
920+
"PATH": f"{fake_bin}{os.pathsep}{env['PATH']}",
921+
"BOATSTACK_REPO": str(repository),
922+
"BOATSTACK_HOME": str(root / "home"),
923+
"BOATSTACK_INSTALL_DIR": str(root / "bin"),
924+
"BOATSTACK_VERSION": "v9.9.9",
925+
}
926+
)
927+
unavailable = self.run_command(
928+
"bash", REPO / "install.sh", cwd=repository, env=env, expected=1,
929+
)
930+
self.assertIn(
931+
"BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=v9.9.9 asset=boatstack-helper_",
932+
unavailable.stderr,
933+
)
934+
self.assertEqual(
935+
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
936+
"",
937+
)
938+
939+
self.assertFalse((root / "home").exists())
940+
self.assertFalse((root / "bin").exists())
941+
942+
checksum_env = dict(env)
943+
checksum_env.update(
944+
{
945+
"BOATSTACK_BINARY": str(self.helper),
946+
"BOATSTACK_BINARY_SHA256": "0" * 64,
947+
}
948+
)
949+
mismatch = self.run_command(
950+
"bash", REPO / "install.sh", cwd=repository, env=checksum_env, expected=1,
951+
)
952+
self.assertIn(
953+
"BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=v9.9.9",
954+
mismatch.stderr,
955+
)
956+
self.assertEqual(
957+
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
958+
"",
959+
)
960+
961+
def test_installer_hydrates_exact_local_runtime_without_repository_state(self) -> None:
962+
# control-law: approved-runtime-hydration-restores-only-trusted-runtime-storage
963+
if os.name == "nt":
964+
self.skipTest("the repository contract job exercises the POSIX installer")
965+
with tempfile.TemporaryDirectory() as temp:
966+
root = Path(temp)
967+
repository = root / "repo"
968+
repository.mkdir()
969+
self.init_repository(repository)
970+
version = self.run_command(self.helper, "version").stdout.strip()
971+
digest = hashlib.sha256(self.helper.read_bytes()).hexdigest()
972+
env = dict(os.environ)
973+
env.update(
974+
{
975+
"BOATSTACK_REPO": str(repository),
976+
"BOATSTACK_HOME": str(root / "home"),
977+
"BOATSTACK_INSTALL_DIR": str(root / "bin"),
978+
"BOATSTACK_MODE": "hydrate",
979+
"BOATSTACK_VERSION": version,
980+
"BOATSTACK_BINARY": str(self.helper),
981+
"BOATSTACK_BINARY_SHA256": digest,
982+
}
983+
)
984+
985+
hydrated = self.run_command("bash", REPO / "install.sh", cwd=repository, env=env)
986+
runtime = root / "home" / "runtimes" / f"{version}-{digest}" / "boatstack-runtime"
987+
self.assertTrue(runtime.is_file())
988+
self.assertTrue((root / "bin" / "boatstack").is_file())
989+
self.assertNotIn("Review and commit", hydrated.stdout)
990+
self.assertFalse((repository / ".boatstack").exists())
991+
self.assertFalse((repository / ".git" / "boatstack").exists())
992+
self.assertEqual(
993+
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
994+
"",
995+
)
996+
997+
wrong_digest = dict(env)
998+
wrong_digest.update(
999+
{
1000+
"BOATSTACK_HOME": str(root / "digest-home"),
1001+
"BOATSTACK_INSTALL_DIR": str(root / "digest-bin"),
1002+
"BOATSTACK_EXPECTED_RUNTIME_SHA256": "0" * 64,
1003+
}
1004+
)
1005+
digest_rejected = self.run_command(
1006+
"bash", REPO / "install.sh", cwd=repository, env=wrong_digest, expected=1,
1007+
)
1008+
self.assertIn("BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH", digest_rejected.stderr)
1009+
self.assertFalse((root / "digest-home").exists())
1010+
self.assertFalse((root / "digest-bin").exists())
1011+
1012+
wrong_version = dict(env)
1013+
wrong_version.update(
1014+
{
1015+
"BOATSTACK_HOME": str(root / "wrong-home"),
1016+
"BOATSTACK_INSTALL_DIR": str(root / "wrong-bin"),
1017+
"BOATSTACK_VERSION": "v9.9.9",
1018+
}
1019+
)
1020+
rejected = self.run_command(
1021+
"bash", REPO / "install.sh", cwd=repository, env=wrong_version, expected=1,
1022+
)
1023+
self.assertIn("Boatstack runtime version mismatch", rejected.stderr)
1024+
self.assertFalse((root / "wrong-home").exists())
1025+
self.assertFalse((root / "wrong-bin").exists())
1026+
1027+
old_version = self.run_command(self.old_helper, "version").stdout.strip()
1028+
old_digest = hashlib.sha256(self.old_helper.read_bytes()).hexdigest()
1029+
older_runtime = dict(env)
1030+
older_runtime.update(
1031+
{
1032+
"BOATSTACK_HOME": str(root / "old-home"),
1033+
"BOATSTACK_INSTALL_DIR": str(root / "old-bin"),
1034+
"BOATSTACK_VERSION": old_version,
1035+
"BOATSTACK_BINARY": str(self.old_helper),
1036+
"BOATSTACK_BINARY_SHA256": old_digest,
1037+
"BOATSTACK_EXPECTED_RUNTIME_SHA256": old_digest,
1038+
}
1039+
)
1040+
self.run_command("bash", REPO / "install.sh", cwd=repository, env=older_runtime)
1041+
restored = root / "old-home" / "runtimes" / f"{old_version}-{old_digest}" / "boatstack-runtime"
1042+
self.assertTrue(restored.is_file())
1043+
self.assertFalse((repository / ".boatstack").exists())
1044+
self.assertFalse((repository / ".git" / "boatstack").exists())
1045+
1046+
def test_launcher_renders_missing_runtime_as_text_and_json_before_state(self) -> None:
1047+
# control-law: launcher-diagnostic-precedes-runtime-dispatch-and-managed-state
1048+
with tempfile.TemporaryDirectory() as temp:
1049+
root = Path(temp)
1050+
repository = root / "repo"
1051+
repository.mkdir()
1052+
self.init_repository(repository)
1053+
runtime = b"required but absent"
1054+
pin = {
1055+
"schema_version": 1,
1056+
"version": "v9.9.9",
1057+
"sha256": hashlib.sha256(runtime).hexdigest(),
1058+
"source_revision": "9" * 40,
1059+
"program_fingerprint": "a" * 64,
1060+
"state_schema_version": 3,
1061+
}
1062+
pin_path = repository / ".boatstack" / "runtime.json"
1063+
pin_path.parent.mkdir()
1064+
pin_path.write_text(json.dumps(pin, indent=2) + "\n")
1065+
launcher = root / ("boatstack.exe" if os.name == "nt" else "boatstack")
1066+
launcher.write_bytes(self.helper.read_bytes())
1067+
launcher.chmod(0o755)
1068+
env = dict(os.environ)
1069+
env.update(
1070+
{
1071+
"BOATSTACK_HOME": str(root / "runtime-home"),
1072+
"BOATSTACK_STATE_ROOT": str(root / "state-root"),
1073+
}
1074+
)
1075+
1076+
rendered = self.run_command(
1077+
launcher, "next", "--repo", repository, "--format", "json",
1078+
env=env, expected=1,
1079+
)
1080+
diagnostic = json.loads(rendered.stderr)
1081+
self.assertEqual(diagnostic["schema"], "boatstack-bootstrap-diagnostic")
1082+
self.assertEqual(diagnostic["schema_revision"], 1)
1083+
self.assertEqual(diagnostic["code"], "BOATSTACK_RUNTIME_NOT_INSTALLED")
1084+
self.assertEqual(diagnostic["required_runtime"], {
1085+
"version": pin["version"],
1086+
"sha256": pin["sha256"],
1087+
"source_revision": pin["source_revision"],
1088+
})
1089+
self.assertIn("/v9.9.10/install", diagnostic["recovery"]["command"])
1090+
self.assertIn(pin["sha256"], diagnostic["recovery"]["command"])
1091+
self.assertIn("BOATSTACK_MODE=hydrate", diagnostic["recovery"]["command"])
1092+
self.assertNotIn("BOATSTACK_MODE=update", diagnostic["recovery"]["command"])
1093+
self.assertTrue(diagnostic["recovery"]["requires_confirmation"])
1094+
self.assertFalse(diagnostic["flow_run_created"])
1095+
self.assertFalse(diagnostic["managed_state_changed"])
1096+
1097+
text = self.run_command(
1098+
launcher, "next", "--repo", repository, "--format", "text",
1099+
env=env, expected=1,
1100+
)
1101+
self.assertIn("Blocked:", text.stderr)
1102+
self.assertIn("BOATSTACK_RUNTIME_NOT_INSTALLED", text.stderr)
1103+
self.assertIn("BOATSTACK_VERSION=v9.9.9", text.stderr)
1104+
self.assertFalse((root / "state-root").exists())
1105+
self.assertFalse((repository / ".git" / "boatstack").exists())
1106+
9031107
def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None:
9041108
# control-law: accepted-program-delta-atomically-pins-runtime-and-program
9051109
if os.name == "nt":
@@ -997,7 +1201,7 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
9971201
env["BOATSTACK_ACCEPT_PROGRAM_CHANGE"] = "true"
9981202
self.run_command("bash", REPO / "install.sh", cwd=repository, env=env)
9991203
pin = json.loads((repository / ".boatstack" / "runtime.json").read_text())
1000-
self.assertEqual(pin["version"], "v0.7.contract-new")
1204+
self.assertEqual(pin["version"], "v9.9.10")
10011205
self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest())
10021206
self.assertNotIn("path", pin)
10031207
doctor = json.loads(

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,53 @@ jobs:
184184
$errors | ForEach-Object { Write-Error $_ }
185185
exit 1
186186
}
187+
- name: Prove PowerShell install and hydration boundaries
188+
if: matrix.shard == '0'
189+
shell: pwsh
190+
run: |
191+
$root = Join-Path $env:RUNNER_TEMP ("boatstack-installer-" + [guid]::NewGuid().ToString("N"))
192+
$helper = (Resolve-Path boatstack/boatstack-helper.exe).Path
193+
$version = (& $helper version).Trim()
194+
$digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $helper).Hash.ToLowerInvariant()
195+
try {
196+
foreach ($mode in @("hydrate", "install")) {
197+
$repository = Join-Path $root "$mode-repository"
198+
New-Item -ItemType Directory -Force -Path $repository | Out-Null
199+
& git -C $repository init --initial-branch=main | Out-Null
200+
& git -C $repository config user.email "installer-contract@example.invalid"
201+
& git -C $repository config user.name "Installer Contract"
202+
Set-Content -LiteralPath (Join-Path $repository "README.md") -Value "fixture"
203+
& git -C $repository add README.md
204+
& git -C $repository commit -m "Initialize fixture" | Out-Null
205+
206+
$runtimeHome = Join-Path $root "$mode-home"
207+
$installDir = Join-Path $root "$mode-bin"
208+
$env:BOATSTACK_REPO = $repository
209+
$env:BOATSTACK_HOME = $runtimeHome
210+
$env:BOATSTACK_INSTALL_DIR = $installDir
211+
$env:BOATSTACK_MODE = $mode
212+
$env:BOATSTACK_VERSION = $version
213+
$env:BOATSTACK_BINARY = $helper
214+
$env:BOATSTACK_BINARY_SHA256 = $digest
215+
$env:BOATSTACK_EXPECTED_RUNTIME_SHA256 = $digest
216+
& ./install.ps1
217+
if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" }
218+
219+
$runtime = Join-Path $runtimeHome "runtimes\$version-$digest\boatstack-runtime.exe"
220+
$launcher = Join-Path $installDir "boatstack.exe"
221+
if (-not (Test-Path -LiteralPath $runtime -PathType Leaf)) { throw "$mode did not stage the runtime" }
222+
if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw "$mode did not stage the launcher" }
223+
if ($mode -eq "hydrate") {
224+
if (Test-Path -LiteralPath (Join-Path $repository ".boatstack")) { throw "hydrate changed repository state" }
225+
if (Test-Path -LiteralPath (Join-Path $repository ".git\boatstack")) { throw "hydrate changed controller state" }
226+
if (& git -C $repository status --porcelain) { throw "hydrate changed tracked repository files" }
227+
} elseif (-not (Test-Path -LiteralPath (Join-Path $repository ".boatstack\runtime.json") -PathType Leaf)) {
228+
throw "install did not initialize the repository runtime pin"
229+
}
230+
}
231+
} finally {
232+
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
233+
}
187234
188235
repository-contract:
189236
name: repository-contract

boatstack/cmd/boatstack-helper/main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ func main() {
8181
if boatstackruntime.ShouldDispatch(os.Args[0]) {
8282
code, err := boatstackruntime.Dispatch(os.Args[1:])
8383
if err != nil {
84-
fmt.Fprintln(os.Stderr, "boatstack:", err)
84+
rendered, renderErr := boatstackruntime.RenderBootstrapDiagnostic(os.Stderr, err, os.Args[1:])
85+
if renderErr != nil {
86+
fmt.Fprintln(os.Stderr, "boatstack:", renderErr)
87+
} else if !rendered {
88+
fmt.Fprintln(os.Stderr, "boatstack:", err)
89+
}
8590
os.Exit(1)
8691
}
8792
os.Exit(code)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package skillprojection
2+
3+
import "fmt"
4+
5+
// BootstrapContract is shared by every generated Flow entry skill. It covers
6+
// failures that occur before a repository Control Program can be loaded.
7+
func BootstrapContract(installerVersion string) string {
8+
return fmt.Sprintf(`Before starting the Flow, verify that the `+"`boatstack`"+` command is
9+
available (`+"`command -v boatstack`"+` on POSIX or `+"`Get-Command boatstack`"+` in
10+
PowerShell). If it is absent, read the exact committed
11+
`+"`.boatstack/runtime.json`"+` regular file. Report
12+
`+"`BOATSTACK_LAUNCHER_NOT_FOUND`"+`, the pinned version and SHA-256, and the
13+
tag-specific installer command for the current platform:
14+
15+
POSIX:
16+
`+"`BOATSTACK_MODE=hydrate BOATSTACK_VERSION=<exact-version> BOATSTACK_EXPECTED_RUNTIME_SHA256=<exact-sha256> /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.sh)\"`"+`
17+
18+
PowerShell:
19+
`+"`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION='<exact-version>'; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256='<exact-sha256>'; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | Invoke-Expression`"+`
20+
21+
Replace `+"`<exact-version>`"+` and `+"`<exact-sha256>`"+` only with the validated
22+
values in the pin. The installer comes from Boatstack %s, the runtime version
23+
that generated this skill, so it can hydrate older pinned runtime artifacts.
24+
If the pin is absent or invalid, report `+"`BOATSTACK_RUNTIME_PIN_MISSING`"+` or
25+
`+"`BOATSTACK_RUNTIME_PIN_INVALID`"+` and stop without guessing a version or
26+
selecting `+"`latest`"+`.
27+
28+
Display the installer command and ask for explicit approval. Never run it or
29+
authorize installation on the user's behalf. A bootstrap failure creates no
30+
Flow run ID. Preserve any Boatstack bootstrap diagnostic verbatim, including
31+
stderr, and resume this same requested entry only after the user has installed
32+
the exact runtime.`, installerVersion, installerVersion, installerVersion)
33+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package skillprojection
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) {
9+
// control-law: generated-skills-report-bootstrap-recovery-without-executing-it
10+
contract := BootstrapContract("v9.9.10")
11+
for _, expected := range []string{
12+
"command -v boatstack", "Get-Command boatstack", ".boatstack/runtime.json",
13+
"BOATSTACK_LAUNCHER_NOT_FOUND", "BOATSTACK_RUNTIME_PIN_MISSING",
14+
"BOATSTACK_RUNTIME_PIN_INVALID", "explicit approval", "Never run it",
15+
"creates no\nFlow run ID", "resume this same requested entry",
16+
"BOATSTACK_MODE=hydrate", "BOATSTACK_VERSION=<exact-version>",
17+
"BOATSTACK_EXPECTED_RUNTIME_SHA256=<exact-sha256>", "/v9.9.10/install.sh",
18+
} {
19+
if !strings.Contains(contract, expected) {
20+
t.Fatalf("bootstrap contract lacks %q", expected)
21+
}
22+
}
23+
if strings.Contains(contract, "BOATSTACK_VERSION=latest") {
24+
t.Fatal("bootstrap contract permits mutable latest selection")
25+
}
26+
if strings.Contains(contract, "BOATSTACK_MODE=update") {
27+
t.Fatal("bootstrap contract permits repository mutation during runtime recovery")
28+
}
29+
}

boatstack/flow/softwaredelivery/skills.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"strings"
88

99
"github.com/operatorstack/boatstack/boatstack/controlprogram"
10+
"github.com/operatorstack/boatstack/boatstack/flow/skillprojection"
11+
"github.com/operatorstack/boatstack/boatstack/internal/buildinfo"
1012
)
1113

1214
func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) {
@@ -91,6 +93,8 @@ description: %q
9193
Run the repository-owned Flow %q entry %q until its marked target %q is reached.
9294
Boatstack does not interpret the entry name.
9395
96+
%s
97+
9498
Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`.
9599
Preserve the returned program fingerprint, entry, run ID, delivery, repository,
96100
worktree, host, actor, authority receipts, prescription, and receipts through
@@ -106,7 +110,7 @@ background while input is missing. Never synthesize authority.
106110
Stop only when Boatstack reports the marked target, a typed blocker, refusal,
107111
unresolved recovery, or missing authority. This entry grants no merge or deploy
108112
authority.
109-
`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, delegation, supersession))
113+
`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession))
110114
}
111115

112116
func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) {

0 commit comments

Comments
 (0)