diff --git a/labkit/src/labkit/gen_workflows.py b/labkit/src/labkit/gen_workflows.py index 37eaa713c..f7ff33f63 100644 --- a/labkit/src/labkit/gen_workflows.py +++ b/labkit/src/labkit/gen_workflows.py @@ -263,8 +263,18 @@ def render_sync_upstream(config: PublishConfig) -> str: --body-file "$body_file")" echo "Opened generated PR: $pr_url" else + pr_url="$existing" echo "Updated existing PR: $existing" fi + required_checks="$(gh api \ + "repos/{config.target_slug}/branches/main/protection/required_status_checks" \ + --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')" + if [[ "$required_checks" -lt 1 ]]; then + echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2 + exit 1 + fi + gh pr merge "$pr_url" --auto --squash + echo "Native auto-merge requested; branch protection owns merge eligibility." """ ) @@ -457,17 +467,9 @@ def _release_tail_goreleaser() -> str: def render_verify_automerge(config: PublishConfig) -> str: """Render the public-repo ``verify.yml`` for labs that opt in via ``sync.auto_merge``. - This systematizes Boatstack's hand-authored ``ci.yml`` "auto-merge-sync" job: - a cross-platform Go ``test`` matrix runs on every PR, and a second job merges - the sync PR — but only once the tests pass and the projection provenance - checks out (bot-authored, from the expected source repo, on the expected - ``sync/intelligence-flow-`` branch). - - The merge uses the **GitHub-App token**, not the default ``GITHUB_TOKEN``: a - ``GITHUB_TOKEN`` merge would not trigger downstream workflows, so the App - token is what lets the merge-to-``main`` fire the release workflow. Because - the projection token deliberately cannot rewrite ``.github``, this is a - *staged* artifact — installed into the public repo as a bootstrap step. + A cross-platform Go matrix and a provenance job are required checks. The sync + workflow asks GitHub for native auto-merge as the publisher App; branch + protection, not this workflow, owns the merge decision. """ manifest = config.downstream_manifest_path return ( @@ -503,7 +505,7 @@ def render_verify_automerge(config: PublishConfig) -> str: - run: go test ./... - run: go build ./... - auto-merge-sync: + verify-sync-provenance: if: >- github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && @@ -511,22 +513,11 @@ def render_verify_automerge(config: PublishConfig) -> str: needs: test runs-on: ubuntu-latest steps: - - name: Create publisher token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: {_token_expr("client-id", config)} - private-key: {_token_expr("private-key", config)} - owner: {config.target.owner} - repositories: {config.target.repo} - permission-contents: write - permission-pull-requests: write - uses: actions/checkout@v4 with: ref: ${{{{ github.event.pull_request.head.sha }}}} - name: Verify generated projection provenance env: - APP_SLUG: ${{{{ steps.app-token.outputs.app-slug }}}} HEAD_BRANCH: ${{{{ github.head_ref }}}} PR_AUTHOR: ${{{{ github.event.pull_request.user.login }}}} shell: bash @@ -534,14 +525,9 @@ def render_verify_automerge(config: PublishConfig) -> str: source_repo="$(jq -r '.source.repository' {manifest})" source_commit="$(jq -r '.source.commit' {manifest})" short="${{source_commit:0:12}}" - [[ "$PR_AUTHOR" == "${{APP_SLUG}}[bot]" ]] + [[ "$PR_AUTHOR" == "operator-stack-publisher[bot]" ]] [[ "$source_repo" == "{config.source.repo}" ]] [[ "$HEAD_BRANCH" == "sync/intelligence-flow-$short" ]] - - name: Merge verified sync PR - env: - GH_TOKEN: ${{{{ steps.app-token.outputs.token }}}} - PR_URL: ${{{{ github.event.pull_request.html_url }}}} - run: gh pr merge "$PR_URL" --squash """ ) diff --git a/labkit/src/labkit/project.py b/labkit/src/labkit/project.py index d83cd00f0..ce1470cf7 100644 --- a/labkit/src/labkit/project.py +++ b/labkit/src/labkit/project.py @@ -298,10 +298,34 @@ def _load_previous_manifest(repo: Path, projection: Projection) -> dict: return {} generator = projection.config.manifest.generator if generator and value.get("generator") != generator: + if _legacy_manifest_equivalent(repo, projection, value): + return { + "schema_version": value.get("schema_version"), + "generator": generator, + "files": value["files"], + "_legacy_adopted": True, + } return {} return value +def _legacy_manifest_equivalent(repo: Path, projection: Projection, value: dict) -> bool: + """Recognize a legacy surface manifest only when its ownership proof is exact.""" + if set(value) != {"schema_version", "files"} or value.get("schema_version") != 1: + return False + owned = value.get("files") + if not isinstance(owned, dict) or set(owned) != set(projection.files): + return False + for relative, content in projection.files.items(): + expected = sha256_bytes(content) + target = repo / relative + if owned.get(relative) != expected or not target.is_file(): + return False + if sha256_bytes(target.read_bytes()) != expected: + return False + return True + + def collisions(repo: Path, projection: Projection, adopt: bool) -> list[str]: if adopt: return [] diff --git a/labkit/tests/test_labkit_gen.py b/labkit/tests/test_labkit_gen.py index d3d220b58..007945a23 100644 --- a/labkit/tests/test_labkit_gen.py +++ b/labkit/tests/test_labkit_gen.py @@ -158,22 +158,25 @@ def test_auto_merge_is_opt_in(): def test_verify_automerge_gates_merge_on_tests_and_provenance(): - # The verify job must pass before the merge job runs, the merge must be - # scoped to bot-authored sync branches, and it must use the App token (not - # the default GITHUB_TOKEN) so the merge-to-main can trigger the release. + # Verification supplies required checks; the sync workflow requests native + # auto-merge as the App and branch protection owns eligibility. verify = render_verify_automerge(_config("21-interlock")) assert "go test ./..." in verify # Go verification gates the merge - assert "needs: test" in verify # merge waits for the test matrix - assert "gh pr merge" in verify and "--squash" in verify + assert "go build ./..." in verify + assert "needs: test" in verify + assert "gh pr merge" not in verify assert "startsWith(github.head_ref, 'sync/intelligence-flow-')" in verify - assert "create-github-app-token" in verify # merge runs as the publisher bot - # The merge step's token is the minted App token, never a bare GITHUB_TOKEN. - assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in verify - assert "secrets.GITHUB_TOKEN" not in verify # Provenance guard: bot author, expected source repo, matching sync branch. assert "'.source.repository' UPSTREAM.json" in verify assert '"$source_repo" == "operatorstack/intelligence-flow"' in verify - assert '"$PR_AUTHOR" == "${APP_SLUG}[bot]"' in verify + assert '"$PR_AUTHOR" == "operator-stack-publisher[bot]"' in verify + + sync = render_sync_upstream(_config("21-interlock")) + assert 'gh pr merge "$pr_url" --auto --squash' in sync + assert "branches/main/protection/required_status_checks" in sync + assert "refusing unsafe auto-merge" in sync + assert "permission-contents: write" in sync + assert "permission-pull-requests: write" in sync def test_auto_merge_requires_go_module(): diff --git a/labkit/tests/test_labkit_project.py b/labkit/tests/test_labkit_project.py index 6a0c878fe..bc8d39bc6 100644 --- a/labkit/tests/test_labkit_project.py +++ b/labkit/tests/test_labkit_project.py @@ -15,6 +15,7 @@ collisions, diff_repo, resolve_surface, + sha256_bytes, write_repo, ) @@ -182,6 +183,42 @@ def test_collision_when_downstream_file_not_owned(tmp_path): assert write_repo(repo, proj, adopt=True) == 0 # adopt bypasses +def test_equivalent_legacy_surface_manifest_is_adopted_automatically(tmp_path): + lab = _make_lab(tmp_path) + config = _projection_config([{"kind": "copy", "source": "README.md", "dest": "README.md"}], + generator="demo:gen") + proj = build_projection(config, lab, "a" * 40) + repo = tmp_path / "public" + repo.mkdir() + (repo / "README.md").write_bytes(proj.files["README.md"]) + legacy = {"schema_version": 1, "files": { + "README.md": sha256_bytes(proj.files["README.md"]), + }} + (repo / "UPSTREAM.json").write_text(json.dumps(legacy)) + + assert collisions(repo, proj, adopt=False) == [] + assert write_repo(repo, proj, adopt=False) == 0 + adopted = json.loads((repo / "UPSTREAM.json").read_text()) + assert adopted["generator"] == "demo:gen" + assert adopted["source"]["repository"] == "operatorstack/intelligence-flow" + + +def test_legacy_manifest_adoption_fails_closed_on_any_hash_mismatch(tmp_path): + lab = _make_lab(tmp_path) + config = _projection_config([{"kind": "copy", "source": "README.md", "dest": "README.md"}], + generator="demo:gen") + proj = build_projection(config, lab, "a" * 40) + repo = tmp_path / "public" + repo.mkdir() + (repo / "README.md").write_text("downstream edit\n") + legacy = {"schema_version": 1, "files": { + "README.md": sha256_bytes(proj.files["README.md"]), + }} + (repo / "UPSTREAM.json").write_text(json.dumps(legacy)) + + assert sorted(collisions(repo, proj, adopt=False)) == ["README.md", "UPSTREAM.json"] + + # --------------------------------------------------------------------------- # # Leak guard (fail-closed projection scan) # --------------------------------------------------------------------------- # diff --git a/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml b/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml index 064285c8d..2c9d9ee9b 100644 --- a/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml +++ b/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml @@ -127,5 +127,13 @@ jobs: --body-file "$body_file")" echo "Opened generated PR: $pr_url" else + pr_url="$existing" echo "Updated existing PR: $existing" fi + required_checks="$(gh api "repos/operatorstack/boatstack/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')" + if [[ "$required_checks" -lt 1 ]]; then + echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2 + exit 1 + fi + gh pr merge "$pr_url" --auto --squash + echo "Native auto-merge requested; branch protection owns merge eligibility." diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-protected-native-auto-merge.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-protected-native-auto-merge.md new file mode 100644 index 000000000..6a035ea8c --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-protected-native-auto-merge.md @@ -0,0 +1,12 @@ +### Upstream sync now defers merge eligibility to protected checks + +Boatstack's generated upstream workflow now asks GitHub for native auto-merge as +the publisher App instead of treating workflow code as the merge-policy engine. +The request fails closed unless `main` has required status checks, so a missing +branch-protection rule cannot turn a newly opened projection PR into an +unchecked merge. + +Concurrent mutation and operation locks also now recognize Windows' +`Access is denied` response as normal contention only when the lock file is +present. Real ACL failures still stop immediately, while duplicate workers wait +and converge on the same receipt as they do on Unix. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/mutation.go b/labs/12-product-engineering-loop/product-engineering-loop/mutation.go index 18f7fe9ed..fe944b822 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/mutation.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/mutation.go @@ -40,12 +40,12 @@ const ( // Sentinel errors let callers (and tests) distinguish deterministic refusals // from genuine I/O faults. Every refusal below leaves accepted state unchanged. var ( - ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion") - ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read") - ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized") + ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion") + ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read") + ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized") ErrMutationVerificationFailed = errors.New("mutation rolled back: post-write verification failed") - ErrMutationScope = errors.New("mutation operation falls outside its declared scope") - ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image") + ErrMutationScope = errors.New("mutation operation falls outside its declared scope") + ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image") ) // MutationOperation is a single file change within a transaction. Candidate holds @@ -170,7 +170,7 @@ func withMutationLock(repo, id string, apply func() error) error { defer os.Remove(lock) return apply() } - if !os.IsExist(openErr) { + if !isLockContention(openErr, lock) { return openErr } if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/operation.go b/labs/12-product-engineering-loop/product-engineering-loop/operation.go index c1f50ceec..9e15f4959 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/operation.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/operation.go @@ -246,7 +246,7 @@ func withOperationLock(repo, id string, apply func() error) error { defer os.Remove(lock) return apply() } - if !os.IsExist(openErr) { + if !isLockContention(openErr, lock) { return openErr } if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute { @@ -258,6 +258,20 @@ func withOperationLock(repo, id string, apply func() error) error { return fmt.Errorf("operation %s is busy", id) } +// Windows can report ERROR_ACCESS_DENIED when another process owns an O_EXCL +// lock file. Treat that as contention only when the lock path actually exists; +// genuine directory/ACL permission failures still fail closed. +func isLockContention(openErr error, lock string) bool { + if os.IsExist(openErr) { + return true + } + if !os.IsPermission(openErr) { + return false + } + _, statErr := os.Stat(lock) + return statErr == nil +} + func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) { repo, err := ResolveRepository(options.Repo) if err != nil { diff --git a/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml b/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml index dc133311c..8e447e160 100644 --- a/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml +++ b/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml @@ -109,5 +109,13 @@ jobs: --body-file "$body_file")" echo "Opened generated PR: $pr_url" else + pr_url="$existing" echo "Updated existing PR: $existing" fi + required_checks="$(gh api "repos/operatorstack/value-map/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')" + if [[ "$required_checks" -lt 1 ]]; then + echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2 + exit 1 + fi + gh pr merge "$pr_url" --auto --squash + echo "Native auto-merge requested; branch protection owns merge eligibility." diff --git a/labs/15-pitot/.labkit/generated/sync-upstream.yml b/labs/15-pitot/.labkit/generated/sync-upstream.yml index 737eb346d..34ac90405 100644 --- a/labs/15-pitot/.labkit/generated/sync-upstream.yml +++ b/labs/15-pitot/.labkit/generated/sync-upstream.yml @@ -109,5 +109,13 @@ jobs: --body-file "$body_file")" echo "Opened generated PR: $pr_url" else + pr_url="$existing" echo "Updated existing PR: $existing" fi + required_checks="$(gh api "repos/operatorstack/pitot/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')" + if [[ "$required_checks" -lt 1 ]]; then + echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2 + exit 1 + fi + gh pr merge "$pr_url" --auto --squash + echo "Native auto-merge requested; branch protection owns merge eligibility." diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-protected-native-auto-merge.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-protected-native-auto-merge.md new file mode 100644 index 000000000..1bfe658cd --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-protected-native-auto-merge.md @@ -0,0 +1,6 @@ +### Upstream sync now requires protected native auto-merge + +Pitot's generated upstream workflow now leaves merge eligibility to GitHub's +required checks while the publisher App remains the authenticated change actor. +It refuses to request auto-merge when `main` has no required status checks, +making missing repository protection a visible, fail-closed configuration error. diff --git a/labs/21-interlock/.labkit/generated/sync-upstream.yml b/labs/21-interlock/.labkit/generated/sync-upstream.yml index d8d7bc5e8..f97e49504 100644 --- a/labs/21-interlock/.labkit/generated/sync-upstream.yml +++ b/labs/21-interlock/.labkit/generated/sync-upstream.yml @@ -109,5 +109,13 @@ jobs: --body-file "$body_file")" echo "Opened generated PR: $pr_url" else + pr_url="$existing" echo "Updated existing PR: $existing" fi + required_checks="$(gh api "repos/operatorstack/interlock/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')" + if [[ "$required_checks" -lt 1 ]]; then + echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2 + exit 1 + fi + gh pr merge "$pr_url" --auto --squash + echo "Native auto-merge requested; branch protection owns merge eligibility." diff --git a/labs/21-interlock/.labkit/generated/verify.yml b/labs/21-interlock/.labkit/generated/verify.yml index 426f0b0c9..817e860ee 100644 --- a/labs/21-interlock/.labkit/generated/verify.yml +++ b/labs/21-interlock/.labkit/generated/verify.yml @@ -31,7 +31,7 @@ jobs: - run: go test ./... - run: go build ./... - auto-merge-sync: + verify-sync-provenance: if: >- github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && @@ -39,22 +39,11 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - name: Create publisher token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.OPERATOR_STACK_PUBLISHER_APP_CLIENT_ID || vars.BOATSTACK_APP_CLIENT_ID }} - private-key: ${{ secrets.OPERATOR_STACK_PUBLISHER_APP_PRIVATE_KEY || secrets.BOATSTACK_APP_PRIVATE_KEY }} - owner: operatorstack - repositories: interlock - permission-contents: write - permission-pull-requests: write - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Verify generated projection provenance env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} HEAD_BRANCH: ${{ github.head_ref }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} shell: bash @@ -62,11 +51,6 @@ jobs: source_repo="$(jq -r '.source.repository' UPSTREAM.json)" source_commit="$(jq -r '.source.commit' UPSTREAM.json)" short="${source_commit:0:12}" - [[ "$PR_AUTHOR" == "${APP_SLUG}[bot]" ]] + [[ "$PR_AUTHOR" == "operator-stack-publisher[bot]" ]] [[ "$source_repo" == "operatorstack/intelligence-flow" ]] [[ "$HEAD_BRANCH" == "sync/intelligence-flow-$short" ]] - - name: Merge verified sync PR - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_URL: ${{ github.event.pull_request.html_url }} - run: gh pr merge "$PR_URL" --squash diff --git a/labs/21-interlock/interlock/cmd/interlock/main.go b/labs/21-interlock/interlock/cmd/interlock/main.go index 0cc10ec21..bec1e03a6 100644 --- a/labs/21-interlock/interlock/cmd/interlock/main.go +++ b/labs/21-interlock/interlock/cmd/interlock/main.go @@ -35,6 +35,8 @@ func main() { err = cmdInstall(os.Args[2:]) case "upgrade": err = cmdUpgrade(os.Args[2:]) + case "__apply-upgrade": + err = cmdApplyUpgrade(os.Args[2:]) case "derive": err = cmdDerive(os.Args[2:]) case "compile": diff --git a/labs/21-interlock/interlock/cmd/interlock/upgrade.go b/labs/21-interlock/interlock/cmd/interlock/upgrade.go index 159f54ff4..ac909872c 100644 --- a/labs/21-interlock/interlock/cmd/interlock/upgrade.go +++ b/labs/21-interlock/interlock/cmd/interlock/upgrade.go @@ -1,18 +1,12 @@ package main import ( - "archive/tar" - "bytes" - "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" - "os" - "path/filepath" - "runtime" "strconv" "strings" "time" @@ -126,9 +120,6 @@ func cmdUpgrade(args []string) error { fmt.Printf("a newer interlock is available: %s -> %s (run: interlock upgrade)\n", current, latest) return nil } - if runtime.GOOS == "windows" { - return fmt.Errorf("upgrade: automatic upgrade is unix-only; on Windows re-run the install.ps1 installer for %s", latest) - } if !yes { fmt.Printf("upgrading interlock %s -> %s\n", current, latest) } @@ -138,7 +129,7 @@ func cmdUpgrade(args []string) error { // downloadAndReplace fetches the OS/arch archive + checksums for version, verifies // the SHA-256 in-process, and atomically replaces the running executable. func downloadAndReplace(host, version string) error { - archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", binaryName, version, runtime.GOOS, runtime.GOARCH) + archive := upgradeArchiveName(version) base := fmt.Sprintf("%s/%s/dl/%s", getBaseURL(host), binaryName, version) archiveBytes, err := httpGetBytes(base + "/" + archive) @@ -158,35 +149,11 @@ func downloadAndReplace(host, version string) error { return fmt.Errorf("upgrade: checksum mismatch for %s (want %s, got %s) — refusing", archive, want, got) } - bin, err := extractBinary(archiveBytes, binaryName) + bin, err := extractUpgradeBinary(archiveBytes) if err != nil { return fmt.Errorf("upgrade: %w", err) } - - self, err := os.Executable() - if err != nil { - return err - } - if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { - self = resolved - } - dir := filepath.Dir(self) - tmp := filepath.Join(dir, "."+binaryName+".upgrade") - if err := os.WriteFile(tmp, bin, 0o755); err != nil { - if os.IsPermission(err) { - return fmt.Errorf("upgrade: cannot write to %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", dir, resolveGetHost(host), binaryName) - } - return err - } - if err := os.Rename(tmp, self); err != nil { - os.Remove(tmp) - if os.IsPermission(err) { - return fmt.Errorf("upgrade: cannot replace %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", self, resolveGetHost(host), binaryName) - } - return err - } - fmt.Printf("upgraded to interlock %s at %s\n", version, self) - return nil + return applyUpgrade(bin, version, host) } // checksumFor returns the hex digest listed for name in a "sha name" manifest. @@ -199,26 +166,3 @@ func checksumFor(manifest, name string) string { } return "" } - -// extractBinary returns the named regular file from a .tar.gz archive. -func extractBinary(data []byte, name string) ([]byte, error) { - gzr, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("open archive: %w", err) - } - defer gzr.Close() - tr := tar.NewReader(gzr) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, fmt.Errorf("read archive: %w", err) - } - if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == name { - return io.ReadAll(tr) - } - } - return nil, fmt.Errorf("binary %q not found in archive", name) -} diff --git a/labs/21-interlock/interlock/cmd/interlock/upgrade_unix.go b/labs/21-interlock/interlock/cmd/interlock/upgrade_unix.go new file mode 100644 index 000000000..672831952 --- /dev/null +++ b/labs/21-interlock/interlock/cmd/interlock/upgrade_unix.go @@ -0,0 +1,68 @@ +//go:build !windows + +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +func upgradeArchiveName(version string) string { + return fmt.Sprintf("%s_%s_%s_%s.tar.gz", binaryName, version, runtime.GOOS, runtime.GOARCH) +} + +func extractUpgradeBinary(data []byte) ([]byte, error) { + gzr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + defer gzr.Close() + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read archive: %w", err) + } + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == binaryName { + return io.ReadAll(tr) + } + } + return nil, fmt.Errorf("binary %q not found in archive", binaryName) +} + +func applyUpgrade(bin []byte, version, host string) error { + self, err := os.Executable() + if err != nil { + return err + } + if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { + self = resolved + } + dir := filepath.Dir(self) + tmp := filepath.Join(dir, "."+binaryName+".upgrade") + if err := os.WriteFile(tmp, bin, 0o755); err != nil { + if os.IsPermission(err) { + return fmt.Errorf("upgrade: cannot write to %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", dir, resolveGetHost(host), binaryName) + } + return err + } + if err := os.Rename(tmp, self); err != nil { + os.Remove(tmp) + return fmt.Errorf("upgrade: replace %s: %w", self, err) + } + fmt.Printf("upgraded to interlock %s at %s\n", version, self) + return nil +} + +func cmdApplyUpgrade([]string) error { + return fmt.Errorf("internal Windows upgrade helper is unavailable") +} diff --git a/labs/21-interlock/interlock/cmd/interlock/upgrade_windows.go b/labs/21-interlock/interlock/cmd/interlock/upgrade_windows.go new file mode 100644 index 000000000..fafe01a32 --- /dev/null +++ b/labs/21-interlock/interlock/cmd/interlock/upgrade_windows.go @@ -0,0 +1,185 @@ +//go:build windows + +package main + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "syscall" + "time" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procWaitForSingleObj = kernel32.NewProc("WaitForSingleObject") + procCloseHandle = kernel32.NewProc("CloseHandle") + procReplaceFile = kernel32.NewProc("ReplaceFileW") + procMoveFileEx = kernel32.NewProc("MoveFileExW") +) + +const ( + synchronize = 0x00100000 + waitObject0 = 0 + replaceWriteThrough = 0x00000001 + moveFileDelayUntilReboot = 0x00000004 +) + +func upgradeArchiveName(version string) string { + return fmt.Sprintf("%s_%s_windows_%s.zip", binaryName, version, runtime.GOARCH) +} + +func extractUpgradeBinary(data []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + for _, file := range zr.File { + if filepath.Base(file.Name) != binaryName+".exe" { + continue + } + reader, err := file.Open() + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) + } + return nil, fmt.Errorf("binary %q not found in archive", binaryName+".exe") +} + +func applyUpgrade(bin []byte, version, _ string) error { + self, err := os.Executable() + if err != nil { + return err + } + if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { + self = resolved + } + dir := filepath.Dir(self) + staged := filepath.Join(dir, "."+binaryName+".upgrade.exe") + backup := filepath.Join(dir, "."+binaryName+".backup.exe") + _ = os.Remove(backup) + if err := os.WriteFile(staged, bin, 0o755); err != nil { + return fmt.Errorf("upgrade: stage replacement: %w", err) + } + helper, err := copyUpgradeHelper(self) + if err != nil { + os.Remove(staged) + return err + } + result := os.Getenv("INTERLOCK_UPGRADE_RESULT") + cmd := exec.Command(helper, "__apply-upgrade", + strconv.Itoa(os.Getpid()), staged, self, backup, result, helper) + if err := cmd.Start(); err != nil { + os.Remove(staged) + os.Remove(helper) + return fmt.Errorf("upgrade: start replacement helper: %w", err) + } + if err := cmd.Process.Release(); err != nil { + return fmt.Errorf("upgrade: detach replacement helper: %w", err) + } + fmt.Printf("interlock %s upgrade safely scheduled for %s\n", version, self) + return nil +} + +func copyUpgradeHelper(self string) (string, error) { + src, err := os.Open(self) + if err != nil { + return "", err + } + defer src.Close() + dst, err := os.CreateTemp("", "interlock-upgrade-helper-*.exe") + if err != nil { + return "", err + } + name := dst.Name() + if _, err = io.Copy(dst, src); err != nil { + dst.Close() + os.Remove(name) + return "", err + } + if err = dst.Close(); err != nil { + os.Remove(name) + return "", err + } + return name, nil +} + +func cmdApplyUpgrade(args []string) error { + if len(args) != 6 { + return fmt.Errorf("invalid internal upgrade request") + } + parentPID, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + staged, target, backup, result, helper := args[1], args[2], args[3], args[4], args[5] + err = waitForProcess(parentPID, 30*time.Second) + if err == nil { + err = replaceFile(target, staged, backup) + } + if err == nil { + os.Remove(backup) + } + writeUpgradeResult(result, err) + os.Remove(staged) + scheduleDelete(helper) + return err +} + +func waitForProcess(pid int, timeout time.Duration) error { + handle, _, callErr := procOpenProcess.Call(synchronize, 0, uintptr(uint32(pid))) + if handle == 0 { + if errno, ok := callErr.(syscall.Errno); ok && errno == syscall.Errno(87) { + return nil // the parent exited before the helper opened its handle + } + return fmt.Errorf("open parent process: %w", callErr) + } + defer procCloseHandle.Call(handle) + status, _, callErr := procWaitForSingleObj.Call(handle, uintptr(timeout/time.Millisecond)) + if status != waitObject0 { + return fmt.Errorf("wait for parent process: status %d: %w", status, callErr) + } + return nil +} + +func replaceFile(target, staged, backup string) error { + targetp, _ := syscall.UTF16PtrFromString(target) + stagedp, _ := syscall.UTF16PtrFromString(staged) + backupp, _ := syscall.UTF16PtrFromString(backup) + ok, _, callErr := procReplaceFile.Call( + uintptr(unsafe.Pointer(targetp)), + uintptr(unsafe.Pointer(stagedp)), + uintptr(unsafe.Pointer(backupp)), + replaceWriteThrough, 0, 0, + ) + if ok == 0 { + return fmt.Errorf("atomic replace: %w", callErr) + } + return nil +} + +func writeUpgradeResult(path string, err error) { + if path == "" { + return + } + value := "ok\n" + if err != nil { + value = "error: " + err.Error() + "\n" + } + _ = os.WriteFile(path, []byte(value), 0o600) +} + +func scheduleDelete(path string) { + pathp, _ := syscall.UTF16PtrFromString(path) + procMoveFileEx.Call(uintptr(unsafe.Pointer(pathp)), 0, moveFileDelayUntilReboot) +} diff --git a/labs/21-interlock/interlock/e2e/coverage_test.go b/labs/21-interlock/interlock/e2e/coverage_test.go index 1461ed720..4a5758ae7 100644 --- a/labs/21-interlock/interlock/e2e/coverage_test.go +++ b/labs/21-interlock/interlock/e2e/coverage_test.go @@ -64,8 +64,8 @@ func TestEveryCommandHasE2E(t *testing.T) { for _, m := range caseLabel.FindAllStringSubmatch(dispatch, -1) { for _, q := range quoted.FindAllStringSubmatch(m[1], -1) { label := q[1] - if strings.HasPrefix(label, "-") || label == "help" { - continue // alias flags and help are not commands + if strings.HasPrefix(label, "-") || label == "help" || strings.HasPrefix(label, "__") { + continue // alias flags, help, and private helper entrypoints are not public commands } commands[label] = true } diff --git a/labs/21-interlock/interlock/e2e/upgrade_test.go b/labs/21-interlock/interlock/e2e/upgrade_test.go index 31f3218d6..9bb55765d 100644 --- a/labs/21-interlock/interlock/e2e/upgrade_test.go +++ b/labs/21-interlock/interlock/e2e/upgrade_test.go @@ -10,6 +10,7 @@ package e2e import ( "archive/tar" + "archive/zip" "bytes" "compress/gzip" "crypto/sha256" @@ -23,25 +24,53 @@ import ( "runtime" "strings" "testing" + "time" ) // stubFrontDoor serves /interlock/latest and /interlock/dl//{archive,checksums.txt} // for the given version, with a tar.gz whose "interlock" entry contains payload. func stubFrontDoor(t *testing.T, version string, payload []byte) *httptest.Server { - t.Helper() - archive := fmt.Sprintf("interlock_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) + return stubFrontDoorWithChecksum(t, version, payload, true) +} +func stubFrontDoorWithChecksum(t *testing.T, version string, payload []byte, valid bool) *httptest.Server { + t.Helper() var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - if err := tw.WriteHeader(&tar.Header{Name: "interlock", Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil { - t.Fatal(err) + archive := fmt.Sprintf("interlock_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + archive = fmt.Sprintf("interlock_%s_windows_%s.zip", version, runtime.GOARCH) + zw := zip.NewWriter(&buf) + entry, err := zw.Create("interlock.exe") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(payload); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + } else { + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "interlock", Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(payload); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } } - tw.Write(payload) - tw.Close() - gz.Close() tgz := buf.Bytes() sum := sha256.Sum256(tgz) + if !valid { + sum[0] ^= 0xff + } checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), archive) mux := http.NewServeMux() @@ -78,6 +107,9 @@ func TestJourney_Upgrade(t *testing.T) { // Copy the test binary so os.Executable() points at a throwaway target. dir := t.TempDir() target := filepath.Join(dir, "interlock") + if runtime.GOOS == "windows" { + target += ".exe" + } src, err := os.ReadFile(interlockBin) if err != nil { t.Fatal(err) @@ -86,10 +118,28 @@ func TestJourney_Upgrade(t *testing.T) { t.Fatal(err) } cmd := exec.Command(target, "upgrade", "--host", srv.URL, "--yes") + result := filepath.Join(dir, "upgrade-result") + cmd.Env = append(os.Environ(), "INTERLOCK_UPGRADE_RESULT="+result) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("upgrade failed: %v\n%s", err, out) } + if runtime.GOOS == "windows" { + deadline := time.Now().Add(10 * time.Second) + for { + status, readErr := os.ReadFile(result) + if readErr == nil { + if string(status) != "ok\n" { + t.Fatalf("upgrade helper failed: %s", status) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for upgrade helper: %v", readErr) + } + time.Sleep(25 * time.Millisecond) + } + } got, err := os.ReadFile(target) if err != nil { t.Fatal(err) @@ -106,4 +156,32 @@ func TestJourney_Upgrade(t *testing.T) { t.Fatal("upgrade against an unreachable host should fail") } }) + + t.Run("checksum mismatch fails before replacing the binary", func(t *testing.T) { + bad := stubFrontDoorWithChecksum(t, "9.9.10", []byte("UNTRUSTED\n"), false) + dir := t.TempDir() + target := filepath.Join(dir, "interlock") + if runtime.GOOS == "windows" { + target += ".exe" + } + original, err := os.ReadFile(interlockBin) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, original, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(target, "upgrade", "--host", bad.URL, "--yes") + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "checksum mismatch") { + t.Fatalf("want checksum failure, got err=%v output=%s", err, out) + } + after, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, original) { + t.Fatal("checksum failure modified the installed binary") + } + }) }