Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 208 additions & 4 deletions .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ def setUpClass(cls) -> None:
"boatstack-helper-old.exe" if os.name == "nt" else "boatstack-helper-old"
)
for output, version, source in (
(cls.helper, "v0.7.contract-new", "b" * 40),
(cls.old_helper, "v0.7.contract-old", "a" * 40),
(cls.helper, "v9.9.10", "b" * 40),
(cls.old_helper, "v9.9.9", "a" * 40),
):
ldflags = " ".join(
(
Expand Down Expand Up @@ -884,7 +884,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
)
self.assertTrue(updated["doctor"]["healthy"])
pin = json.loads((repository / ".boatstack" / "runtime.json").read_text())
self.assertEqual(pin["version"], "v0.7.contract-new")
self.assertEqual(pin["version"], "v9.9.10")
self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest())
self.assertNotIn("path", pin)
events = [json.loads(line) for line in self.run_command(
Expand All @@ -900,6 +900,210 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
self.assertTrue(event["committed_effects"])
self.assertEqual(event["verification"]["result"], "satisfied")

def test_installer_download_and_checksum_failures_are_actionable_and_non_mutating(self) -> None:
# control-law: installer-failure-identifies-exact-artifact-and-does-not-mutate-repository
if os.name == "nt":
self.skipTest("the repository contract job exercises the POSIX installer")
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
repository = root / "repo"
repository.mkdir()
self.init_repository(repository)
fake_bin = root / "fake-bin"
fake_bin.mkdir()
curl = fake_bin / "curl"
curl.write_text("#!/bin/sh\nexit 22\n")
curl.chmod(0o755)
env = dict(os.environ)
env.update(
{
"PATH": f"{fake_bin}{os.pathsep}{env['PATH']}",
"BOATSTACK_REPO": str(repository),
"BOATSTACK_HOME": str(root / "home"),
"BOATSTACK_INSTALL_DIR": str(root / "bin"),
"BOATSTACK_VERSION": "v9.9.9",
}
)
unavailable = self.run_command(
"bash", REPO / "install.sh", cwd=repository, env=env, expected=1,
)
self.assertIn(
"BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=v9.9.9 asset=boatstack-helper_",
unavailable.stderr,
)
self.assertEqual(
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
"",
)

self.assertFalse((root / "home").exists())
self.assertFalse((root / "bin").exists())

checksum_env = dict(env)
checksum_env.update(
{
"BOATSTACK_BINARY": str(self.helper),
"BOATSTACK_BINARY_SHA256": "0" * 64,
}
)
mismatch = self.run_command(
"bash", REPO / "install.sh", cwd=repository, env=checksum_env, expected=1,
)
self.assertIn(
"BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=v9.9.9",
mismatch.stderr,
)
self.assertEqual(
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
"",
)

def test_installer_hydrates_exact_local_runtime_without_repository_state(self) -> None:
# control-law: approved-runtime-hydration-restores-only-trusted-runtime-storage
if os.name == "nt":
self.skipTest("the repository contract job exercises the POSIX installer")
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
repository = root / "repo"
repository.mkdir()
self.init_repository(repository)
version = self.run_command(self.helper, "version").stdout.strip()
digest = hashlib.sha256(self.helper.read_bytes()).hexdigest()
env = dict(os.environ)
env.update(
{
"BOATSTACK_REPO": str(repository),
"BOATSTACK_HOME": str(root / "home"),
"BOATSTACK_INSTALL_DIR": str(root / "bin"),
"BOATSTACK_MODE": "hydrate",
"BOATSTACK_VERSION": version,
"BOATSTACK_BINARY": str(self.helper),
"BOATSTACK_BINARY_SHA256": digest,
}
)

hydrated = self.run_command("bash", REPO / "install.sh", cwd=repository, env=env)
runtime = root / "home" / "runtimes" / f"{version}-{digest}" / "boatstack-runtime"
self.assertTrue(runtime.is_file())
self.assertTrue((root / "bin" / "boatstack").is_file())
self.assertNotIn("Review and commit", hydrated.stdout)
self.assertFalse((repository / ".boatstack").exists())
self.assertFalse((repository / ".git" / "boatstack").exists())
self.assertEqual(
self.run_command("git", "status", "--porcelain", cwd=repository).stdout,
"",
)

wrong_digest = dict(env)
wrong_digest.update(
{
"BOATSTACK_HOME": str(root / "digest-home"),
"BOATSTACK_INSTALL_DIR": str(root / "digest-bin"),
"BOATSTACK_EXPECTED_RUNTIME_SHA256": "0" * 64,
}
)
digest_rejected = self.run_command(
"bash", REPO / "install.sh", cwd=repository, env=wrong_digest, expected=1,
)
self.assertIn("BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH", digest_rejected.stderr)
self.assertFalse((root / "digest-home").exists())
self.assertFalse((root / "digest-bin").exists())

wrong_version = dict(env)
wrong_version.update(
{
"BOATSTACK_HOME": str(root / "wrong-home"),
"BOATSTACK_INSTALL_DIR": str(root / "wrong-bin"),
"BOATSTACK_VERSION": "v9.9.9",
}
)
rejected = self.run_command(
"bash", REPO / "install.sh", cwd=repository, env=wrong_version, expected=1,
)
self.assertIn("Boatstack runtime version mismatch", rejected.stderr)
self.assertFalse((root / "wrong-home").exists())
self.assertFalse((root / "wrong-bin").exists())

old_version = self.run_command(self.old_helper, "version").stdout.strip()
old_digest = hashlib.sha256(self.old_helper.read_bytes()).hexdigest()
older_runtime = dict(env)
older_runtime.update(
{
"BOATSTACK_HOME": str(root / "old-home"),
"BOATSTACK_INSTALL_DIR": str(root / "old-bin"),
"BOATSTACK_VERSION": old_version,
"BOATSTACK_BINARY": str(self.old_helper),
"BOATSTACK_BINARY_SHA256": old_digest,
"BOATSTACK_EXPECTED_RUNTIME_SHA256": old_digest,
}
)
self.run_command("bash", REPO / "install.sh", cwd=repository, env=older_runtime)
restored = root / "old-home" / "runtimes" / f"{old_version}-{old_digest}" / "boatstack-runtime"
self.assertTrue(restored.is_file())
self.assertFalse((repository / ".boatstack").exists())
self.assertFalse((repository / ".git" / "boatstack").exists())

def test_launcher_renders_missing_runtime_as_text_and_json_before_state(self) -> None:
# control-law: launcher-diagnostic-precedes-runtime-dispatch-and-managed-state
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
repository = root / "repo"
repository.mkdir()
self.init_repository(repository)
runtime = b"required but absent"
pin = {
"schema_version": 1,
"version": "v9.9.9",
"sha256": hashlib.sha256(runtime).hexdigest(),
"source_revision": "9" * 40,
"program_fingerprint": "a" * 64,
"state_schema_version": 3,
}
pin_path = repository / ".boatstack" / "runtime.json"
pin_path.parent.mkdir()
pin_path.write_text(json.dumps(pin, indent=2) + "\n")
launcher = root / ("boatstack.exe" if os.name == "nt" else "boatstack")
launcher.write_bytes(self.helper.read_bytes())
launcher.chmod(0o755)
env = dict(os.environ)
env.update(
{
"BOATSTACK_HOME": str(root / "runtime-home"),
"BOATSTACK_STATE_ROOT": str(root / "state-root"),
}
)

rendered = self.run_command(
launcher, "next", "--repo", repository, "--format", "json",
env=env, expected=1,
)
diagnostic = json.loads(rendered.stderr)
self.assertEqual(diagnostic["schema"], "boatstack-bootstrap-diagnostic")
self.assertEqual(diagnostic["schema_revision"], 1)
self.assertEqual(diagnostic["code"], "BOATSTACK_RUNTIME_NOT_INSTALLED")
self.assertEqual(diagnostic["required_runtime"], {
"version": pin["version"],
"sha256": pin["sha256"],
"source_revision": pin["source_revision"],
})
self.assertIn("/v9.9.10/install", diagnostic["recovery"]["command"])
self.assertIn(pin["sha256"], diagnostic["recovery"]["command"])
self.assertIn("BOATSTACK_MODE=hydrate", diagnostic["recovery"]["command"])
self.assertNotIn("BOATSTACK_MODE=update", diagnostic["recovery"]["command"])
self.assertTrue(diagnostic["recovery"]["requires_confirmation"])
self.assertFalse(diagnostic["flow_run_created"])
self.assertFalse(diagnostic["managed_state_changed"])

text = self.run_command(
launcher, "next", "--repo", repository, "--format", "text",
env=env, expected=1,
)
self.assertIn("Blocked:", text.stderr)
self.assertIn("BOATSTACK_RUNTIME_NOT_INSTALLED", text.stderr)
self.assertIn("BOATSTACK_VERSION=v9.9.9", text.stderr)
self.assertFalse((root / "state-root").exists())
self.assertFalse((repository / ".git" / "boatstack").exists())

def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None:
# control-law: accepted-program-delta-atomically-pins-runtime-and-program
if os.name == "nt":
Expand Down Expand Up @@ -997,7 +1201,7 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No
env["BOATSTACK_ACCEPT_PROGRAM_CHANGE"] = "true"
self.run_command("bash", REPO / "install.sh", cwd=repository, env=env)
pin = json.loads((repository / ".boatstack" / "runtime.json").read_text())
self.assertEqual(pin["version"], "v0.7.contract-new")
self.assertEqual(pin["version"], "v9.9.10")
self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest())
self.assertNotIn("path", pin)
doctor = json.loads(
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,53 @@ jobs:
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
- name: Prove PowerShell install and hydration boundaries
if: matrix.shard == '0'
shell: pwsh
run: |
$root = Join-Path $env:RUNNER_TEMP ("boatstack-installer-" + [guid]::NewGuid().ToString("N"))
$helper = (Resolve-Path boatstack/boatstack-helper.exe).Path
$version = (& $helper version).Trim()
$digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $helper).Hash.ToLowerInvariant()
try {
foreach ($mode in @("hydrate", "install")) {
$repository = Join-Path $root "$mode-repository"
New-Item -ItemType Directory -Force -Path $repository | Out-Null
& git -C $repository init --initial-branch=main | Out-Null
& git -C $repository config user.email "installer-contract@example.invalid"
& git -C $repository config user.name "Installer Contract"
Set-Content -LiteralPath (Join-Path $repository "README.md") -Value "fixture"
& git -C $repository add README.md
& git -C $repository commit -m "Initialize fixture" | Out-Null

$runtimeHome = Join-Path $root "$mode-home"
$installDir = Join-Path $root "$mode-bin"
$env:BOATSTACK_REPO = $repository
$env:BOATSTACK_HOME = $runtimeHome
$env:BOATSTACK_INSTALL_DIR = $installDir
$env:BOATSTACK_MODE = $mode
$env:BOATSTACK_VERSION = $version
$env:BOATSTACK_BINARY = $helper
$env:BOATSTACK_BINARY_SHA256 = $digest
$env:BOATSTACK_EXPECTED_RUNTIME_SHA256 = $digest
& ./install.ps1
if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" }

$runtime = Join-Path $runtimeHome "runtimes\$version-$digest\boatstack-runtime.exe"
$launcher = Join-Path $installDir "boatstack.exe"
if (-not (Test-Path -LiteralPath $runtime -PathType Leaf)) { throw "$mode did not stage the runtime" }
if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw "$mode did not stage the launcher" }
if ($mode -eq "hydrate") {
if (Test-Path -LiteralPath (Join-Path $repository ".boatstack")) { throw "hydrate changed repository state" }
if (Test-Path -LiteralPath (Join-Path $repository ".git\boatstack")) { throw "hydrate changed controller state" }
if (& git -C $repository status --porcelain) { throw "hydrate changed tracked repository files" }
} elseif (-not (Test-Path -LiteralPath (Join-Path $repository ".boatstack\runtime.json") -PathType Leaf)) {
throw "install did not initialize the repository runtime pin"
}
}
} finally {
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
}

repository-contract:
name: repository-contract
Expand Down
7 changes: 6 additions & 1 deletion boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ func main() {
if boatstackruntime.ShouldDispatch(os.Args[0]) {
code, err := boatstackruntime.Dispatch(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "boatstack:", err)
rendered, renderErr := boatstackruntime.RenderBootstrapDiagnostic(os.Stderr, err, os.Args[1:])
if renderErr != nil {
fmt.Fprintln(os.Stderr, "boatstack:", renderErr)
} else if !rendered {
fmt.Fprintln(os.Stderr, "boatstack:", err)
}
os.Exit(1)
}
os.Exit(code)
Expand Down
33 changes: 33 additions & 0 deletions boatstack/flow/skillprojection/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package skillprojection

import "fmt"

// BootstrapContract is shared by every generated Flow entry skill. It covers
// failures that occur before a repository Control Program can be loaded.
func BootstrapContract(installerVersion string) string {
return fmt.Sprintf(`Before starting the Flow, verify that the `+"`boatstack`"+` command is
available (`+"`command -v boatstack`"+` on POSIX or `+"`Get-Command boatstack`"+` in
PowerShell). If it is absent, read the exact committed
`+"`.boatstack/runtime.json`"+` regular file. Report
`+"`BOATSTACK_LAUNCHER_NOT_FOUND`"+`, the pinned version and SHA-256, and the
tag-specific installer command for the current platform:

POSIX:
`+"`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)\"`"+`

PowerShell:
`+"`$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`"+`

Replace `+"`<exact-version>`"+` and `+"`<exact-sha256>`"+` only with the validated
values in the pin. The installer comes from Boatstack %s, the runtime version
that generated this skill, so it can hydrate older pinned runtime artifacts.
If the pin is absent or invalid, report `+"`BOATSTACK_RUNTIME_PIN_MISSING`"+` or
`+"`BOATSTACK_RUNTIME_PIN_INVALID`"+` and stop without guessing a version or
selecting `+"`latest`"+`.

Display the installer command and ask for explicit approval. Never run it or
authorize installation on the user's behalf. A bootstrap failure creates no
Flow run ID. Preserve any Boatstack bootstrap diagnostic verbatim, including
stderr, and resume this same requested entry only after the user has installed
the exact runtime.`, installerVersion, installerVersion, installerVersion)
}
29 changes: 29 additions & 0 deletions boatstack/flow/skillprojection/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package skillprojection

import (
"strings"
"testing"
)

func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) {
// control-law: generated-skills-report-bootstrap-recovery-without-executing-it
contract := BootstrapContract("v9.9.10")
for _, expected := range []string{
"command -v boatstack", "Get-Command boatstack", ".boatstack/runtime.json",
"BOATSTACK_LAUNCHER_NOT_FOUND", "BOATSTACK_RUNTIME_PIN_MISSING",
"BOATSTACK_RUNTIME_PIN_INVALID", "explicit approval", "Never run it",
"creates no\nFlow run ID", "resume this same requested entry",
"BOATSTACK_MODE=hydrate", "BOATSTACK_VERSION=<exact-version>",
"BOATSTACK_EXPECTED_RUNTIME_SHA256=<exact-sha256>", "/v9.9.10/install.sh",
} {
if !strings.Contains(contract, expected) {
t.Fatalf("bootstrap contract lacks %q", expected)
}
}
if strings.Contains(contract, "BOATSTACK_VERSION=latest") {
t.Fatal("bootstrap contract permits mutable latest selection")
}
if strings.Contains(contract, "BOATSTACK_MODE=update") {
t.Fatal("bootstrap contract permits repository mutation during runtime recovery")
}
}
6 changes: 5 additions & 1 deletion boatstack/flow/softwaredelivery/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"

"github.com/operatorstack/boatstack/boatstack/controlprogram"
"github.com/operatorstack/boatstack/boatstack/flow/skillprojection"
"github.com/operatorstack/boatstack/boatstack/internal/buildinfo"
)

func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) {
Expand Down Expand Up @@ -91,6 +93,8 @@ description: %q
Run the repository-owned Flow %q entry %q until its marked target %q is reached.
Boatstack does not interpret the entry name.

%s

Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`.
Preserve the returned program fingerprint, entry, run ID, delivery, repository,
worktree, host, actor, authority receipts, prescription, and receipts through
Expand All @@ -106,7 +110,7 @@ background while input is missing. Never synthesize authority.
Stop only when Boatstack reports the marked target, a typed blocker, refusal,
unresolved recovery, or missing authority. This entry grants no merge or deploy
authority.
`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, delegation, supersession))
`, 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))
}

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