feat(installer): add transactional P0-P9 state machine
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# C1 expected-RED gate. The fixture must execute from zero and discriminate the
|
||||||
|
# known failed postconditions; this step is green only when the fixture itself
|
||||||
|
# returns the expected non-zero and the named evidence rows are present.
|
||||||
|
when:
|
||||||
|
- event: [pull_request, manual]
|
||||||
|
- event: push
|
||||||
|
branch: [next, main]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
greenfield-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||||
|
> /tmp/greenfield-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-present.log
|
||||||
|
test "$fixture_status" -eq 1
|
||||||
|
grep -Eq '^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P5\] FAIL:' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P6\] FAIL:' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P8\] FAIL:' /tmp/greenfield-git-present.log
|
||||||
|
grep -q '^\[P9\] FAIL:' /tmp/greenfield-git-present.log
|
||||||
|
|
||||||
|
greenfield-main-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||||
|
> /tmp/greenfield-main-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-main-git-present.log
|
||||||
|
test "$fixture_status" -eq 1
|
||||||
|
grep -Eq '^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P1\] PASS: required tools present (including downstream git)' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P5\] FAIL:' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P6\] FAIL:' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P8\] FAIL:' /tmp/greenfield-main-git-present.log
|
||||||
|
grep -q '^\[P9\] FAIL:' /tmp/greenfield-main-git-present.log
|
||||||
|
|
||||||
|
greenfield-git-absent:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||||
|
> /tmp/greenfield-git-absent.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-absent.log
|
||||||
|
test "$fixture_status" -eq 1
|
||||||
|
grep -q '^\[fixture\] installer_exit=1 done_claims=0' /tmp/greenfield-git-absent.log
|
||||||
|
grep -q '^\[P1\] FAIL: undeclared/missing prerequisite(s)=git;' /tmp/greenfield-git-absent.log
|
||||||
|
grep -q '^\[P3\] FAIL: .*executable=no' /tmp/greenfield-git-absent.log
|
||||||
|
if grep -q 'Done\.' /tmp/greenfield-git-absent.log; then
|
||||||
|
echo 'git-absent state-machine run falsely certified Done' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
Or use the direct URL:
|
||||||
@@ -33,12 +33,12 @@ This installs both components:
|
|||||||
### Install lanes
|
### Install lanes
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
| Lane | Command | Use when | Source |
|
||||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||||
|
|
||||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
@@ -48,10 +48,14 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- Node.js ≥ 20 and npm ≥ 9
|
||||||
|
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||||
|
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||||
|
|
||||||
|
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Launching Agent Sessions
|
### Launching Agent Sessions
|
||||||
@@ -347,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
Or use the direct URL:
|
||||||
@@ -368,17 +372,17 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1379,7 +1379,7 @@ A from-zero install can report success while leaving the target host unusable be
|
|||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit.
|
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||||
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||||
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||||
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||||
|
|
||||||
|
## Installation and upgrades
|
||||||
|
|
||||||
|
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||||
|
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||||
|
|
||||||
## CLI and skill management
|
## CLI and skill management
|
||||||
|
|
||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Installer State Machine and Recovery
|
||||||
|
|
||||||
|
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||||
|
|
||||||
|
## Canonical phases
|
||||||
|
|
||||||
|
| Phase | Responsibility | Failure disposition |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||||
|
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||||
|
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||||
|
| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot |
|
||||||
|
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||||
|
| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding |
|
||||||
|
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||||
|
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||||
|
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||||
|
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||||
|
|
||||||
|
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||||
|
|
||||||
|
## Side-effect-free check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/install.sh --check # stable/latest lane
|
||||||
|
bash tools/install.sh --check --next # prerelease lane
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check`:
|
||||||
|
|
||||||
|
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||||
|
- exits non-zero if any predicate fails;
|
||||||
|
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||||
|
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||||
|
|
||||||
|
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||||
|
|
||||||
|
## Durable journal
|
||||||
|
|
||||||
|
Each mutating run creates a private transaction directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||||
|
active.json
|
||||||
|
<UTC-run-id>/
|
||||||
|
journal.ndjson
|
||||||
|
journal.ndjson.sha256 # committed runs only
|
||||||
|
commands.log
|
||||||
|
snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
Before each mutation scope is touched, `journal.ndjson` records:
|
||||||
|
|
||||||
|
- phase and path;
|
||||||
|
- whether prior state existed and where its snapshot lives;
|
||||||
|
- the reversal action;
|
||||||
|
- the captured command-output location and command status.
|
||||||
|
|
||||||
|
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||||
|
|
||||||
|
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||||
|
|
||||||
|
`active.json` is the current projection:
|
||||||
|
|
||||||
|
- `in-progress`: incomplete/open transaction;
|
||||||
|
- `rolled-back`: a fault restored the snapshot;
|
||||||
|
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||||
|
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||||
|
- `committed`: P9 passed and the journal is sealed.
|
||||||
|
|
||||||
|
## Failure recovery
|
||||||
|
|
||||||
|
1. Read the named phase and remediation line from installer stderr.
|
||||||
|
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||||
|
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||||
|
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||||
|
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||||
|
|
||||||
|
## Greenfield CI gate
|
||||||
|
|
||||||
|
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||||
|
|
||||||
|
The C1 gate intentionally validates an attributable RED while C2–C5 remain open:
|
||||||
|
|
||||||
|
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||||
|
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||||
|
|
||||||
|
The fixture is lane-parametric:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/e2e-install-test.sh --lane next --git present
|
||||||
|
bash tools/e2e-install-test.sh --lane main --git present
|
||||||
|
```
|
||||||
|
|
||||||
|
CI exercises both lane parameters as expected-RED structural checks. The authoritative main-lane promotion acceptance and issue closure remain owned by #1037.
|
||||||
|
|
||||||
|
## Source trust boundary
|
||||||
|
|
||||||
|
Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture.
|
||||||
@@ -12,6 +12,20 @@ with no snapshot to fall back to.
|
|||||||
Protection is layered. Each layer is independent; a later layer catches what an
|
Protection is layered. Each layer is independent; a later layer catches what an
|
||||||
earlier one misses.
|
earlier one misses.
|
||||||
|
|
||||||
|
## Layer 0 — Transaction journal (install-wide recovery)
|
||||||
|
|
||||||
|
The unified installer opens a private journal under
|
||||||
|
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||||
|
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||||
|
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||||
|
seals successful journals with a SHA-256 sidecar. See
|
||||||
|
[Installer state machine and recovery](./installer-state-machine.md).
|
||||||
|
|
||||||
|
This transaction journal is distinct from the retained operator-only backup
|
||||||
|
below. The transaction journal is required for correctness and rollback;
|
||||||
|
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||||
|
for a manifest bug that the normal transaction did not detect.
|
||||||
|
|
||||||
## Layer 1 — Manifest-owned sync (prevention)
|
## Layer 1 — Manifest-owned sync (prevention)
|
||||||
|
|
||||||
The single source of truth for ownership is
|
The single source of truth for ownership is
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–
|
|||||||
|
|
||||||
## Authority and scope
|
## Authority and scope
|
||||||
|
|
||||||
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`, read from local `origin/main` object `b2b6ed41f5aff5ea964e69b7c701cb45718742fa`; remote currency is **unestablished** because authenticated fetch returned repository-not-found.
|
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||||
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||||
- Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
|
- Base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
|
||||||
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||||
@@ -40,23 +40,29 @@ Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–
|
|||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] Charter, doctrine, delivery/CI/QA/docs guides read.
|
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||||
- [x] Canonical PRD v2 and charters read from local origin object; numbering reconciles with the TL spec. No numbering conflict found. TL additions (early durable journal and INV-C) are additive, not contradictory.
|
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||||
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||||
- [x] Issue #1050 created and provider author read back.
|
- [x] Issue #1050 created and provider author read back.
|
||||||
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||||
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||||
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||||
- [ ] Corrected RED transcript captured and reported.
|
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||||
- [ ] State machine implemented.
|
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||||
- [ ] Reviews complete.
|
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance.
|
||||||
|
- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required.
|
||||||
|
|
||||||
## Risks / blockers
|
## Risks / blockers
|
||||||
|
|
||||||
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||||
- `next` is an integration lane; `main` promotion remains #1037-owned.
|
- `next` is an integration lane; `main` promotion remains #1037-owned.
|
||||||
- #869 must remain staged and inactive.
|
- #869 must remain staged and inactive.
|
||||||
|
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||||
|
|
||||||
## Verification log
|
## Verification log
|
||||||
|
|
||||||
(To be updated with exact commands and resulting objects.)
|
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||||
|
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization.
|
||||||
|
- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch.
|
||||||
|
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised.
|
||||||
|
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ done
|
|||||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||||
# shellcheck source=tools/_lib/manifest.sh
|
# shellcheck source=tools/_lib/manifest.sh
|
||||||
|
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||||
|
|
||||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||||
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
|
|||||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||||
list="$(mktemp)"
|
list="$(mktemp)"
|
||||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||||
|
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||||
# than risk pruning in an undefined order.
|
# than risk pruning in an undefined order.
|
||||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||||
|
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r d; do
|
while IFS= read -r d; do
|
||||||
@@ -266,7 +269,11 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
chmod 700 "$root" 2>/dev/null || true
|
if ! chmod 700 "$root"; then
|
||||||
|
umask "$old_umask"
|
||||||
|
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
dir="$root/pre-update-$ts"
|
dir="$root/pre-update-$ts"
|
||||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||||
@@ -281,7 +288,10 @@ make_durable_snapshot() {
|
|||||||
if ! enumerate_operator_files "$list"; then
|
if ! enumerate_operator_files "$list"; then
|
||||||
umask "$old_umask"
|
umask "$old_umask"
|
||||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
rm -f "$list"
|
||||||
|
if ! rmdir "$dir"; then
|
||||||
|
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r -d '' rel; do
|
while IFS= read -r -d '' rel; do
|
||||||
@@ -292,12 +302,18 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
chmod 600 "$dst" 2>/dev/null || true
|
if ! chmod 600 "$dst"; then
|
||||||
|
rm -f "$dst"
|
||||||
|
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
count=$((count + 1))
|
count=$((count + 1))
|
||||||
done < "$list"
|
done < "$list"
|
||||||
rm -f "$list"
|
rm -f "$list"
|
||||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||||
|
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||||
|
fi
|
||||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||||
DURABLE_SNAPSHOT_DIR="$dir"
|
DURABLE_SNAPSHOT_DIR="$dir"
|
||||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||||
@@ -344,7 +360,9 @@ verify_operator_surface() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if cp "$snap" "$cur"; then
|
if cp "$snap" "$cur"; then
|
||||||
chmod 600 "$cur" 2>/dev/null || true
|
if ! chmod 600 "$cur"; then
|
||||||
|
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||||
|
fi
|
||||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||||
healed=$((healed + 1))
|
healed=$((healed + 1))
|
||||||
else
|
else
|
||||||
@@ -535,7 +553,7 @@ sync_framework_keep() {
|
|||||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
if ! find "$dst/$root" -type d -empty -delete; then
|
||||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||||
fi
|
fi
|
||||||
done < <(manifest_subtree_roots)
|
done < <(manifest_subtree_roots)
|
||||||
@@ -581,7 +599,7 @@ run_migrations() {
|
|||||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||||
rm -rf "$TARGET_DIR/bin"
|
rm -rf "${TARGET_DIR:?}/bin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove old mosaic PATH entry from shell profiles
|
# Remove old mosaic PATH entry from shell profiles
|
||||||
@@ -706,13 +724,23 @@ mkdir -p "$TARGET_DIR/credentials"
|
|||||||
# by `mosaic init` from templates with user-supplied values.
|
# by `mosaic init` from templates with user-supplied values.
|
||||||
reconcile_framework_files
|
reconcile_framework_files
|
||||||
|
|
||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped shell tools executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped runtime scripts executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||||
|
fail "Could not mark git-credential-mosaic executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Framework synced to $TARGET_DIR"
|
ok "Framework synced to $TARGET_DIR"
|
||||||
|
|
||||||
@@ -739,49 +767,110 @@ step "Post-install tasks"
|
|||||||
|
|
||||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||||
|
|
||||||
|
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||||
|
# also appended to the parent transaction's private command log. Failure to
|
||||||
|
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||||
|
# INV-C forbids.
|
||||||
|
record_phase_outcome() {
|
||||||
|
local phase="$1" status="$2" reason="$3"
|
||||||
|
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||||
|
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||||
|
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_captured() {
|
||||||
|
local label="$1" output status=0
|
||||||
|
shift
|
||||||
|
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")"
|
||||||
|
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||||
|
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||||
|
cat "$output" >&2
|
||||||
|
rm -f "$output"
|
||||||
|
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi
|
||||||
|
rm -f "$output"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||||
link_args=()
|
link_args=()
|
||||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||||
# stdout is suppressed as before, but stderr is left connected: the
|
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||||
# the operator, not be swallowed silently.
|
|
||||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
|
||||||
ok "Runtime assets linked"
|
ok "Runtime assets linked"
|
||||||
else
|
else
|
||||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||||
|
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||||
|
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||||
ok "sequential-thinking MCP configured"
|
ok "sequential-thinking MCP configured"
|
||||||
else
|
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||||
else
|
else
|
||||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||||
|
ok "excalidraw MCP configured"
|
||||||
|
else
|
||||||
|
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||||
|
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||||
|
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||||
|
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||||
|
record_phase_outcome P4 committed "skills sync exited zero"
|
||||||
|
ok "Skills synced"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||||
|
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||||
|
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||||
|
ok "Local skills migrated"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||||
|
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||||
|
ok "Health audit passed"
|
||||||
|
else
|
||||||
|
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write version stamp AFTER everything succeeds
|
# The version stamp records the successfully committed framework file sync.
|
||||||
|
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||||
|
# by this stamp.
|
||||||
write_framework_version
|
write_framework_version
|
||||||
|
|
||||||
# ── Summary ──────────────────────────────────────────────────
|
# ── Summary ──────────────────────────────────────────────────
|
||||||
|
|||||||
+100
-15
@@ -14,6 +14,7 @@ SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
|||||||
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||||
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||||
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||||
|
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -39,38 +40,62 @@ case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expec
|
|||||||
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||||
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
if ! docker info >/dev/null 2>&1; then
|
if ! docker info >/dev/null 2>&1; then
|
||||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||||
exit 2
|
exit 2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
installer_b64=""
|
installer_b64=""
|
||||||
framework_payload_count="NOT-MEASURED"
|
framework_payload_count="NOT-MEASURED"
|
||||||
repo_root_count="NOT-MEASURED"
|
repo_root_count="NOT-MEASURED"
|
||||||
|
checkout_archive=""
|
||||||
|
checkout_digest=""
|
||||||
|
checkout_content_id=""
|
||||||
if [[ "$SOURCE" == "checkout" ]]; then
|
if [[ "$SOURCE" == "checkout" ]]; then
|
||||||
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||||
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||||
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
[[ -d "$ROOT/skills" ]] \
|
[[ -d "$ROOT/skills" ]] \
|
||||||
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
|
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")"
|
||||||
|
repo_parent="$(dirname "$ROOT")"
|
||||||
|
repo_name="$(basename "$ROOT")"
|
||||||
|
tar -C "$repo_parent" \
|
||||||
|
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||||
|
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||||
|
--exclude='*/.env' --exclude='*/.env.*' \
|
||||||
|
-czf "$checkout_archive" "$repo_name"
|
||||||
|
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||||
|
checkout_content_id="${checkout_digest:0:40}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
||||||
trap 'rm -f "$inner"' EXIT
|
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||||
cat > "$inner" <<'INNER'
|
cat > "$inner" <<'INNER'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
packages=(bash ca-certificates curl jq passwd util-linux)
|
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||||
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||||
apt-get install -y -qq "${packages[@]}" >/dev/null
|
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||||
|
|
||||||
|
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||||
|
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz
|
||||||
|
actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')"
|
||||||
|
if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||||
|
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||||
install -d -o mosaic -g mosaic /home/mosaic/work
|
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||||
|
|
||||||
@@ -130,7 +155,7 @@ fi
|
|||||||
|
|
||||||
# P1 Preflight
|
# P1 Preflight
|
||||||
missing_tools=()
|
missing_tools=()
|
||||||
for tool in bash curl git node npm tar; do
|
for tool in bash curl git node npm python3 tar; do
|
||||||
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||||
done
|
done
|
||||||
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||||
@@ -173,18 +198,34 @@ printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sy
|
|||||||
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||||
if [[ ! -s "$declared_set" ]]; then
|
if [[ ! -s "$declared_set" ]]; then
|
||||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||||
elif node - "$declared_set" <<'NODE'
|
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||||
|
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||||
if (!data || typeof data !== 'object' || !['latest', 'next'].includes(data.lane) ||
|
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||||
typeof data.version !== 'string' || !data.version || !Array.isArray(data.skills) || data.skills.length === 0 ||
|
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||||
data.skills.some((name) => typeof name !== 'string' || !name)) process.exit(1);
|
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||||
|
for (const name of data.skills) {
|
||||||
|
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||||
|
const skill = path.join(root, name, 'SKILL.md');
|
||||||
|
let real;
|
||||||
|
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||||
|
const text = fs.readFileSync(real, 'utf8');
|
||||||
|
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||||
|
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||||
|
}
|
||||||
NODE
|
NODE
|
||||||
then
|
then
|
||||||
declared_count="$(node -p "require('$declared_set').skills.length")"
|
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||||
phase_pass P4 "declared shipped-set artifact parses (declared_count=$declared_count); C5 owns containment/loadability fulfillment"
|
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||||
|
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||||
|
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||||
|
else
|
||||||
|
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: shipped-set artifact exists but is empty, malformed, or lacks lane/version"
|
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# P5 Identity
|
# P5 Identity
|
||||||
@@ -207,13 +248,20 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||||
|
manifest="$mosaic_home/.install-manifest.json"
|
||||||
broker_present=false
|
broker_present=false
|
||||||
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||||
dead_hooks=0
|
dead_hooks=0
|
||||||
if [[ -f "$home/.claude/settings.json" ]]; then
|
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||||
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||||
fi
|
fi
|
||||||
if [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
p6_action_failed=false
|
||||||
|
if [[ -s "$manifest" ]]; then
|
||||||
|
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||||
|
fi
|
||||||
|
if [[ "$p6_action_failed" == true ]]; then
|
||||||
|
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||||
|
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||||
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||||
elif [[ "$broker_present" == true ]]; then
|
elif [[ "$broker_present" == true ]]; then
|
||||||
phase_pass P6 "activation broker present; hook state is evaluable"
|
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||||
@@ -257,19 +305,56 @@ exec runuser -u mosaic -- env -i \
|
|||||||
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||||
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \
|
||||||
/bin/bash /tmp/run-as-target.sh
|
/bin/bash /tmp/run-as-target.sh
|
||||||
INNER
|
INNER
|
||||||
|
if [[ "$SOURCE" == "checkout" ]]; then
|
||||||
|
{
|
||||||
|
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||||
|
base64 "$checkout_archive"
|
||||||
|
} >> "$inner"
|
||||||
|
fi
|
||||||
chmod 0755 "$inner"
|
chmod 0755 "$inner"
|
||||||
|
|
||||||
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||||
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||||
|
|
||||||
docker run --rm -i \
|
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||||
|
# Woodpecker already supplies the clean Debian container. The target install
|
||||||
|
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||||
|
# enter the target user's process.
|
||||||
|
FIXTURE_LANE="$LANE" \
|
||||||
|
FIXTURE_SOURCE="$SOURCE" \
|
||||||
|
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||||
|
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||||
|
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||||
|
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||||
|
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||||
|
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||||
|
/bin/bash "$inner"
|
||||||
|
else
|
||||||
|
# Copy the self-contained script+archive into a stopped container instead of
|
||||||
|
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||||
|
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||||
|
# payload avoids argv/environment size limits.
|
||||||
|
fixture_cid="$(docker create \
|
||||||
--network bridge \
|
--network bridge \
|
||||||
--env FIXTURE_LANE="$LANE" \
|
--env FIXTURE_LANE="$LANE" \
|
||||||
--env FIXTURE_SOURCE="$SOURCE" \
|
--env FIXTURE_SOURCE="$SOURCE" \
|
||||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||||
|
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||||
|
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||||
"$IMAGE" /bin/bash -s < "$inner"
|
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||||
|
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||||
|
set +e
|
||||||
|
docker start -a "$fixture_cid"
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
docker rm "$fixture_cid" >/dev/null
|
||||||
|
exit "$fixture_status"
|
||||||
|
fi
|
||||||
|
|||||||
+152
-15
@@ -7,8 +7,8 @@ trap 'rm -rf "$TMP"' EXIT
|
|||||||
|
|
||||||
FAKE_BIN="$TMP/bin"
|
FAKE_BIN="$TMP/bin"
|
||||||
HOME_DIR="$TMP/home"
|
HOME_DIR="$TMP/home"
|
||||||
PREFIX="$TMP/prefix"
|
PREFIX="$HOME_DIR/prefix"
|
||||||
MOSAIC_HOME="$TMP/mosaic"
|
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||||
STATE="$TMP/state"
|
STATE="$TMP/state"
|
||||||
LOG="$TMP/npm.log"
|
LOG="$TMP/npm.log"
|
||||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||||
@@ -20,7 +20,27 @@ LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
|||||||
STATE="${MOSAIC_TEST_STATE:?}"
|
STATE="${MOSAIC_TEST_STATE:?}"
|
||||||
echo "$*" >> "$LOG"
|
echo "$*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "10.6.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_cli() {
|
||||||
|
local version="$1"
|
||||||
|
echo "$version" > "$STATE/mosaic"
|
||||||
|
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||||
|
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\\n' '$version'
|
||||||
|
CLI
|
||||||
|
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$1" == "view" ]]; then
|
if [[ "$1" == "view" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||||
|
echo "forced registry metadata failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
case "$2 $3" in
|
case "$2 $3" in
|
||||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||||
@@ -33,7 +53,7 @@ fi
|
|||||||
if [[ "$1" == "install" ]]; then
|
if [[ "$1" == "install" ]]; then
|
||||||
case "$*" in
|
case "$*" in
|
||||||
*"@mosaicstack/[email protected]"*)
|
*"@mosaicstack/[email protected]"*)
|
||||||
echo "0.0.49-next.999" > "$STATE/mosaic"
|
install_cli "0.0.49-next.999"
|
||||||
;;
|
;;
|
||||||
*"@mosaicstack/[email protected]"*)
|
*"@mosaicstack/[email protected]"*)
|
||||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||||
@@ -43,7 +63,7 @@ if [[ "$1" == "install" ]]; then
|
|||||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||||
;;
|
;;
|
||||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||||
echo "0.0.0-source" > "$STATE/mosaic"
|
install_cli "0.0.0-source"
|
||||||
;;
|
;;
|
||||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||||
echo "0.0.0-source" > "$STATE/gateway"
|
echo "0.0.0-source" > "$STATE/gateway"
|
||||||
@@ -75,21 +95,48 @@ chmod +x "$FAKE_BIN/npm"
|
|||||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
|
headers=""; output=""; url=""
|
||||||
exit 0
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) headers="$2"; shift 2 ;;
|
||||||
|
-o) output="$2"; shift 2 ;;
|
||||||
|
--max-filesize) shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||||
|
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||||
|
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||||
|
;;
|
||||||
|
*/archive/*.tar.gz)
|
||||||
|
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||||
|
printf 'not-a-tarball\n' > "$output"
|
||||||
|
else
|
||||||
|
archive_root="$(mktemp -d)"
|
||||||
|
mkdir -p "$archive_root/stack"
|
||||||
|
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||||
|
/bin/tar czf "$output" -C "$archive_root" stack
|
||||||
|
rm -rf "$archive_root"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
FAKE_CURL
|
FAKE_CURL
|
||||||
chmod +x "$FAKE_BIN/curl"
|
chmod +x "$FAKE_BIN/curl"
|
||||||
|
|
||||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
dest=""
|
dest=""; list=false
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-C) dest="$2"; shift 2 ;;
|
-C) dest="$2"; shift 2 ;;
|
||||||
|
-*t*|t*) list=true; shift ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
[[ "$list" == true ]] && exit 0
|
||||||
if [[ -z "$dest" ]]; then
|
if [[ -z "$dest" ]]; then
|
||||||
echo "fake tar missing -C destination" >&2
|
echo "fake tar missing -C destination" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -125,7 +172,12 @@ if [[ "$1" == "pack" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# install/build commands are no-ops in this harness.
|
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||||
|
echo "forced pnpm install failure" >&2
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Other install/build commands are no-ops in this harness.
|
||||||
exit 0
|
exit 0
|
||||||
FAKE_PNPM
|
FAKE_PNPM
|
||||||
chmod +x "$FAKE_BIN/pnpm"
|
chmod +x "$FAKE_BIN/pnpm"
|
||||||
@@ -135,6 +187,15 @@ reset_state() {
|
|||||||
rm -f "$STATE"/*
|
rm -f "$STATE"/*
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefix_fingerprint() {
|
||||||
|
if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi
|
||||||
|
(
|
||||||
|
cd "$PREFIX"
|
||||||
|
find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort
|
||||||
|
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum
|
||||||
|
) | sha256sum | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
echo "[test] --next fast path pins resolved package versions"
|
echo "[test] --next fast path pins resolved package versions"
|
||||||
OUTPUT="$(
|
OUTPUT="$(
|
||||||
@@ -155,11 +216,18 @@ if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
|||||||
echo "expected exact-version installs, found mutable @next install" >&2
|
echo "expected exact-version installs, found mutable @next install" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
|
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||||
echo "fast path unexpectedly fell back to source" >&2
|
echo "fast path unexpectedly fell back to source" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||||
|
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||||
|
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||||
|
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||||
|
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
echo "[test] fast path failure falls back to source build"
|
echo "[test] fast path failure falls back to source build"
|
||||||
OUTPUT="$(
|
OUTPUT="$(
|
||||||
@@ -176,7 +244,7 @@ OUTPUT="$(
|
|||||||
|
|
||||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||||
grep -qF 'Downloading source from next' <<<"$OUTPUT"
|
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||||
@@ -184,8 +252,72 @@ grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
|||||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --dev source install does not require registry version resolution"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||||
|
set +e
|
||||||
OUTPUT="$(
|
OUTPUT="$(
|
||||||
HOME="$HOME_DIR" \
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
@@ -196,15 +328,18 @@ OUTPUT="$(
|
|||||||
PATH="$FAKE_BIN:$PATH" \
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||||
)"
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
if grep -qF '@next version' "$LOG"; then
|
if grep -qF '@next version' "$LOG"; then
|
||||||
echo "explicit ref should not query @next dist-tags" >&2
|
echo "explicit ref should not query @next dist-tags" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
|
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||||
|
set +e
|
||||||
OUTPUT="$(
|
OUTPUT="$(
|
||||||
HOME="$HOME_DIR" \
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
@@ -216,7 +351,9 @@ OUTPUT="$(
|
|||||||
PATH="$FAKE_BIN:$PATH" \
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||||
)"
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||||
|
|
||||||
echo "[test] installer next lane tests passed"
|
echo "[test] installer next lane tests passed"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Red-first acceptance checks for #1050. This file is committed before the
|
# Red-first acceptance checks for #1050. This file is committed before the
|
||||||
# installer implementation. Do not weaken these properties to make it green.
|
# installer implementation. Do not weaken these properties to make it green.
|
||||||
|
|
||||||
|
# pass_case always returns zero and fail_case records the aggregate failure;
|
||||||
|
# the compact A&&pass||fail assertions are intentional.
|
||||||
|
# shellcheck disable=SC2015
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -26,8 +29,9 @@ make_fake_npm() {
|
|||||||
local bin="$1"
|
local bin="$1"
|
||||||
mkdir -p "$bin"
|
mkdir -p "$bin"
|
||||||
cat > "$bin/npm" <<'FAKE'
|
cat > "$bin/npm" <<'FAKE'
|
||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
|
||||||
case "${1:-} ${2:-} ${3:-}" in
|
case "${1:-} ${2:-} ${3:-}" in
|
||||||
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
||||||
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
||||||
@@ -86,6 +90,78 @@ for phase in P5 P8; do
|
|||||||
|| fail_case "$phase did not report its own expected failure"
|
|| fail_case "$phase did not report its own expected failure"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
printf '[test] case: --check discriminates a constructed good host without mutation\n'
|
||||||
|
good_home="$TMP/good-home"
|
||||||
|
good_bin="$TMP/good-bin"
|
||||||
|
good_prefix="$good_home/.npm-global"
|
||||||
|
good_mosaic="$good_home/.config/mosaic"
|
||||||
|
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
|
||||||
|
make_fake_npm "$good_bin"
|
||||||
|
cat > "$good_prefix/bin/mosaic" <<'CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '0.0.50-next.999\n'
|
||||||
|
CLI
|
||||||
|
chmod 0755 "$good_prefix/bin/mosaic"
|
||||||
|
cat > "$good_bin/getent" <<GETENT
|
||||||
|
#!/bin/bash
|
||||||
|
printf '%s:x:%s:%s::%s:%s\\n' '$(id -un)' '$(id -u)' '$(id -g)' '$good_home' '$good_bin/bash'
|
||||||
|
GETENT
|
||||||
|
cat > "$good_bin/bash" <<SHELL
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
|
||||||
|
printf '%s\\n' '$good_prefix/bin/mosaic'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/bash "\$@"
|
||||||
|
SHELL
|
||||||
|
chmod 0755 "$good_bin/getent" "$good_bin/bash"
|
||||||
|
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
|
||||||
|
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
|
||||||
|
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||||
|
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
|
||||||
|
---
|
||||||
|
name: declared-skill
|
||||||
|
description: Constructed loadable acceptance skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Declared skill
|
||||||
|
SKILL
|
||||||
|
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
|
||||||
|
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||||
|
before="$(fingerprint "$good_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$good_home")"
|
||||||
|
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
|
||||||
|
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
|
||||||
|
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
|
||||||
|
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|
||||||
|
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
|
||||||
|
|
||||||
|
printf '[test] case: persisted required-action failures remain blocking\n'
|
||||||
|
for blocked_phase in P4 P6; do
|
||||||
|
node -e '
|
||||||
|
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
|
||||||
|
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
|
||||||
|
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
|
||||||
|
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
|
||||||
|
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
|
||||||
|
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|
||||||
|
|| fail_case "$blocked_phase persisted action failure was not attributed"
|
||||||
|
done
|
||||||
|
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||||
|
|
||||||
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
|
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
|
||||||
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||||
home="$TMP/fault-$phase/home"
|
home="$TMP/fault-$phase/home"
|
||||||
@@ -117,6 +193,86 @@ for phase in P2 P3 P4 P5 P6 P7 P8; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
|
||||||
|
unsafe_home="$TMP/unsafe-home"
|
||||||
|
mkdir -p "$unsafe_home"
|
||||||
|
for case_name in root-target home-target overlap-target; do
|
||||||
|
case "$case_name" in
|
||||||
|
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||||
|
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||||
|
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
|
||||||
|
esac
|
||||||
|
before="$(fingerprint "$unsafe_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$unsafe_home")"
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
|
||||||
|
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
|
||||||
|
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
|
||||||
|
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
|
||||||
|
done
|
||||||
|
|
||||||
|
symlink_home="$TMP/symlink-home"
|
||||||
|
symlink_outside="$TMP/symlink-outside"
|
||||||
|
mkdir -p "$symlink_home" "$symlink_outside"
|
||||||
|
ln -s "$symlink_outside" "$symlink_home/.config"
|
||||||
|
set +e
|
||||||
|
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
|
||||||
|
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
|
||||||
|
&& pass_case 'symlinked rollback parent was rejected by P0' \
|
||||||
|
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
|
||||||
|
[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated'
|
||||||
|
|
||||||
|
printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n'
|
||||||
|
stale_home="$TMP/stale/home"
|
||||||
|
stale_state="$TMP/stale/state"
|
||||||
|
mkdir -p "$stale_home/.config/mosaic" "$stale_state"
|
||||||
|
printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json"
|
||||||
|
set +e
|
||||||
|
HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status"
|
||||||
|
if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then
|
||||||
|
pass_case 'stale projection was preserved and superseded after the free OS lock was acquired'
|
||||||
|
else
|
||||||
|
fail_case 'stale projection was not preserved for recovery evidence'
|
||||||
|
fi
|
||||||
|
[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \
|
||||||
|
|| fail_case 'stale retry did not reach an honest rolled-back terminal state'
|
||||||
|
|
||||||
|
printf '[test] case: journal initialization failure is fatal before mutation\n'
|
||||||
|
journal_home="$TMP/journal-failure/home"
|
||||||
|
mkdir -p "$journal_home/.config/mosaic"
|
||||||
|
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
|
||||||
|
before="$(fingerprint "$journal_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||||
|
MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||||
|
>"$TMP/journal-failure.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$journal_home")"
|
||||||
|
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|
||||||
|
|| fail_case 'unwritable journal directory returned zero'
|
||||||
|
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
|
||||||
|
&& pass_case 'journal initialization failure was named' \
|
||||||
|
|| fail_case 'journal initialization failure lacked a named diagnostic'
|
||||||
|
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|
||||||
|
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
|
||||||
|
|
||||||
if [[ "$failures" -ne 0 ]]; then
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
||||||
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
||||||
|
|||||||
+961
-55
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user