diff --git a/.delivery-status.local.example b/.delivery-status.local.example new file mode 100644 index 0000000..e900b27 --- /dev/null +++ b/.delivery-status.local.example @@ -0,0 +1,4 @@ +# Copy outside git or export locally. Never commit real private coordinates. +HELM_STATUS_CANDIDATE_URL=http://PRIVATE_GUEST_ADDRESS:8123 +HELM_STATUS_CANDIDATE_HOST=LOCAL_PROXMOX_SSH_ALIAS +HELM_STATUS_CANDIDATE_ID=LOCAL_GUEST_ID diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml new file mode 100644 index 0000000..3eac0bf --- /dev/null +++ b/.github/workflows/candidate.yml @@ -0,0 +1,175 @@ +name: Candidate dress rehearsal + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: read + +concurrency: + group: 1helm-private-dress-rehearsal + cancel-in-progress: false + +jobs: + build: + name: Build exact trusted-main Linux candidate + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.repository.full_name == github.repository && + github.sha == github.event.workflow_run.head_sha && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + id-token: write + attestations: write + outputs: + artifact-name: ${{ steps.identity.outputs.artifact_name }} + commit: ${{ steps.identity.outputs.commit }} + steps: + - name: Check out the exact successful CI commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Re-verify trusted repository, ref, event, and SHA + env: + CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + CI_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + CI_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + CI_EVENT: ${{ github.event.workflow_run.event }} + CI_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + CI_WORKFLOW: ${{ github.event.workflow_run.name }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "gitcommit90/1Helm" + test "$CI_HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" + test "$CI_HEAD_BRANCH" = "main" + test "$CI_EVENT" = "push" + test "$CI_CONCLUSION" = "success" + test "$CI_WORKFLOW" = "CI" + test "$GITHUB_REF" = "refs/heads/main" + test "$GITHUB_SHA" = "$CI_HEAD_SHA" + test "$(git rev-parse HEAD)" = "$CI_HEAD_SHA" + test -z "$(git status --porcelain)" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + cache: npm + + - name: Install exact dependencies and builder runtime + run: | + set -euo pipefail + PUPPETEER_SKIP_DOWNLOAD=1 npm ci + sudo apt-get update + sudo apt-get install -y podman + + - name: Build sealed OCI image and ready-to-run Linux archive + env: + HELM_CANDIDATE_REPOSITORY: gitcommit90/1Helm + HELM_CANDIDATE_REF: refs/heads/main + HELM_CANDIDATE_COMMIT: ${{ github.event.workflow_run.head_sha }} + HELM_CANDIDATE_SOURCE_STATE: trusted-main + HELM_CANDIDATE_BUILD_ID: candidate-${{ github.event.workflow_run.id }}-${{ github.run_id }}.${{ github.run_attempt }} + HELM_CANDIDATE_CREATED_AT: ${{ github.event.workflow_run.updated_at }} + HELM_CANDIDATE_CI_WORKFLOW: CI + HELM_CANDIDATE_CI_RUN_ID: ${{ github.event.workflow_run.id }} + HELM_CANDIDATE_CI_CONCLUSION: success + run: | + set -euo pipefail + npm run package:channel-image + npm run package:linux + + - name: Generate candidate manifest and evidence + id: identity + env: + CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + version="$(node -p 'require("./package.json").version')" + archive="dist/1Helm-${version}-linux-node.tgz" + evidence="dist/candidate-evidence" + mkdir -p "$evidence" + HELM_CANDIDATE_ARCHIVE="$archive" \ + HELM_CANDIDATE_MANIFEST="$evidence/candidate.json" \ + node scripts/candidate-manifest.mjs + cp "$archive.sha256" "$evidence/archive.sha256" + sha256sum "$evidence/candidate.json" > "$evidence/manifest.sha256" + printf 'artifact_name=1helm-candidate-%s\n' "$CI_HEAD_SHA" >> "$GITHUB_OUTPUT" + printf 'commit=%s\n' "$CI_HEAD_SHA" >> "$GITHUB_OUTPUT" + + - name: Attest archive provenance on the hosted builder + id: attest + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: dist/1Helm-*-linux-node.tgz + + - name: Retain signed provenance bundle + env: + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + run: | + set -euo pipefail + test -s "$BUNDLE_PATH" + install -m 0644 "$BUNDLE_PATH" dist/candidate-evidence/provenance.bundle.json + + - name: Upload exact candidate and evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + dist/1Helm-*-linux-node.tgz + dist/candidate-evidence/candidate.json + dist/candidate-evidence/archive.sha256 + dist/candidate-evidence/manifest.sha256 + dist/candidate-evidence/provenance.bundle.json + if-no-files-found: error + retention-days: 30 + + deploy: + name: Install only on private Phase 2 dress rehearsal + needs: build + runs-on: [1helm-dress-rehearsal-phase2] + timeout-minutes: 20 + permissions: + contents: read + actions: read + steps: + - name: Download this workflow's exact candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build.outputs.artifact-name }} + path: candidate-download + + - name: Submit fixed candidate inputs to the root-owned boundary + env: + EXPECTED_COMMIT: ${{ needs.build.outputs.commit }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "gitcommit90/1Helm" + test "$EXPECTED_COMMIT" = "${{ github.event.workflow_run.head_sha }}" + test "${{ github.event.workflow_run.head_branch }}" = "main" + test "${{ github.event.workflow_run.head_repository.full_name }}" = "$GITHUB_REPOSITORY" + test "${{ github.event.workflow_run.conclusion }}" = "success" + archive="$(find candidate-download -maxdepth 1 -type f -name '1Helm-*-linux-node.tgz' -print -quit)" + test -n "$archive" + install -m 0600 "$archive" /var/lib/1helm-candidate/inbox/candidate.tgz + install -m 0600 candidate-download/candidate-evidence/candidate.json /var/lib/1helm-candidate/inbox/candidate.json + install -m 0600 candidate-download/candidate-evidence/provenance.bundle.json /var/lib/1helm-candidate/inbox/provenance.bundle.json + sudo -n /usr/local/sbin/1helm-candidate-install + + - name: Publish private installation evidence in the job log + if: always() + run: | + test -r /var/lib/1helm-candidate/evidence/status.json + python3 /usr/local/lib/1helm-candidate/candidate-boundary.py summary \ + /var/lib/1helm-candidate/evidence/status.json diff --git a/.gitignore b/.gitignore index 1e800bf..afc86ac 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,16 @@ data-refactored/ .deploy-backups/ .claude/ .design/ + +# generated local delivery/test state +# Host tooling may ignore agent instruction files globally; these are tracked. +!/AGENTS.md +!/CLAUDE.md +/.release-tmp/ +/.native-test-data/ +/.preview-data/ +/.test-state/ +/.delivery-status.local +__pycache__/ +*.py[cod] +/src/server/agent.ts.bak-normal-terminal-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70f2165 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Delivery contract for coding agents + +The default delivery mode in this repository is **PREVIEW ONLY**. Implement and +verify a small, explicitly requested change, then report it for review. + +Unless the owner explicitly requests a stable promotion, do not: + +- bump versions or edit release notes for a release; +- create or publish tags, releases, or artifacts; +- deploy the public website or update stable or its release metadata; +- change production data, infrastructure, containers, VMs, or services; or +- broaden the requested scope. + +Keep changes focused. Run the narrowest relevant tests while iterating, then run +the full CI contract (`npm run ci`) before merge. Never weaken a check to make a +change pass. + +Every handoff must briefly name changed files, checks run and their results, +known risks, rollback steps, and whether stable or any external system was +touched. + +Maintainer policy and release mechanics remain authoritative in +[`docs/GOVERNANCE.md`](docs/GOVERNANCE.md) and +[`docs/release-lifecycle.md`](docs/release-lifecycle.md). This file adds the +agent default; it does not replace those documents. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e48310b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# Claude Code instructions + +Follow [AGENTS.md](AGENTS.md) as the authoritative delivery contract for all +work in this repository. diff --git a/README.md b/README.md index 1c1e06e..8902e7f 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,25 @@ npm start # http://127.0.0.1:8123 A fresh data directory opens first-run setup. The source runtime defaults to `./data`; do not point development at an existing production data directory. +### Private development preview + +Run `npm run preview`, then open **http://127.0.0.1:8124**. Stop it with +Ctrl+C. Client CSS/JavaScript and server source changes are watched; refresh +the private page manually after a change. + +The command refuses Stable's port and normal app data paths. It always uses +separate generated test data under `.preview-data/`, so it never changes or +restarts Stable. If Stable uses a locally customized port, set +`HELM_STABLE_PORT` before launching the preview so that port is refused too. + +### Private Linux dress rehearsal + +After trusted `main` passes CI, the candidate workflow prepares one exact, +attested Linux build for a dedicated private guest and installs it through a +digest-verified, rollback-aware root boundary. It does not publish a release, +change Stable, or run PR code on the guest. Private coordinates stay in local +status configuration. See [Private Linux dress rehearsal](docs/dress-rehearsal.md). + ### Core configuration | Environment variable | Default | Meaning | diff --git a/docs/canary-plan.md b/docs/canary-plan.md new file mode 100644 index 0000000..c43860e --- /dev/null +++ b/docs/canary-plan.md @@ -0,0 +1,56 @@ +# Delivery canary plan + +## Phase 0 boundary + +Phase 0 creates documentation and read-only visibility only. It does not +create or modify infrastructure, containers, VMs, services, deployment targets, +releases, stable metadata, or production data. + +LXC 112 on `pve2` remains unchanged as the legacy **v0.0.38 updater fixture**. +It is evidence for the prior-version update path, not a general-purpose canary. +No experiment, candidate install, reset, or cleanup may repurpose it. + +## Phase 2 private dress rehearsal + +Phase 2 was separately approved. It creates one fresh, unprivileged LXC with its +own identity, storage, private network address, and lifecycle. It does not share +production data, credentials, release metadata, or the legacy fixture. The +guest ID, address, and hypervisor access stay in local operator configuration, +not this public repository. + +After `CI` succeeds for a push to trusted `main`, `Candidate dress rehearsal` +checks out that exact CI SHA on a GitHub-hosted builder. It builds the sealed OCI +image and ready-to-run Linux archive without a version bump or GitHub Release, +embeds source/build identity, emits a digest manifest, signs GitHub artifact +provenance, and retains all evidence as a workflow artifact. Only then does its +deployment job select the uniquely labelled repository runner in the dedicated +guest. + +The runner cannot install arbitrary bytes. A fixed root-owned command copies +the fixed inbox files, requires signed provenance from the trusted candidate +workflow on `main`, rejects self-hosted provenance, and requires the outer +manifest, embedded identity, archive digest, source SHA, version, and sealed OCI +digest to agree. It then reuses the immutable release store, service health +check, and automatic rollback in the Linux installer. The status record contains: + +- canary role, hypervisor/guest identity, and health endpoint; +- current version and candidate version; +- exact source commit plus artifact name and SHA-256 digest; +- service and application health, check time, result, and any uncertainty; +- CI workflow/run result and candidate build identity; +- install health and time; and +- previous candidate plus rollback result/time. + +Unknown metadata must be reported as unknown, never inferred from a nearby +checkout, tag, or responding port. + +## Rollback gate + +The dedicated guest starts from a documented clean baseline. Each accepted +candidate becomes an immutable, digest-named release directory. Before changing +the current symlink or host contract, the existing Linux transaction snapshots +the prior current release, runtime files, units, and unit state. A failed or +uncertain service/API health check restores that exact prior contract and proves +the restored service healthy. The candidate evidence records both failure and +rollback outcome. This is application rollback inside the dedicated guest; no +Proxmox snapshot, Stable change, or LXC 112 action is part of the automation. diff --git a/docs/dress-rehearsal.md b/docs/dress-rehearsal.md new file mode 100644 index 0000000..ba0049a --- /dev/null +++ b/docs/dress-rehearsal.md @@ -0,0 +1,97 @@ +# Private Linux dress rehearsal + +This Phase 2 environment installs Linux candidates automatically after the +existing `CI` workflow succeeds for a push to trusted `main`. It is private +acceptance infrastructure, not Stable, a public release, or a promotion gate. +The workflow is inert until this code reaches `main` and the uniquely labelled +repository runner is online. + +## Trust and installation path + +1. The hosted `build` job checks out the exact SHA from the successful `CI` + `workflow_run`, re-verifies repository, push event, branch, conclusion, and + checkout SHA, then builds the sealed OCI image and ready-to-run Linux archive. +2. `resources/candidate-build.json` inside the archive and `candidate.json` + outside it record the same repository, `refs/heads/main`, full commit, + package version, source state, CI run, build identity, archive SHA-256, and + sealed OCI SHA-256. Candidates reuse the package version and do not create a + semantic version, tag, GitHub Release, or public updater entry. +3. GitHub signs artifact provenance on the hosted builder. The self-hosted job + only downloads that workflow artifact and writes three fixed inbox files. +4. `sudo -n /usr/local/sbin/1helm-candidate-install` (with no arguments) is the only delegated root + command. The helper accepts no arguments, copies the inbox to a root-only + transaction directory, validates paths/digests/identities, verifies signed + provenance for `.github/workflows/candidate.yml` on `main`, and rejects + attestations from self-hosted builders. + +Initial local provisioning proof uses a one-use, mode-0600, root-owned marker +inside the guest so unpublished worktree bytes can exercise the same boundary +without claiming signed GitHub provenance. The helper consumes that marker +before validation; the runner cannot write its directory. Normal automation +has no bypass flag or marker. + +Rollback acceptance may use a separately labelled `rollback-fixture` archive +with an embedded controlled startup fault. That source state is accepted only +while the same one-use root marker is present. It cannot pass normal trusted-main +validation or signed workflow provenance, and status must keep the prior healthy +candidate as running after the failed attempt. +5. The helper calls the archive's digest-pinned Linux installer. Existing + release directories remain immutable; the existing host transaction owns + runtime/unit changes, service restart, API health, and known-good rollback. + +The runner service account has no Proxmox, Stable, production, website, or +release credentials and is not a member of privileged container/runtime groups. +Its start hook rejects every repository, workflow, job, and event except the +Phase 2 deployment job resulting from successful `CI` for `main`. Ordinary PR +workflows do not carry the unique runner label. The runner is registered with +`--no-default-labels`, so generic `self-hosted`, OS, or architecture selectors +cannot schedule it either. + +The long-lived runner has a read-only system filesystem. Its one sudo command +delegates the same fixed, argument-free helper to a transient root systemd unit; +the app installation never runs as part of an arbitrary workflow shell and the +runner retains no general root command or writable host filesystem. + +The guest is LAN-private: the application listens on its private guest address, +and no public tunnel, DNS name, forwarding rule, or public ingress is created. +Restrict operator access with the surrounding private LAN/Tailscale and Proxmox +firewall policy; the repository intentionally contains no address or host ID. + +## Status + +The guest keeps current evidence at +`/var/lib/1helm-candidate/evidence/status.json`. It reports the running candidate +commit/digest/version/build identity, successful CI run, install health/time, +previous candidate, and rollback result/time. Historical attempts are retained +root-only beside it; detailed install logs are root-only under +`/var/log/1helm-candidate`. + +Integrate this read-only evidence with Phase 0 without tracking private +coordinates: + +```bash +HELM_STATUS_CANDIDATE_URL=http://PRIVATE_GUEST_ADDRESS:8123 \ +HELM_STATUS_CANDIDATE_HOST=LOCAL_PROXMOX_SSH_ALIAS \ +HELM_STATUS_CANDIDATE_ID=LOCAL_GUEST_ID \ +npm run delivery:status +``` + +Configure all three values locally. The command uses the fixed `pct exec ... +cat /var/lib/1helm-candidate/evidence/status.json` operation; it does not accept +an arbitrary remote command or write anything. + +## Teardown and rollback + +Do not use these steps as part of normal candidate rollback. Failed installs +already restore the previous healthy candidate in-guest. To disable future +automation while preserving evidence, remove the unique runner label or stop +the runner service in the dedicated guest through the Proxmox console. + +For final decommissioning, first verify no candidate job is active, remove the +repository runner registration in GitHub, and archive the root-owned evidence +needed by the owner. Then stop and destroy **only the locally recorded Phase 2 +guest ID**, after separately resolving its name and confirming it is the +dedicated dress-rehearsal guest. Never use a range, wildcard, name-only lookup, +the legacy fixture ID, or another existing guest. Removing the guest deletes +its candidates, private app data, runner, logs, and evidence; it does not affect +Stable or the public website. No teardown was performed during Phase 2 delivery. diff --git a/ops/dress-rehearsal/1helm-candidate-install b/ops/dress-rehearsal/1helm-candidate-install new file mode 100755 index 0000000..f05b531 --- /dev/null +++ b/ops/dress-rehearsal/1helm-candidate-install @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +INBOX=/var/lib/1helm-candidate/inbox +EVIDENCE=/var/lib/1helm-candidate/evidence +HISTORY="$EVIDENCE/history" +BOUNDARY=/usr/local/lib/1helm-candidate/candidate-boundary.py +LOCK=/run/lock/1helm-candidate-install.lock +INSTALL_LOG=/var/log/1helm-candidate +LOCAL_PROOF=0 +LOCAL_PROOF_MARKER=/var/lib/1helm-candidate/local-proof-authorized + +[[ "$EUID" -eq 0 ]] || { echo "The candidate boundary must run through its fixed sudo rule." >&2; exit 1; } +[[ "$#" -eq 0 ]] || { echo "The candidate boundary accepts no arguments." >&2; exit 2; } +# Keep the long-lived runner inside a read-only systemd namespace. Sudo grants +# only this file; this root-owned file asks PID 1 to run the same fixed, +# argument-free transaction in a fresh transient unit outside that namespace. +if awk -F: '$3 ~ /actions\.runner\.gitcommit90-1Helm\.1helm-dress-rehearsal-p2\.service(\/|$)/ { found=1 } END { exit found ? 0 : 1 }' /proc/self/cgroup; then + exec systemd-run --quiet --collect --wait --pipe \ + --unit="1helm-candidate-install-$(date +%s)-$$" \ + --property=Type=oneshot --property=NoNewPrivileges=false --property=PrivateTmp=true --property=ProtectHome=true \ + /usr/local/sbin/1helm-candidate-install +fi +if [[ -f "$LOCAL_PROOF_MARKER" && ! -L "$LOCAL_PROOF_MARKER" \ + && "$(stat -c '%U:%G:%a' "$LOCAL_PROOF_MARKER")" == "root:root:600" ]]; then + LOCAL_PROOF=1 + unlink "$LOCAL_PROOF_MARKER" +fi +[[ -x "$BOUNDARY" ]] || { echo "The root-owned candidate validator is missing." >&2; exit 1; } +for file in candidate.json candidate.tgz; do + [[ -f "$INBOX/$file" && ! -L "$INBOX/$file" ]] || { echo "Candidate inbox is incomplete." >&2; exit 1; } +done + +exec 9>"$LOCK" +flock -n 9 || { echo "Another candidate installation is active." >&2; exit 1; } +work="$(mktemp -d /var/lib/1helm-candidate/.install.XXXXXX)" +trap 'rm -rf -- "$work"' EXIT +install -o root -g root -m 0600 "$INBOX/candidate.json" "$work/candidate.json" +install -o root -g root -m 0600 "$INBOX/candidate.tgz" "$work/candidate.tgz" +if [[ "$LOCAL_PROOF" -eq 0 ]]; then + [[ -f "$INBOX/provenance.bundle.json" && ! -L "$INBOX/provenance.bundle.json" ]] || { echo "Signed candidate provenance is required." >&2; exit 1; } + install -o root -g root -m 0600 "$INBOX/provenance.bundle.json" "$work/provenance.bundle.json" +fi +# Consume the fixed inbox payloads after the root copy. The unprivileged runner +# can then create a fresh set without owning retained candidate bytes. +unlink "$INBOX/candidate.json" "$INBOX/candidate.tgz" +[[ "$LOCAL_PROOF" -eq 1 ]] || unlink "$INBOX/provenance.bundle.json" + +validate_args=(validate "$work/candidate.json" "$work/candidate.tgz" "$work/verified.json") +[[ "$LOCAL_PROOF" -eq 1 ]] && validate_args+=(--allow-local) +python3 "$BOUNDARY" "${validate_args[@]}" +commit="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["source"]["commit"])' "$work/verified.json")" +version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$work/verified.json")" +digest="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["artifact"]["sha256"])' "$work/verified.json")" +if [[ "$LOCAL_PROOF" -eq 0 ]]; then + gh attestation verify "$work/candidate.tgz" \ + --bundle "$work/provenance.bundle.json" \ + --repo gitcommit90/1Helm \ + --signer-workflow gitcommit90/1Helm/.github/workflows/candidate.yml \ + --source-ref refs/heads/main \ + --source-digest "$commit" \ + --deny-self-hosted-runners >/dev/null +fi + +previous_link="$(readlink -f /opt/1helm/current 2>/dev/null || true)" +mkdir -p "$EVIDENCE" "$HISTORY" "$INSTALL_LOG" +chmod 0755 "$EVIDENCE"; chmod 0700 "$HISTORY" "$INSTALL_LOG" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +log="$INSTALL_LOG/$timestamp-$commit.log" +prefix="$(tar -tzf "$work/candidate.tgz" | awk -F/ '/^[^/]+\/site\/public\/install\.sh$/ && !found { print $1; found=1 }')" +[[ -n "$prefix" ]] || { echo "Candidate installer is missing." >&2; exit 1; } +mkdir "$work/source" +tar -xzf "$work/candidate.tgz" -C "$work/source" "$prefix/site/public/install.sh" + +set +e +HELM_RELEASE_SHA256="$digest" bash "$work/source/$prefix/site/public/install.sh" "$work/candidate.tgz" >"$log" 2>&1 +install_status=$? +set -e +result=failed +health=unhealthy +rollback=unavailable +message="Candidate v$version failed installation." +current_link="$(readlink -f /opt/1helm/current 2>/dev/null || true)" +if [[ "$install_status" -eq 0 ]] && systemctl is-active --quiet 1helm.service \ + && curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null 2>&1; then + result=healthy + health=healthy + rollback=not_needed + message="Candidate v$version is installed and healthy." +elif [[ -n "$previous_link" && "$current_link" == "$previous_link" ]] \ + && systemctl is-active --quiet 1helm.service \ + && curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null 2>&1; then + health=healthy + rollback=healthy + message="Candidate v$version failed; the prior candidate was restored and proved healthy." +elif [[ -n "$previous_link" ]]; then + rollback=failed + message="Candidate v$version failed and the prior candidate could not be proven healthy." +fi + +python3 "$BOUNDARY" record "$work/verified.json" "$EVIDENCE/status.json" "$work/status.json" \ + "$result" "$health" "$rollback" "$message" +install -o root -g root -m 0644 "$work/status.json" "$EVIDENCE/status.json" +install -o root -g root -m 0600 "$work/status.json" "$HISTORY/$timestamp-$commit.json" +python3 "$BOUNDARY" summary "$EVIDENCE/status.json" +[[ "$result" == healthy ]] diff --git a/ops/dress-rehearsal/candidate-boundary.py b/ops/dress-rehearsal/candidate-boundary.py new file mode 100755 index 0000000..6927858 --- /dev/null +++ b/ops/dress-rehearsal/candidate-boundary.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Fail-closed validation and evidence formatting for the private candidate host.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import tarfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +KIND = "1helm-dress-rehearsal-candidate" +REPOSITORY = "gitcommit90/1Helm" +REF = "refs/heads/main" +HEX40 = re.compile(r"^[a-f0-9]{40}$") +HEX64 = re.compile(r"^[a-f0-9]{64}$") +VERSION = re.compile(r"^\d+\.\d+\.\d+$") +BUILD = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$") + + +def fail(message: str) -> None: + raise ValueError(message) + + +def load_json(path: Path, maximum: int = 1024 * 1024) -> dict: + if not path.is_file() or path.stat().st_size > maximum: + fail(f"{path.name} is missing or too large") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + fail(f"{path.name} is not valid JSON: {error}") + if not isinstance(value, dict): + fail(f"{path.name} must contain a JSON object") + return value + + +def sha256_stream(stream) -> str: + digest = hashlib.sha256() + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def expect(value, pattern, label: str) -> str: + text = str(value or "") + if not pattern.fullmatch(text): + fail(f"invalid {label}") + return text + + +def validate(manifest_path: Path, archive_path: Path, allow_local: bool) -> dict: + manifest = load_json(manifest_path) + if manifest.get("schema") != 1 or manifest.get("kind") != KIND: + fail("candidate manifest schema or kind mismatch") + source = manifest.get("source") or {} + if source.get("repository") != REPOSITORY or source.get("ref") != REF: + fail("candidate repository or ref mismatch") + state = str(source.get("state") or "") + if state != "trusted-main" and not (allow_local and state in {"local-worktree", "rollback-fixture"}): + fail("candidate source is not trusted main") + commit = expect(source.get("commit"), HEX40, "source commit") + source_digest = expect(source.get("source_archive_sha256"), HEX64, "source archive digest") + version = expect(manifest.get("version"), VERSION, "version") + build = manifest.get("build") or {} + build_id = expect(build.get("identity"), BUILD, "build identity") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", str(build.get("created_at") or "")): + fail("invalid candidate creation time") + ci = manifest.get("ci") or {} + if state == "trusted-main": + valid_ci = ci.get("workflow") == "CI" and ci.get("conclusion") == "success" and str(ci.get("run_id") or "").isdigit() + else: + valid_ci = ci.get("workflow") == "local" and ci.get("conclusion") == "not_run" and str(ci.get("run_id")) == "0" + if not valid_ci: + fail("candidate CI identity does not match its source state") + artifact = manifest.get("artifact") or {} + expected_name = f"1Helm-{version}-linux-node.tgz" + if artifact.get("name") != expected_name: + fail("candidate artifact name/version mismatch") + archive_sha = expect(artifact.get("sha256"), HEX64, "artifact digest") + if not archive_path.is_file() or archive_path.stat().st_size != artifact.get("bytes"): + fail("candidate archive size mismatch") + with archive_path.open("rb") as stream: + if sha256_stream(stream) != archive_sha: + fail("candidate archive SHA-256 mismatch") + oci_sha = expect((manifest.get("sealed_oci") or {}).get("sha256"), HEX64, "sealed OCI digest") + + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or member.isdev(): + fail("candidate archive contains an unsafe entry") + identity_members = [member for member in members if re.fullmatch(r"[^/]+/resources/candidate-build\.json", member.name)] + package_members = [member for member in members if re.fullmatch(r"[^/]+/package\.json", member.name)] + oci_members = [member for member in members if re.fullmatch(r"[^/]+/container/channel-machine\.oci\.tar", member.name)] + if len(identity_members) != 1 or len(package_members) != 1 or len(oci_members) != 1: + fail("candidate archive identity/package/sealed OCI layout mismatch") + try: + identity = json.load(archive.extractfile(identity_members[0])) + package = json.load(archive.extractfile(package_members[0])) + except (TypeError, json.JSONDecodeError) as error: + fail(f"candidate embedded identity is invalid: {error}") + embedded_oci = archive.extractfile(oci_members[0]) + if embedded_oci is None or sha256_stream(embedded_oci) != oci_sha: + fail("sealed OCI bytes do not match the candidate identity") + + comparisons = { + "schema": 1, + "kind": KIND, + "repository": REPOSITORY, + "ref": REF, + "commit": commit, + "source_state": state, + "build_identity": build_id, + "created_at": build.get("created_at"), + "version": version, + "source_archive_sha256": source_digest, + "sealed_oci_sha256": oci_sha, + } + for key, expected_value in comparisons.items(): + if identity.get(key) != expected_value: + fail(f"embedded candidate {key} mismatch") + if identity.get("ci") != ci or package.get("version") != version: + fail("embedded CI identity or package version mismatch") + return manifest + + +def now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def attempt(manifest: dict) -> dict: + return { + "commit": manifest["source"]["commit"], + "digest": manifest["artifact"]["sha256"], + "version": manifest["version"], + "build_identity": manifest["build"]["identity"], + "source_state": manifest["source"]["state"], + "ci": ({"workflow": "local", "run_id": "0", "conclusion": "not_run"} + if manifest["source"]["state"] != "trusted-main" else manifest["ci"]), + } + + +def record(manifest_path: Path, previous_path: Path, output_path: Path, result: str, + health: str, rollback: str, message: str) -> dict: + manifest = load_json(manifest_path) + previous = load_json(previous_path) if previous_path.is_file() else {} + prior_running = previous.get("running_candidate") + current_attempt = attempt(manifest) + rollback_record = {"result": rollback, "checked_at": now()} + previous_rollback = previous.get("last_rollback") or previous.get("rollback") or {} + if result == "healthy" and rollback == "not_needed" and previous_rollback.get("result") == "healthy": + rollback_record = previous_rollback + status = { + "schema": 1, + "kind": "1helm-dress-rehearsal-status", + "checked_at": now(), + "running_candidate": current_attempt if result == "healthy" else prior_running, + "last_attempt": current_attempt, + "previous_candidate": prior_running if result == "healthy" else previous.get("previous_candidate"), + "install": {"result": result, "health": health, "checked_at": now(), "message": message}, + "rollback": {"result": rollback, "checked_at": now()}, + "last_rollback": rollback_record, + } + output_path.write_text(json.dumps(status, indent=2) + "\n", encoding="utf-8") + return status + + +def summary(status: dict) -> str: + running = status.get("running_candidate") or {} + previous = status.get("previous_candidate") or {} + ci = running.get("ci") or (status.get("last_attempt") or {}).get("ci") or {} + install = status.get("install") or {} + rollback = status.get("last_rollback") or status.get("rollback") or {} + ci_line = "not run (local provisioning proof)" if running.get("source_state") != "trusted-main" else ( + f"{ci.get('workflow', 'unknown')} run {ci.get('run_id', 'unknown')} — {ci.get('conclusion', 'unknown')}" + ) + lines = [ + "1Helm private dress rehearsal", + f" Running: v{running.get('version', 'unknown')} @ {running.get('commit', 'unknown')}", + f" Digest: {running.get('digest', 'unknown')}", + f" Build: {running.get('build_identity', 'unknown')}", + f" CI: {ci_line}", + f" Install health: {install.get('result', 'unknown')} / {install.get('health', 'unknown')} at {install.get('checked_at', 'unknown')}", + f" Previous: {previous.get('commit', 'none')} / {previous.get('digest', 'none')}", + f" Rollback: {rollback.get('result', 'unknown')} at {rollback.get('checked_at', 'unknown')}", + ] + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("validate") + check.add_argument("manifest", type=Path) + check.add_argument("archive", type=Path) + check.add_argument("output", type=Path) + check.add_argument("--allow-local", action="store_true") + evidence = sub.add_parser("record") + evidence.add_argument("manifest", type=Path) + evidence.add_argument("previous", type=Path) + evidence.add_argument("output", type=Path) + evidence.add_argument("result", choices=["healthy", "failed"]) + evidence.add_argument("health", choices=["healthy", "unhealthy", "unknown"]) + evidence.add_argument("rollback", choices=["not_needed", "healthy", "failed", "unavailable"]) + evidence.add_argument("message") + show = sub.add_parser("summary") + show.add_argument("status", type=Path) + args = parser.parse_args() + try: + if args.command == "validate": + value = validate(args.manifest, args.archive, args.allow_local) + args.output.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + elif args.command == "record": + record(args.manifest, args.previous, args.output, args.result, args.health, args.rollback, args.message) + else: + print(summary(load_json(args.status)), end="") + except (OSError, ValueError, tarfile.TarError) as error: + raise SystemExit(f"Candidate boundary refused input: {error}") + + +if __name__ == "__main__": + main() diff --git a/ops/dress-rehearsal/runner-job-started b/ops/dress-rehearsal/runner-job-started new file mode 100755 index 0000000..3cd981e --- /dev/null +++ b/ops/dress-rehearsal/runner-job-started @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ "${GITHUB_REPOSITORY:-}" == "gitcommit90/1Helm" ]] || { echo "This runner accepts only gitcommit90/1Helm." >&2; exit 1; } +[[ "${GITHUB_WORKFLOW:-}" == "Candidate dress rehearsal" ]] || { echo "This runner accepts only the candidate workflow." >&2; exit 1; } +[[ "${GITHUB_JOB:-}" == "deploy" ]] || { echo "This runner accepts only the constrained deployment job." >&2; exit 1; } +[[ "${GITHUB_EVENT_NAME:-}" == "workflow_run" ]] || { echo "This runner rejects PR and direct-push jobs." >&2; exit 1; } +[[ -r "${GITHUB_EVENT_PATH:-}" ]] || { echo "The trusted workflow event payload is unavailable." >&2; exit 1; } + +python3 - "$GITHUB_EVENT_PATH" <<'PY' +import json, re, sys +event = json.load(open(sys.argv[1], encoding="utf-8")) +run = event.get("workflow_run") or {} +repository = event.get("repository") or {} +head_repository = run.get("head_repository") or {} +trusted = ( + repository.get("full_name") == "gitcommit90/1Helm" + and head_repository.get("full_name") == "gitcommit90/1Helm" + and run.get("name") == "CI" + and run.get("event") == "push" + and run.get("head_branch") == "main" + and run.get("conclusion") == "success" + and re.fullmatch(r"[a-f0-9]{40}", str(run.get("head_sha") or "")) +) +if not trusted: + raise SystemExit("The candidate runner refused an untrusted repository/ref/SHA/CI event.") +PY diff --git a/ops/dress-rehearsal/runner.service.override.conf b/ops/dress-rehearsal/runner.service.override.conf new file mode 100644 index 0000000..2f71b2d --- /dev/null +++ b/ops/dress-rehearsal/runner.service.override.conf @@ -0,0 +1,7 @@ +[Service] +Environment=ACTIONS_RUNNER_HOOK_JOB_STARTED=/usr/local/lib/1helm-candidate/runner-job-started +NoNewPrivileges=false +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true +ReadWritePaths=/home/actions /opt/actions-runner/_diag /var/lib/1helm-candidate/inbox diff --git a/package.json b/package.json index 2fa1d20..a2ff316 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "mobile:check": "npm run typecheck && node --test test/mobile.mjs && cap doctor android", "start": "node --disable-warning=ExperimentalWarning src/server/index.ts", "dev": "npm run build && npm start", + "preview": "node scripts/preview.mjs", "watch:js": "esbuild src/client/app.ts --bundle --format=esm --outfile=public/bundle.js --loader:.css=css --watch", "watch:css": "tailwindcss -i src/client/styles.css -o public/app.css --watch", "typecheck": "tsc --noEmit && tsc -p cloudflare/tsconfig.json --noEmit", @@ -39,6 +40,12 @@ "test:terminal-browser": "node --test test/terminal-reconnect-browser.mjs", "test:feedback-browser": "node --test test/feedback-browser.mjs", "test:site": "node --test test/site.mjs", + "test:delivery": "node --test test/delivery-status.mjs test/cleanup-report.mjs test/delivery-governance.mjs", + "test:phase1": "node --test test/phase1-tools.mjs test/delivery-status.mjs test/cleanup-report.mjs", + "test:phase2": "node --test test/phase2-candidate.mjs test/delivery-status.mjs", + "test:fast": "node scripts/run-fast-tests.mjs", + "delivery:status": "node scripts/delivery-status.mjs", + "cleanup:report": "node scripts/cleanup-report.mjs", "benchmark:autonomy": "node scripts/autonomy-benchmark.mjs", "helm": "node scripts/1helm-cli.mjs", "test:live": "node test/live-smoke.mjs", diff --git a/scripts/candidate-manifest.mjs b/scripts/candidate-manifest.mjs new file mode 100755 index 0000000..0d75ef4 --- /dev/null +++ b/scripts/candidate-manifest.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +export const CANDIDATE_KIND = "1helm-dress-rehearsal-candidate"; +export const CANDIDATE_REPOSITORY = "gitcommit90/1Helm"; +export const CANDIDATE_REF = "refs/heads/main"; + +const sha256File = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); +const exactString = (value, pattern, label) => { + const text = String(value || ""); + if (!pattern.test(text)) throw new Error(`Invalid candidate ${label}`); + return text; +}; + +export function validateCandidateBuildIdentity(value, { allowLocal = false } = {}) { + if (!value || value.schema !== 1 || value.kind !== CANDIDATE_KIND + || value.repository !== CANDIDATE_REPOSITORY || value.ref !== CANDIDATE_REF) { + throw new Error("Candidate build identity has the wrong schema, repository, or ref"); + } + const sourceState = String(value.source_state || ""); + if (sourceState !== "trusted-main" && !(allowLocal && ["local-worktree", "rollback-fixture"].includes(sourceState))) { + throw new Error("Candidate build identity is not trusted main"); + } + const trustedMain = sourceState === "trusted-main"; + const ci = value.ci || {}; + if (trustedMain + ? ci.workflow !== "CI" || !/^\d+$/.test(String(ci.run_id || "")) || ci.conclusion !== "success" + : ci.workflow !== "local" || String(ci.run_id) !== "0" || ci.conclusion !== "not_run") { + throw new Error("Candidate CI identity does not match its source state"); + } + return { + ...value, + commit: exactString(value.commit, /^[a-f0-9]{40}$/, "commit"), + source_state: sourceState, + build_identity: exactString(value.build_identity, /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/, "build identity"), + created_at: exactString(value.created_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "creation time"), + version: exactString(value.version, /^\d+\.\d+\.\d+$/, "version"), + source_archive_sha256: exactString(value.source_archive_sha256, /^[a-f0-9]{64}$/, "source archive digest"), + sealed_oci_sha256: exactString(value.sealed_oci_sha256, /^[a-f0-9]{64}$/, "sealed OCI digest"), + ci: { workflow: ci.workflow, run_id: String(ci.run_id), conclusion: ci.conclusion }, + }; +} + +export function candidateIdentityFromArchive(archivePath, options = {}) { + const listed = spawnSync("tar", ["-tzf", archivePath], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + if (listed.status !== 0) throw new Error("Candidate archive is not a readable gzip tar archive"); + const entries = String(listed.stdout || "").trim().split("\n").filter(Boolean); + if (entries.some((entry) => entry.startsWith("/") || entry.split("/").includes(".."))) { + throw new Error("Candidate archive contains an unsafe path"); + } + const identities = entries.filter((entry) => /^[^/]+\/resources\/candidate-build\.json$/.test(entry)); + if (identities.length !== 1) throw new Error("Candidate archive must contain exactly one embedded build identity"); + const extracted = spawnSync("tar", ["-xOzf", archivePath, identities[0]], { encoding: "utf8", maxBuffer: 1024 * 1024 }); + if (extracted.status !== 0) throw new Error("Could not read the embedded candidate build identity"); + let parsed; + try { parsed = JSON.parse(String(extracted.stdout || "")); } catch { throw new Error("Embedded candidate build identity is not valid JSON"); } + return validateCandidateBuildIdentity(parsed, options); +} + +export function createCandidateManifest({ archivePath, outputPath, allowLocal = false }) { + const archive = resolve(archivePath); + const identity = candidateIdentityFromArchive(archive, { allowLocal }); + const artifact = { + name: basename(archive), + sha256: sha256File(archive), + bytes: statSync(archive).size, + }; + const manifest = { + schema: 1, + kind: CANDIDATE_KIND, + source: { + repository: identity.repository, + ref: identity.ref, + commit: identity.commit, + state: identity.source_state, + source_archive_sha256: identity.source_archive_sha256, + }, + version: identity.version, + build: { identity: identity.build_identity, created_at: identity.created_at }, + ci: identity.ci, + artifact, + sealed_oci: { sha256: identity.sealed_oci_sha256 }, + }; + writeFileSync(resolve(outputPath), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 }); + return manifest; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const archivePath = process.env.HELM_CANDIDATE_ARCHIVE || ""; + const outputPath = process.env.HELM_CANDIDATE_MANIFEST || ""; + if (!archivePath || !outputPath) { + process.stderr.write("Set HELM_CANDIDATE_ARCHIVE and HELM_CANDIDATE_MANIFEST.\n"); + process.exit(2); + } + try { + const manifest = createCandidateManifest({ + archivePath, + outputPath, + allowLocal: process.env.HELM_CANDIDATE_ALLOW_LOCAL === "1", + }); + process.stdout.write(`Candidate ${manifest.build.identity}: ${manifest.artifact.sha256}\n`); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} diff --git a/scripts/cleanup-report-lib.mjs b/scripts/cleanup-report-lib.mjs new file mode 100644 index 0000000..eca5731 --- /dev/null +++ b/scripts/cleanup-report-lib.mjs @@ -0,0 +1,129 @@ +import { lstat, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +const BACKUP_PATTERN = /^agent\.ts\.bak-normal-terminal-\d{8}-\d{6}$/; + +export function isGeneratedAgentBackup(name) { + return BACKUP_PATTERN.test(String(name)); +} + +function emptyScan(label, path) { + return { label, path, exists: false, fileCount: 0, bytes: 0, oldestMtimeMs: null, newestMtimeMs: null, incomplete: false }; +} + +function addFile(scan, stat) { + scan.fileCount += 1; + scan.bytes += stat.size; + scan.oldestMtimeMs = scan.oldestMtimeMs === null ? stat.mtimeMs : Math.min(scan.oldestMtimeMs, stat.mtimeMs); + scan.newestMtimeMs = scan.newestMtimeMs === null ? stat.mtimeMs : Math.max(scan.newestMtimeMs, stat.mtimeMs); +} + +export async function scanDirectory(root, label, displayPath, dependencies = {}) { + const lstatImpl = dependencies.lstatImpl || lstat; + const readdirImpl = dependencies.readdirImpl || readdir; + const scan = emptyScan(label, displayPath); + let rootEntries; + try { + const rootStat = await lstatImpl(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + scan.exists = true; + scan.incomplete = true; + scan.notDirectory = true; + return scan; + } + scan.exists = true; + rootEntries = await readdirImpl(root, { withFileTypes: true }); + } catch (error) { + if (error?.code !== "ENOENT") scan.incomplete = true; + return scan; + } + const pending = rootEntries.map((entry) => ({ entry, parent: root })); + while (pending.length) { + const { entry, parent } = pending.pop(); + const path = join(parent, entry.name); + if (entry.isDirectory()) { + try { + const children = await readdirImpl(path, { withFileTypes: true }); + pending.push(...children.map((child) => ({ entry: child, parent: path }))); + } catch { scan.incomplete = true; } + continue; + } + try { addFile(scan, await lstatImpl(path)); } catch { scan.incomplete = true; } + } + return scan; +} + +async function scanBackups(root) { + const displayPath = "src/server/agent.ts.bak-normal-terminal-"; + const scan = emptyScan("Timestamped agent.ts backups", displayPath); + const server = join(root, "src", "server"); + let entries; + try { entries = await readdir(server, { withFileTypes: true }); } + catch (error) { + if (error?.code !== "ENOENT") scan.incomplete = true; + return scan; + } + for (const entry of entries) { + if (!isGeneratedAgentBackup(entry.name) || entry.isDirectory()) continue; + scan.exists = true; + try { addFile(scan, await lstat(join(server, entry.name))); } catch { scan.incomplete = true; } + } + return scan; +} + +export async function collectCleanupReport(root, now = Date.now()) { + const paths = await Promise.all([ + scanDirectory(join(root, ".release-tmp"), "Release scratch data", ".release-tmp/"), + scanDirectory(join(root, ".native-test-data"), "Native test data", ".native-test-data/"), + scanBackups(root), + ]); + return { checkedAt: new Date(now).toISOString(), readOnly: true, removed: false, paths }; +} + +export function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; +} + +export function formatAge(timestamp, now) { + if (timestamp === null) return "unknown"; + const hours = Math.max(0, Math.floor((now - timestamp) / 3_600_000)); + if (hours < 1) return "less than one hour"; + if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"}`; + const days = Math.floor(hours / 24); + return `${days} days`; +} + +export function formatCleanupReport(report) { + const now = Date.parse(report.checkedAt); + const lines = [ + "1Helm generated-state cleanup report", + `Checked: ${report.checkedAt}`, + "Read-only report: this command has no removal mode and nothing was removed.", + ]; + for (const path of report.paths) { + lines.push("", path.label, ` Path: ${path.path}`); + if (!path.exists) lines.push(" Status: not present"); + else if (path.notDirectory) lines.push(" Status: present but not scanned because it is not a normal directory"); + else if (path.fileCount === 0 && path.incomplete) lines.push(" Status: present, but contents could not be fully enumerated"); + else if (path.fileCount === 0) lines.push(" Status: present and empty"); + else { + lines.push(` Contents: ${path.fileCount} file${path.fileCount === 1 ? "" : "s"}, ${formatBytes(path.bytes)}`); + lines.push(` Oldest item age: ${formatAge(path.oldestMtimeMs, now)}`); + } + if (path.incomplete) lines.push(" Uncertainty: scan incomplete (some entries could not be read)"); + } + const candidates = report.paths.filter((path) => path.exists && path.fileCount > 0).map((path) => path.path); + lines.push("", candidates.length + ? `Likely removable generated state (review first): ${candidates.join(", ")}` + : "No generated files matching the known paths were found."); + lines.push("Nothing was removed."); + return `${lines.join("\n")}\n`; +} diff --git a/scripts/cleanup-report.mjs b/scripts/cleanup-report.mjs new file mode 100644 index 0000000..7a26abe --- /dev/null +++ b/scripts/cleanup-report.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { collectCleanupReport, formatCleanupReport } from "./cleanup-report-lib.mjs"; + +const HELP = `Usage: npm run cleanup:report -- [--json] + +Reports counts, sizes, and age for .release-tmp/, .native-test-data/, and known +timestamped agent.ts backups. The scan is read-only and bounded to those paths. +This command has no removal mode. +`; + +const args = process.argv.slice(2); +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(HELP); + process.exit(0); +} +if (args.some((arg) => arg !== "--json")) { + process.stderr.write(`${HELP}\nUnknown option.\n`); + process.exit(2); +} + +const report = await collectCleanupReport(resolve(import.meta.dirname, "..")); +process.stdout.write(args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatCleanupReport(report)); diff --git a/scripts/delivery-status-lib.mjs b/scripts/delivery-status-lib.mjs new file mode 100644 index 0000000..1fd00fc --- /dev/null +++ b/scripts/delivery-status-lib.mjs @@ -0,0 +1,358 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +const DEFAULT_TIMEOUT_MS = 3_000; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export const DEFAULT_STATUS_CONFIG = Object.freeze({ + localUrl: "http://127.0.0.1:8123", + siteUrl: "https://1helm.com", + fixtureUrl: null, + fixtureHost: null, + fixtureId: null, + candidateUrl: null, + candidateHost: null, + candidateId: null, + timeoutMs: DEFAULT_TIMEOUT_MS, +}); + +function configuredUrl(value, label) { + let url; + try { url = new URL(String(value)); } catch { throw new Error(`${label} must be an http(s) URL`); } + if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error(`${label} must be an http(s) URL without credentials, a query, or a fragment`); + } + return url.toString().replace(/\/$/, ""); +} + +export function statusConfig(env = process.env) { + const timeoutMs = Number(env.HELM_STATUS_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); + const fixtureValues = [env.HELM_STATUS_FIXTURE_URL, env.HELM_STATUS_FIXTURE_HOST, env.HELM_STATUS_FIXTURE_ID]; + const fixtureConfigured = fixtureValues.every((value) => String(value || "").trim()); + if (!fixtureConfigured && fixtureValues.some((value) => String(value || "").trim())) { + throw new Error("fixture probing requires HELM_STATUS_FIXTURE_URL, HELM_STATUS_FIXTURE_HOST, and HELM_STATUS_FIXTURE_ID together"); + } + const fixtureHost = fixtureConfigured ? String(env.HELM_STATUS_FIXTURE_HOST) : null; + const fixtureId = fixtureConfigured ? String(env.HELM_STATUS_FIXTURE_ID) : null; + const candidateValues = [env.HELM_STATUS_CANDIDATE_URL, env.HELM_STATUS_CANDIDATE_HOST, env.HELM_STATUS_CANDIDATE_ID]; + const candidateConfigured = candidateValues.every((value) => String(value || "").trim()); + if (!candidateConfigured && candidateValues.some((value) => String(value || "").trim())) { + throw new Error("candidate probing requires HELM_STATUS_CANDIDATE_URL, HELM_STATUS_CANDIDATE_HOST, and HELM_STATUS_CANDIDATE_ID together"); + } + const candidateHost = candidateConfigured ? String(env.HELM_STATUS_CANDIDATE_HOST) : null; + const candidateId = candidateConfigured ? String(env.HELM_STATUS_CANDIDATE_ID) : null; + if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60_000) { + throw new Error("HELM_STATUS_TIMEOUT_MS must be an integer from 100 to 60000"); + } + if (fixtureHost && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fixtureHost)) throw new Error("HELM_STATUS_FIXTURE_HOST is invalid"); + if (fixtureId && !/^\d+$/.test(fixtureId)) throw new Error("HELM_STATUS_FIXTURE_ID must contain digits only"); + if (candidateHost && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(candidateHost)) throw new Error("HELM_STATUS_CANDIDATE_HOST is invalid"); + if (candidateId && !/^\d+$/.test(candidateId)) throw new Error("HELM_STATUS_CANDIDATE_ID must contain digits only"); + return { + localUrl: configuredUrl(env.HELM_STATUS_LOCAL_URL || DEFAULT_STATUS_CONFIG.localUrl, "HELM_STATUS_LOCAL_URL"), + siteUrl: configuredUrl(env.HELM_STATUS_SITE_URL || DEFAULT_STATUS_CONFIG.siteUrl, "HELM_STATUS_SITE_URL"), + fixtureUrl: fixtureConfigured ? configuredUrl(env.HELM_STATUS_FIXTURE_URL, "HELM_STATUS_FIXTURE_URL") : null, + fixtureHost, + fixtureId, + candidateUrl: candidateConfigured ? configuredUrl(env.HELM_STATUS_CANDIDATE_URL, "HELM_STATUS_CANDIDATE_URL") : null, + candidateHost, + candidateId, + timeoutMs, + }; +} + +export function parseCandidateEvidence(body) { + try { + const parsed = JSON.parse(body); + const candidate = parsed?.running_candidate; + const previous = parsed?.previous_candidate; + const attempt = parsed?.last_attempt; + const install = parsed?.install; + const rollback = parsed?.rollback; + const lastRollback = parsed?.last_rollback || rollback; + const validCandidate = (value, optional = false) => (optional && value == null) || ( + value && /^[a-f0-9]{40}$/.test(String(value.commit || "")) + && /^[a-f0-9]{64}$/.test(String(value.digest || "")) + && VERSION_PATTERN.test(String(value.version || "")) + && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(String(value.build_identity || "")) + ); + if (parsed?.schema !== 1 || parsed?.kind !== "1helm-dress-rehearsal-status" + || !validCandidate(candidate, true) || !validCandidate(previous, true) || !validCandidate(attempt) + || !["healthy", "failed"].includes(install?.result) + || !["healthy", "unhealthy", "unknown"].includes(install?.health) + || !["not_needed", "healthy", "failed", "unavailable"].includes(rollback?.result) + || !["not_needed", "healthy", "failed", "unavailable"].includes(lastRollback?.result)) return null; + return parsed; + } catch { + return null; + } +} + +export function parseAppStatus(body) { + try { + const parsed = JSON.parse(body); + if (parsed?.product !== "1Helm" || !VERSION_PATTERN.test(String(parsed.version || ""))) return null; + return { version: String(parsed.version) }; + } catch { + return null; + } +} + +export function parseWebsiteStatus(body) { + try { + const parsed = JSON.parse(body); + if (parsed?.product !== "1Helm" || parsed?.surface !== "website" || parsed?.ok !== true + || !VERSION_PATTERN.test(String(parsed.version || ""))) return null; + return { version: String(parsed.version) }; + } catch { + return null; + } +} + +export function parseStableArtifact(body) { + try { + const parsed = JSON.parse(body); + const version = String(parsed?.version || ""); + const sha256 = String(parsed?.sha256 || "").toLowerCase(); + const url = new URL(String(parsed?.url || "")); + const artifact = basename(url.pathname); + if (!VERSION_PATTERN.test(version) || !/^https:$/.test(url.protocol) + || artifact !== `1Helm-${version}-linux-node.tgz` || !/^[a-f0-9]{64}$/.test(sha256)) return null; + return { version, artifact, sha256 }; + } catch { + return null; + } +} + +export function parsePctStatus(output) { + const match = String(output).trim().match(/^status:\s*([a-z-]+)$/i); + return match ? match[1].toLowerCase() : null; +} + +function defaultRunCommand(file, args, timeoutMs, options = {}) { + return new Promise((resolveResult) => { + execFile(file, args, { + timeout: timeoutMs, + maxBuffer: 64 * 1024, + windowsHide: true, + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + cwd: options.cwd, + }, (error, stdout) => { + resolveResult({ + ok: !error, + stdout: String(stdout || ""), + timedOut: Boolean(error?.killed || error?.code === "ETIMEDOUT"), + }); + }); + }); +} + +async function fetchText(fetchImpl, url, timeoutMs) { + try { + const response = await fetchImpl(url, { + headers: { accept: "application/json,text/html;q=0.5", "user-agent": "1helm-read-only-status" }, + redirect: "follow", + signal: AbortSignal.timeout(timeoutMs), + }); + return { reachable: true, ok: response.ok, status: response.status, body: await response.text() }; + } catch (error) { + const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError"; + return { reachable: false, ok: false, status: null, body: "", timedOut }; + } +} + +async function appProbe(fetchImpl, baseUrl, timeoutMs) { + const endpoint = new URL("/api/mobile/compatibility", `${baseUrl}/`).toString(); + const response = await fetchText(fetchImpl, endpoint, timeoutMs); + if (!response.reachable) return { health: "unreachable", version: null, detail: response.timedOut ? "request timed out" : "could not connect" }; + if (!response.ok) return { health: "unhealthy", version: null, detail: `health endpoint returned HTTP ${response.status}` }; + const identity = parseAppStatus(response.body); + if (!identity) return { health: "uncertain", version: null, detail: "endpoint responded but did not prove it is 1Helm" }; + return { health: "healthy", version: identity.version, detail: "1Helm health endpoint responded" }; +} + +async function siteProbe(fetchImpl, baseUrl, timeoutMs) { + const healthResponse = await fetchText(fetchImpl, new URL("/health", `${baseUrl}/`).toString(), timeoutMs); + if (!healthResponse.reachable) { + return { health: "unreachable", version: null, stableVersion: null, commit: null, artifact: null, sha256: null, detail: healthResponse.timedOut ? "request timed out" : "could not connect" }; + } + if (!healthResponse.ok) { + return { health: "unhealthy", version: null, stableVersion: null, commit: null, artifact: null, sha256: null, detail: `website health endpoint returned HTTP ${healthResponse.status}` }; + } + const identity = parseWebsiteStatus(healthResponse.body); + const metadata = await fetchText(fetchImpl, new URL("/api/releases/linux/latest", `${baseUrl}/`).toString(), timeoutMs); + const artifact = metadata.ok ? parseStableArtifact(metadata.body) : null; + return { + health: identity ? "healthy" : "uncertain", + version: identity?.version || null, + stableVersion: artifact?.version || null, + commit: null, + artifact: artifact?.artifact || null, + sha256: artifact?.sha256 || null, + detail: identity + ? "1Helm website health endpoint responded; site commit is not exposed" + : "health endpoint responded but did not prove it is the 1Helm website", + }; +} + +async function fixtureProbe(fetchImpl, runCommand, config) { + if (!config.fixtureUrl || !config.fixtureHost || !config.fixtureId) { + return { health: "not_configured", version: null, commit: null, artifact: null, lxcState: null, detail: "fixture probing is not configured locally" }; + } + const sshArgs = [ + "-T", + "-o", "BatchMode=yes", + "-o", "ClearAllForwardings=yes", + "-o", "ControlMaster=no", + "-o", "ControlPath=none", + "-o", "KbdInteractiveAuthentication=no", + "-o", "PasswordAuthentication=no", + "-o", "PermitLocalCommand=no", + "-o", "StrictHostKeyChecking=yes", + "-o", "UpdateHostKeys=no", + "-o", `ConnectTimeout=${Math.max(1, Math.ceil(config.timeoutMs / 1000))}`, + config.fixtureHost, "pct", "status", config.fixtureId, + ]; + const [command, app] = await Promise.all([ + runCommand("ssh", sshArgs, config.timeoutMs), + appProbe(fetchImpl, config.fixtureUrl, config.timeoutMs), + ]); + const lxcState = command.ok ? parsePctStatus(command.stdout) : null; + let health; + if (lxcState === "running") health = app.health === "healthy" ? "healthy" : app.health === "uncertain" ? "uncertain" : "unhealthy"; + else if (lxcState === "stopped") health = app.health === "unreachable" ? "unhealthy" : "uncertain"; + else if (lxcState) health = "uncertain"; + else health = app.health === "unreachable" ? "unreachable" : "uncertain"; + + const infrastructure = lxcState || (command.timedOut ? "unreachable (timed out)" : "unreachable"); + const detail = !lxcState + ? `${app.detail}; LXC state could not be read` + : `LXC is ${lxcState}; ${app.detail}`; + return { health, version: app.version, commit: null, artifact: null, lxcState: infrastructure, detail }; +} + +async function candidateProbe(fetchImpl, runCommand, config) { + if (!config.candidateUrl || !config.candidateHost || !config.candidateId) { + return { health: "not_configured", version: null, commit: null, artifact: null, evidence: null, detail: "dress-rehearsal probing is not configured locally" }; + } + const app = await appProbe(fetchImpl, config.candidateUrl, config.timeoutMs); + const sshArgs = [ + "-T", "-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", + "-o", "KbdInteractiveAuthentication=no", "-o", "PasswordAuthentication=no", "-o", "PermitLocalCommand=no", + "-o", "StrictHostKeyChecking=yes", "-o", "UpdateHostKeys=no", + "-o", `ConnectTimeout=${Math.max(1, Math.ceil(config.timeoutMs / 1000))}`, + config.candidateHost, "pct", "exec", config.candidateId, "--", "cat", "/var/lib/1helm-candidate/evidence/status.json", + ]; + const command = await runCommand("ssh", sshArgs, config.timeoutMs); + const evidence = command.ok ? parseCandidateEvidence(command.stdout) : null; + const running = evidence?.running_candidate || null; + let health = app.health; + if (!evidence) health = app.health === "unreachable" ? "unreachable" : "uncertain"; + else if (evidence.install.result === "failed" && evidence.rollback.result === "healthy" && app.health === "healthy" && app.version === running?.version) health = "healthy"; + else if (evidence.install.health !== "healthy" || evidence.install.result !== "healthy") health = "unhealthy"; + else if (app.health !== "healthy" || app.version !== running?.version) health = app.health === "unreachable" ? "unhealthy" : "uncertain"; + return { + health, + version: running?.version || app.version, + commit: running?.commit || null, + artifact: running?.digest || null, + evidence, + detail: evidence ? `${app.detail}; local candidate evidence was read` : `${app.detail}; local candidate evidence is unavailable or invalid`, + }; +} + +export async function repositoryIdentity(root = resolve(import.meta.dirname, ".."), runCommand = defaultRunCommand) { + let version = null; + try { + const candidate = String(JSON.parse(await readFile(join(root, "package.json"), "utf8")).version || ""); + version = VERSION_PATTERN.test(candidate) ? candidate : null; + } catch { /* reported as unknown */ } + const [commitResult, dirtyResult] = await Promise.all([ + runCommand("git", ["rev-parse", "--short=12", "HEAD"], DEFAULT_TIMEOUT_MS, { cwd: root }), + runCommand("git", ["status", "--porcelain"], DEFAULT_TIMEOUT_MS, { cwd: root }), + ]); + return { + version, + commit: commitResult.ok ? commitResult.stdout.trim() || null : null, + dirty: dirtyResult.ok ? Boolean(dirtyResult.stdout.trim()) : null, + }; +} + +export async function collectEnvironmentStatus(config, dependencies = {}) { + const fetchImpl = dependencies.fetchImpl || globalThis.fetch; + const runCommand = dependencies.runCommand || defaultRunCommand; + const source = dependencies.sourceIdentity || await repositoryIdentity(dependencies.root, runCommand); + const [local, site, fixture, candidate] = await Promise.all([ + appProbe(fetchImpl, config.localUrl, config.timeoutMs), + siteProbe(fetchImpl, config.siteUrl, config.timeoutMs), + fixtureProbe(fetchImpl, runCommand, config), + candidateProbe(fetchImpl, runCommand, config), + ]); + return { + checkedAt: new Date(dependencies.now ?? Date.now()).toISOString(), + readOnly: true, + source, + environments: [ + { id: "local", name: "Local standalone app", target: config.localUrl, commit: null, artifact: null, ...local }, + { id: "website", name: "Public website service", target: config.siteUrl, ...site }, + { + id: "fixture", + name: "Linux acceptance fixture", + target: config.fixtureUrl ? `LXC ${config.fixtureId} on ${config.fixtureHost}; ${config.fixtureUrl}` : "not configured", + ...fixture, + }, + { + id: "candidate", + name: "Private dress-rehearsal candidate", + target: config.candidateUrl || "not configured", + ...candidate, + }, + ], + }; +} + +const shown = (value) => value ?? "unknown"; +const healthLabel = (value) => String(value || "unknown").replaceAll("_", " ").toUpperCase(); + +export function formatEnvironmentStatus(report) { + const sourceIdentity = report.source.version || report.source.commit + ? `${report.source.version ? `v${report.source.version}` : "version unknown"} @ ${shown(report.source.commit)}` + : "unknown"; + const dirty = report.source.dirty === null ? "unknown" : report.source.dirty ? "has local changes" : "clean"; + const lines = [ + "1Helm environment status", + `Checked: ${report.checkedAt}`, + "Read-only check: no services, containers, releases, files, or data were changed.", + "", + "Source checkout", + ` Identity: ${sourceIdentity}`, + ` Working tree: ${dirty}`, + ]; + for (const environment of report.environments) { + lines.push("", environment.name, ` Target: ${environment.target}`, ` Health: ${healthLabel(environment.health)} — ${environment.detail}`); + if (environment.id === "website") { + lines.push(` Site version: ${environment.version ? `v${environment.version}` : "unknown"}`); + lines.push(` Stable artifact: ${environment.artifact ? `${environment.artifact} (v${environment.stableVersion}, sha256 ${environment.sha256.slice(0, 12)}…)` : "unknown"}`); + lines.push(` Site commit: ${shown(environment.commit)}`); + } else if (environment.id === "candidate") { + const evidence = environment.evidence; + const running = evidence?.running_candidate; + const previous = evidence?.previous_candidate; + const ci = running?.ci || evidence?.last_attempt?.ci; + lines.push(` Running: ${running ? `v${running.version} @ ${running.commit}` : "unknown"}`); + lines.push(` Digest: ${running?.digest || "unknown"}`); + lines.push(` Build identity: ${running?.build_identity || "unknown"}`); + lines.push(` CI result: ${running?.source_state !== "trusted-main" ? "not run (local provisioning proof)" : ci ? `${ci.workflow} run ${ci.run_id} — ${ci.conclusion}` : "unknown"}`); + lines.push(` Install health: ${evidence ? `${evidence.install.result} / ${evidence.install.health} at ${evidence.install.checked_at}` : "unknown"}`); + lines.push(` Previous candidate: ${previous ? `${previous.commit} / ${previous.digest}` : "none or unknown"}`); + const rollback = evidence?.last_rollback || evidence?.rollback; + lines.push(` Rollback: ${rollback ? `${rollback.result} at ${rollback.checked_at}` : "unknown"}`); + } else { + if (environment.lxcState) lines.push(` LXC state: ${environment.lxcState}`); + lines.push(` Runtime version: ${environment.version ? `v${environment.version}` : "unknown"}`); + } + } + lines.push("", "Nothing was changed."); + return `${lines.join("\n")}\n`; +} diff --git a/scripts/delivery-status.mjs b/scripts/delivery-status.mjs new file mode 100644 index 0000000..2a5de32 --- /dev/null +++ b/scripts/delivery-status.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { collectEnvironmentStatus, formatEnvironmentStatus, statusConfig } from "./delivery-status-lib.mjs"; + +const HELP = `Usage: npm run delivery:status -- [--json] + +Read-only status for the source checkout, local standalone app, public website, +Linux updater fixture, and optional private dress-rehearsal evidence. Unavailable +targets are reported, not treated as a reason to change anything. + +Configuration: + HELM_STATUS_LOCAL_URL default http://127.0.0.1:8123 + HELM_STATUS_SITE_URL default https://1helm.com + HELM_STATUS_FIXTURE_URL optional; configure all three fixture values + HELM_STATUS_FIXTURE_HOST optional; configure all three fixture values + HELM_STATUS_FIXTURE_ID optional; configure all three fixture values + HELM_STATUS_CANDIDATE_URL optional; configure all three candidate values + HELM_STATUS_CANDIDATE_HOST optional local SSH alias for the Proxmox host + HELM_STATUS_CANDIDATE_ID optional local guest ID; evidence read is fixed + HELM_STATUS_TIMEOUT_MS default 3000 +`; + +const args = process.argv.slice(2); +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(HELP); + process.exit(0); +} +if (args.some((arg) => arg !== "--json")) { + process.stderr.write(`${HELP}\nUnknown option.\n`); + process.exit(2); +} + +try { + const report = await collectEnvironmentStatus(statusConfig()); + process.stdout.write(args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatEnvironmentStatus(report)); +} catch (error) { + process.stderr.write(`Status configuration error: ${error.message}\n`); + process.exitCode = 2; +} diff --git a/scripts/fast-test-lib.mjs b/scripts/fast-test-lib.mjs new file mode 100644 index 0000000..9139dcb --- /dev/null +++ b/scripts/fast-test-lib.mjs @@ -0,0 +1,22 @@ +import { existsSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export const DEFAULT_FAST_TESTS = Object.freeze([ + "test/phase1-tools.mjs", + "test/delivery-status.mjs", + "test/cleanup-report.mjs", + "test/delivery-governance.mjs", +]); + +export function selectFastTests(root, args, exists = existsSync) { + const selected = args.length ? args : [...DEFAULT_FAST_TESTS]; + return selected.map((argument) => { + const path = resolve(root, argument); + const local = relative(root, path); + if (isAbsolute(local) || local === ".." || local.startsWith(`..${sep}`) || !/^test[/\\].+\.mjs$/.test(local)) { + throw new Error(`Fast tests must be explicit .mjs files inside test/: ${argument}`); + } + if (!exists(path)) throw new Error(`Fast test file does not exist: ${argument}`); + return local.split(sep).join("/"); + }); +} diff --git a/scripts/mnemosyne-test-runtime.mjs b/scripts/mnemosyne-test-runtime.mjs new file mode 100644 index 0000000..e91a6e5 --- /dev/null +++ b/scripts/mnemosyne-test-runtime.mjs @@ -0,0 +1,97 @@ +import { existsSync, mkdirSync, mkdtempSync, renameSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; + +export const MNEMOSYNE_VERSION = "3.14.0"; + +export function pythonRelativePath(platform = process.platform) { + return platform === "win32" ? join("Scripts", "python.exe") : join("bin", "python"); +} + +export function mnemosyneTestPaths(root, platform = process.platform) { + const environmentRoot = join(root, ".test-state", "mnemosyne", MNEMOSYNE_VERSION); + return { + cacheParent: dirname(environmentRoot), + environmentRoot, + cachePython: join(environmentRoot, "venv", pythonRelativePath(platform)), + }; +} + +export function selectMnemosyneRuntime({ explicitPython, cachePython, mode, validity }) { + if (explicitPython && validity.explicit) return { action: "reuse", runtime: explicitPython, source: "MNEMOSYNE_PYTHON" }; + if (mode === "cache" && cachePython && validity.cache) return { action: "reuse", runtime: cachePython, source: "generated test cache" }; + return { action: mode === "disposable" ? "prepare-disposable" : "prepare-cache", runtime: null, source: null }; +} + +function pinned(candidate, dependencies = {}) { + const exists = dependencies.existsSync || existsSync; + const spawn = dependencies.spawnSync || spawnSync; + return Boolean(candidate) && exists(candidate) && spawn(candidate, [ + "-c", `import mnemosyne; assert mnemosyne.__version__ == "${MNEMOSYNE_VERSION}"`, + ], { stdio: "ignore" }).status === 0; +} + +function modeFromEnvironment(env) { + const requested = String(env.HELM_TEST_MNEMOSYNE_MODE || "").trim().toLowerCase(); + if (requested && !["cache", "disposable"].includes(requested)) { + throw new Error("HELM_TEST_MNEMOSYNE_MODE must be either cache or disposable."); + } + return requested || (env.CI ? "disposable" : "cache"); +} + +export function prepareMnemosyneTestRuntime(root, env = process.env, platform = process.platform, dependencies = {}) { + const exists = dependencies.existsSync || existsSync; + const mkdir = dependencies.mkdirSync || mkdirSync; + const rename = dependencies.renameSync || renameSync; + const makeTemp = dependencies.mkdtempSync || mkdtempSync; + const spawn = dependencies.spawnSync || spawnSync; + const mode = modeFromEnvironment(env); + const pythonName = pythonRelativePath(platform); + const paths = mnemosyneTestPaths(root, platform); + const explicitPython = env.MNEMOSYNE_PYTHON || ""; + const decision = selectMnemosyneRuntime({ + explicitPython, + cachePython: paths.cachePython, + mode, + validity: { + explicit: pinned(explicitPython, { existsSync: exists, spawnSync: spawn }), + cache: pinned(paths.cachePython, { existsSync: exists, spawnSync: spawn }), + }, + }); + if (decision.action === "reuse") return { ...decision, cleanupRoot: "" }; + + let disposableRoot = ""; + if (decision.action === "prepare-cache") { + mkdir(paths.cacheParent, { recursive: true }); + if (exists(paths.environmentRoot)) { + const quarantined = `${paths.environmentRoot}.invalid-${Date.now()}-${process.pid}`; + rename(paths.environmentRoot, quarantined); + process.stdout.write(`Pinned Mnemosyne cache was invalid; preserved it at ${quarantined}.\n`); + } + } else { + disposableRoot = makeTemp(join(tmpdir(), "1helm-mnemosyne-test-")); + } + + const installers = [...new Set([env.PYTHON || "", "python3", ...(platform === "darwin" ? ["/usr/bin/python3"] : [])].filter(Boolean))]; + for (let index = 0; index < installers.length; index += 1) { + const attemptRoot = decision.action === "prepare-cache" + ? `${paths.environmentRoot}.install-${Date.now()}-${process.pid}-${index}` + : join(disposableRoot, `attempt-${index}`); + const venv = join(attemptRoot, "venv"); + mkdir(attemptRoot, { recursive: true }); + if (spawn(installers[index], ["-m", "venv", venv], { stdio: "ignore" }).status !== 0) continue; + const candidate = join(venv, pythonName); + const installed = spawn(candidate, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--ignore-requires-python", `mnemosyne-memory==${MNEMOSYNE_VERSION}`], { stdio: "inherit" }); + if (installed.status !== 0 || !pinned(candidate, { existsSync: exists, spawnSync: spawn })) continue; + if (decision.action === "prepare-cache") { + try { rename(attemptRoot, paths.environmentRoot); } + catch (error) { + if (!pinned(paths.cachePython, { existsSync: exists, spawnSync: spawn })) throw error; + } + return { action: "prepared", runtime: paths.cachePython, source: "generated test cache", cleanupRoot: "" }; + } + return { action: "prepared", runtime: candidate, source: "disposable runtime", cleanupRoot: disposableRoot }; + } + throw new Error("The test suite could not prepare its pinned Mnemosyne runtime."); +} diff --git a/scripts/package-linux-host.mjs b/scripts/package-linux-host.mjs index 2066bd1..e5ecfb3 100755 --- a/scripts/package-linux-host.mjs +++ b/scripts/package-linux-host.mjs @@ -84,6 +84,47 @@ try { headVersion = String(JSON.parse(String(headPackage.stdout || "{}")).versio if (headPackage.status !== 0 || headVersion !== version) { throw new Error("Linux packaging version does not match package.json at Git HEAD"); } +const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); +const headSha = String(headResult.stdout || "").trim(); +if (headResult.status !== 0 || !/^[a-f0-9]{40}$/.test(headSha)) throw new Error("Could not resolve the exact Linux package source commit"); + +const candidateRequested = Boolean(process.env.HELM_CANDIDATE_BUILD_ID); +const candidateIdentity = candidateRequested ? { + schema: 1, + kind: "1helm-dress-rehearsal-candidate", + repository: String(process.env.HELM_CANDIDATE_REPOSITORY || ""), + ref: String(process.env.HELM_CANDIDATE_REF || ""), + commit: String(process.env.HELM_CANDIDATE_COMMIT || ""), + source_state: String(process.env.HELM_CANDIDATE_SOURCE_STATE || ""), + build_identity: String(process.env.HELM_CANDIDATE_BUILD_ID || ""), + created_at: String(process.env.HELM_CANDIDATE_CREATED_AT || ""), + ci: { + workflow: String(process.env.HELM_CANDIDATE_CI_WORKFLOW || ""), + run_id: String(process.env.HELM_CANDIDATE_CI_RUN_ID || ""), + conclusion: String(process.env.HELM_CANDIDATE_CI_CONCLUSION || ""), + }, + version, +} : null; +if (candidateIdentity) { + const trustedMain = candidateIdentity.source_state === "trusted-main"; + const validCi = trustedMain + ? candidateIdentity.ci.workflow === "CI" && /^\d+$/.test(candidateIdentity.ci.run_id) && candidateIdentity.ci.conclusion === "success" + : candidateIdentity.ci.workflow === "local" && candidateIdentity.ci.run_id === "0" && candidateIdentity.ci.conclusion === "not_run"; + if (candidateIdentity.repository !== "gitcommit90/1Helm" + || candidateIdentity.ref !== "refs/heads/main" + || candidateIdentity.commit !== headSha + || !["trusted-main", "local-worktree", "rollback-fixture"].includes(candidateIdentity.source_state) + || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(candidateIdentity.build_identity) + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(candidateIdentity.created_at) + || !validCi) { + throw new Error("Candidate packaging requires the exact trusted repository/ref/commit, successful CI identity, and bounded build identity"); + } + const worktree = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }); + if (worktree.status !== 0) throw new Error("Could not inspect the candidate source worktree"); + if (candidateIdentity.source_state === "trusted-main" && String(worktree.stdout || "").trim()) { + throw new Error("Trusted-main candidate packaging requires a clean exact checkout"); + } +} for (const rel of sealed.slice(0, 2)) { if (!existsSync(resolve(root, rel))) { throw new Error(`Linux packaging requires the sealed channel image at ${rel} (run scripts/build-oci-channel-image.sh on a builder host).`); @@ -119,13 +160,25 @@ rmSync(output, { force: true }); const stage = mkdtempSync(join(tmpdir(), "1helm-linux-pkg-")); try { const prefix = `1Helm-${version}`; - const archive = spawnSync("git", ["archive", "--format=tar", `--prefix=${prefix}/`, "HEAD"], { - cwd: root, - encoding: "buffer", - maxBuffer: 512 * 1024 * 1024, - }); - if (archive.status !== 0) throw new Error("Could not package the exact Git release source"); - if (!archive.stdout?.length) throw new Error("Exact Git release source archive was empty"); + let archive; + if (["local-worktree", "rollback-fixture"].includes(candidateIdentity?.source_state)) { + const files = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { + cwd: root, encoding: "buffer", maxBuffer: 64 * 1024 * 1024, + }); + if (files.status !== 0 || !files.stdout?.length) throw new Error("Could not enumerate the local candidate worktree"); + archive = spawnSync("tar", ["-cf", "-", "--null", "--files-from=-", `--transform=s,^,${prefix}/,`], { + cwd: root, input: files.stdout, encoding: "buffer", maxBuffer: 512 * 1024 * 1024, + }); + } else { + archive = spawnSync("git", ["archive", "--format=tar", `--prefix=${prefix}/`, "HEAD"], { + cwd: root, + encoding: "buffer", + maxBuffer: 512 * 1024 * 1024, + }); + } + if (archive.status !== 0) throw new Error("Could not package the exact Git candidate source"); + if (!archive.stdout?.length) throw new Error("Exact Git candidate source archive was empty"); + const sourceArchiveSha256 = createHash("sha256").update(archive.stdout).digest("hex"); const extract = spawnSync("tar", ["-xf", "-", "-C", stage], { input: archive.stdout, stdio: ["pipe", "inherit", "inherit"] }); if (extract.status !== 0) throw new Error("Could not extract the Git release source for packaging"); @@ -141,6 +194,17 @@ try { if (!existsSync(src)) continue; copyFileSync(src, join(stage, prefix, rel)); } + if (candidateIdentity) { + const imageSha256 = String(readFileSync(resolve(root, "container/channel-machine.oci.sha256"), "utf8")).trim(); + if (!/^[a-f0-9]{64}$/.test(imageSha256)) throw new Error("Candidate packaging requires the sealed OCI image SHA-256"); + const identityFile = join(stage, prefix, "resources", "candidate-build.json"); + mkdirSync(dirname(identityFile), { recursive: true }); + writeFileSync(identityFile, `${JSON.stringify({ + ...candidateIdentity, + source_archive_sha256: sourceArchiveSha256, + sealed_oci_sha256: imageSha256, + }, null, 2)}\n`); + } const resourcesDir = join(stage, prefix, "resources"); mkdirSync(resourcesDir, { recursive: true }); diff --git a/scripts/preview-lib.mjs b/scripts/preview-lib.mjs new file mode 100644 index 0000000..53f3c57 --- /dev/null +++ b/scripts/preview-lib.mjs @@ -0,0 +1,165 @@ +import { createServer } from "node:net"; +import { lstat, readdir, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const DEFAULT_PREVIEW_PORT = 8124; +export const DEFAULT_STABLE_PORT = 8123; +export const PREVIEW_DATA_DIRECTORY = ".preview-data"; + +function portNumber(value, label) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1024 || port > 65_535) { + throw new Error(`${label} must be an integer from 1024 to 65535.`); + } + return port; +} + +function optionValue(args, index, name) { + const argument = args[index]; + if (argument === name) { + if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`${name} requires a value.`); + return { value: args[index + 1], consumed: 2 }; + } + if (argument.startsWith(`${name}=`)) return { value: argument.slice(name.length + 1), consumed: 1 }; + return null; +} + +function within(parent, child) { + const suffix = relative(parent, child); + return suffix === "" || (!suffix.startsWith(`..${sep}`) && suffix !== ".." && !isAbsolute(suffix)); +} + +export function previewConfig(root, args = [], env = process.env) { + let portValue = env.HELM_PREVIEW_PORT || DEFAULT_PREVIEW_PORT; + let dataValue = env.HELM_PREVIEW_DATA_DIR || PREVIEW_DATA_DIRECTORY; + for (let index = 0; index < args.length;) { + const port = optionValue(args, index, "--port"); + if (port) { portValue = port.value; index += port.consumed; continue; } + const data = optionValue(args, index, "--data-dir"); + if (data) { dataValue = data.value; index += data.consumed; continue; } + throw new Error(`Unknown preview option: ${args[index]}`); + } + + const port = portNumber(portValue, "Preview port"); + const stablePort = portNumber(env.HELM_STABLE_PORT || DEFAULT_STABLE_PORT, "Stable port"); + if (port === DEFAULT_STABLE_PORT || port === stablePort) { + throw new Error(`Preview refuses Stable port ${port}. Choose another port with --port (for example, 8124).`); + } + + const projectRoot = resolve(root); + const generatedRoot = join(projectRoot, PREVIEW_DATA_DIRECTORY); + const dataDir = resolve(projectRoot, String(dataValue)); + if (!within(generatedRoot, dataDir)) { + throw new Error(`Preview data must stay inside ${generatedRoot}; normal app and production data paths are refused.`); + } + return { root: projectRoot, port, stablePort, dataDir, generatedRoot, url: `http://127.0.0.1:${port}` }; +} + +export async function assertSafePreviewData(config, dependencies = {}) { + const lstatImpl = dependencies.lstatImpl || lstat; + const realpathImpl = dependencies.realpathImpl || realpath; + const projectRoot = await realpathImpl(config.root); + const generatedRoot = join(projectRoot, PREVIEW_DATA_DIRECTORY); + const relativeData = relative(config.root, config.dataDir); + const segments = relativeData.split(sep).filter(Boolean); + let candidate = projectRoot; + for (const segment of segments) { + candidate = join(candidate, segment); + try { + const stat = await lstatImpl(candidate); + if (stat.isSymbolicLink()) throw new Error(`Preview data path contains a symbolic link: ${candidate}`); + if (!stat.isDirectory()) throw new Error(`Preview data path is not a directory: ${candidate}`); + } catch (error) { + if (error?.code === "ENOENT") break; + throw error; + } + } + if (!within(generatedRoot, join(projectRoot, relativeData))) { + throw new Error("Preview data resolved outside the generated preview-data directory."); + } +} + +export function assertPortAvailable(port, host = "127.0.0.1", createServerImpl = createServer) { + return new Promise((resolveAvailable, reject) => { + const probe = createServerImpl(); + probe.unref?.(); + probe.once("error", (error) => { + if (error?.code === "EADDRINUSE") { + reject(new Error(`Preview port ${port} is already occupied. Stop that process or run npm run preview -- --port .`)); + } else { + reject(new Error(`Preview cannot use ${host}:${port}: ${error?.message || error}`)); + } + }); + probe.listen(port, host, () => probe.close((error) => error ? reject(error) : resolveAvailable())); + }); +} + +export class ServerRestarter { + constructor({ start, stop, delayMs = 120, setTimer = setTimeout, clearTimer = clearTimeout }) { + this.startChild = start; + this.stopChild = stop; + this.delayMs = delayMs; + this.setTimer = setTimer; + this.clearTimer = clearTimer; + this.child = null; + this.timer = null; + this.queue = Promise.resolve(); + this.closed = false; + } + + async start() { + if (this.closed) throw new Error("Preview server restarter is closed."); + this.child = await this.startChild(); + return this.child; + } + + changed() { + if (this.closed) return; + if (this.timer) this.clearTimer(this.timer); + this.timer = this.setTimer(() => { + this.timer = null; + this.queue = this.queue.then(async () => { + if (this.child) await this.stopChild(this.child); + if (!this.closed) this.child = await this.startChild(); + }); + }, this.delayMs); + } + + async close() { + this.closed = true; + if (this.timer) { this.clearTimer(this.timer); this.timer = null; } + await this.queue; + if (this.child) { await this.stopChild(this.child); this.child = null; } + } +} + +export async function watchDirectoryTree(root, onChange, dependencies = {}) { + const readdirImpl = dependencies.readdirImpl || readdir; + const watchImpl = dependencies.watchImpl || (await import("node:fs")).watch; + const watchers = new Map(); + let closed = false; + + const add = async (directory) => { + if (closed || watchers.has(directory)) return; + const entries = await readdirImpl(directory, { withFileTypes: true }).catch(() => []); + const watcher = watchImpl(directory, (event, filename) => { + if (!filename) return; + const path = join(directory, String(filename)); + onChange(path, event); + if (event === "rename") void scan(directory); + }); + watcher.on?.("error", (error) => onChange(directory, "watch-error", error)); + watchers.set(directory, watcher); + await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => add(join(directory, entry.name)))); + }; + const scan = async (directory) => { + const entries = await readdirImpl(directory, { withFileTypes: true }).catch(() => []); + await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => add(join(directory, entry.name)))); + }; + await add(root); + return () => { + closed = true; + for (const watcher of watchers.values()) watcher.close(); + watchers.clear(); + }; +} diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 0000000..f87bacd --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { cp, mkdir, copyFile, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import * as esbuild from "esbuild"; +import { + assertPortAvailable, + assertSafePreviewData, + previewConfig, + ServerRestarter, + watchDirectoryTree, +} from "./preview-lib.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const HELP = `Usage: npm run preview -- [--port 8124] [--data-dir .preview-data] + +Launch the private development preview. It uses loopback and generated preview +data only; Stable port 8123 and normal app data paths are refused. +`; +if (process.argv.slice(2).some((argument) => argument === "--help" || argument === "-h")) { + process.stdout.write(HELP); + process.exit(0); +} + +let config; +const children = new Set(); +const intentionalServerStops = new WeakSet(); +let esbuildContext; +let closeServerWatch = () => {}; +let restarter; +let closing = false; + +function startChild(command, args, options = {}) { + const child = spawn(command, args, { + cwd: root, + env: process.env, + stdio: "inherit", + detached: process.platform !== "win32", + ...options, + }); + children.add(child); + child.once("exit", () => children.delete(child)); + return child; +} + +function waitForExit(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolveExit) => { + const timer = setTimeout(resolveExit, timeoutMs); + child.once("exit", () => { clearTimeout(timer); resolveExit(); }); + }); +} + +function signalChild(child, signal) { + if (child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } +} + +async function stopChild(child) { + signalChild(child, "SIGTERM"); + await waitForExit(child, 5_000); + if (child.exitCode === null && child.signalCode === null) { + signalChild(child, "SIGKILL"); + await waitForExit(child, 1_000); + } +} + +async function prepareExcalidrawAssets() { + const source = join(root, "node_modules", "@excalidraw", "excalidraw", "dist", "prod"); + const target = join(root, "public", "excalidraw"); + await mkdir(target, { recursive: true }); + await copyFile(join(source, "index.css"), join(target, "index.css")); + await cp(join(source, "fonts"), join(target, "fonts"), { recursive: true, force: true }); +} + +function startTailwindWatcher() { + const child = startChild(join(root, "node_modules", ".bin", "tailwindcss"), [ + "-i", "src/client/styles.css", "-o", "public/app.css", "--watch=always", + ], { stdio: ["ignore", "pipe", "pipe"] }); + return new Promise((resolveReady, reject) => { + let ready = false; + const inspect = (chunk, output) => { + output.write(chunk); + if (!ready && /Done in/.test(String(chunk))) { ready = true; resolveReady(child); } + }; + child.stdout.on("data", (chunk) => inspect(chunk, process.stdout)); + child.stderr.on("data", (chunk) => inspect(chunk, process.stderr)); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (!ready) reject(new Error(`Tailwind preview watcher exited before its initial build${signal ? ` (${signal})` : ` (exit ${code})`}.`)); + }); + }); +} + +async function localizeExcalidrawFonts() { + const bundle = join(root, "public", "bundle.js"); + const source = await readFile(bundle, "utf8"); + const pattern = /`https:\/\/esm\.sh\/\$\{.*?\}\/dist\/prod\/`/g; + const matches = source.match(pattern) || []; + if (matches.length !== 1) throw new Error(`Expected one Excalidraw CDN fallback in preview bundle.js, found ${matches.length}.`); + await writeFile(bundle, source.replace(pattern, 'window.location.origin+"/excalidraw/"')); +} + +async function startClientWatcher() { + esbuildContext = await esbuild.context({ + entryPoints: [join(root, "src", "client", "app.ts")], + bundle: true, + format: "esm", + outfile: join(root, "public", "bundle.js"), + loader: { ".css": "css" }, + plugins: [{ + name: "preview-self-host-excalidraw", + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length === 0) await localizeExcalidrawFonts(); + }); + }, + }], + }); + await esbuildContext.rebuild(); + await esbuildContext.watch(); +} + +function serverEnvironment() { + const environment = { + ...process.env, + NODE_ENV: "development", + PORT: String(config.port), + HELM_HOST: "127.0.0.1", + HELM_APP_ROOT: root, + CTRL_DATA_DIR: config.dataDir, + HELM_CHANNEL_COMPUTER_BACKEND: "native", + HELM_OCI_HOST_STATE_ROOT: join(config.dataDir, "runtime", "oci-host"), + HELM_OCI_STATE_ROOT: join(config.dataDir, "runtime", "oci"), + ONEHELM_GOOGLE_CONNECTION_DIR: join(config.dataDir, "connections", "gmail"), + ONEHELM_GOOGLE_TOKENS_DIR: join(config.dataDir, "connections", "gmail", "tokens"), + }; + delete environment.HELM_ROUTER_PORT; + delete environment.HELM_OCI_STATE_ROOT_OVERRIDE; + return environment; +} + +function startServer() { + process.stdout.write("Preview server source ready; starting the isolated preview.\n"); + const child = startChild(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { env: serverEnvironment() }); + child.once("exit", (code, signal) => { + if (!closing && !intentionalServerStops.has(child)) { + process.stderr.write(`Preview server stopped unexpectedly${signal ? ` (${signal})` : ` (exit ${code})`}. See the startup error above.\n`); + void shutdown(1); + } + }); + return child; +} + +async function stopServer(child) { + intentionalServerStops.add(child); + await stopChild(child); +} + +async function waitUntilReady(child) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`The preview server exited before opening ${config.url}. The port or startup error is shown above.`); + } + try { + const response = await fetch(`${config.url}/api/setup/status`, { signal: AbortSignal.timeout(500) }); + if (response.ok) return; + } catch { /* startup is still in progress */ } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`The preview did not become ready at ${config.url} within 20 seconds.`); +} + +async function shutdown(code = 0) { + if (closing) return; + closing = true; + closeServerWatch(); + if (restarter) await restarter.close().catch(() => undefined); + const remaining = [...children]; + await Promise.all(remaining.map((child) => stopChild(child).catch(() => undefined))); + if (esbuildContext) await esbuildContext.dispose().catch(() => undefined); + process.stdout.write("Private preview stopped. Stable was untouched.\n"); + process.exitCode = code; +} + +process.once("SIGINT", () => { void shutdown(); }); +process.once("SIGTERM", () => { void shutdown(); }); + +try { + config = previewConfig(root, process.argv.slice(2)); + await assertSafePreviewData(config); + await assertPortAvailable(config.port); + await mkdir(config.dataDir, { recursive: true }); + await prepareExcalidrawAssets(); + await Promise.all([startClientWatcher(), startTailwindWatcher()]); + + restarter = new ServerRestarter({ start: startServer, stop: stopServer }); + const server = await restarter.start(); + closeServerWatch = await watchDirectoryTree(join(root, "src", "server"), (path, event, error) => { + if (event === "watch-error") { + process.stderr.write(`Preview server watcher error at ${path}: ${error?.message || error}\n`); + return; + } + if (/\.(?:ts|mjs|js|cjs|json)$/.test(path)) { + process.stdout.write(`Server source changed (${path.slice(root.length + 1)}); restarting the preview server only.\n`); + restarter.changed(); + } + }); + await waitUntilReady(server); + process.stdout.write(`\nPRIVATE PREVIEW: ${config.url}\n`); + process.stdout.write(`Preview data: ${config.dataDir}\n`); + process.stdout.write("Stable is untouched: this preview uses a different port and separate test data.\n"); + process.stdout.write("Client and server changes are watched. Refresh the private page manually after a change.\n"); + process.stdout.write("Press Ctrl+C to stop the preview and all of its child processes.\n\n"); +} catch (error) { + process.stderr.write(`Preview could not start: ${error.message}\n`); + await shutdown(1); +} diff --git a/scripts/run-fast-tests.mjs b/scripts/run-fast-tests.mjs new file mode 100644 index 0000000..bf9e1dd --- /dev/null +++ b/scripts/run-fast-tests.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { selectFastTests } from "./fast-test-lib.mjs"; + +const root = resolve(import.meta.dirname, ".."); +try { + const tests = selectFastTests(root, process.argv.slice(2)); + process.stdout.write(`Fast test selection: ${tests.join(", ")}\n`); + process.stdout.write("This is an inner-loop check only; npm run ci remains required before merge.\n"); + const result = spawnSync(process.execPath, ["--test", ...tests], { + cwd: root, + env: { ...process.env, NODE_ENV: "test" }, + stdio: "inherit", + }); + process.exitCode = result.status || (result.signal ? 1 : 0); +} catch (error) { + process.stderr.write(`Fast test selection error: ${error.message}\n`); + process.stderr.write("Usage: npm run test:fast -- [test/focused-file.mjs ...]\n"); + process.stderr.write("npm run ci remains required before merge.\n"); + process.exitCode = 2; +} diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 994059b..6d6f69f 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -1,44 +1,14 @@ #!/usr/bin/env node -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { prepareMnemosyneTestRuntime } from "./mnemosyne-test-runtime.mjs"; const root = resolve(import.meta.dirname, ".."); -const version = "3.14.0"; -const pythonName = process.platform === "win32" ? join("Scripts", "python.exe") : join("bin", "python"); -const candidates = [ - process.env.MNEMOSYNE_PYTHON || "", - join(root, "data-refactored", "mnemosyne-runtime", "venv", pythonName), -].filter(Boolean); +const prepared = prepareMnemosyneTestRuntime(root); +process.stdout.write(`Using pinned Mnemosyne 3.14.0 from ${prepared.source}.\n`); -const pinned = (candidate) => candidate && existsSync(candidate) && spawnSync(candidate, [ - "-c", `import mnemosyne; assert mnemosyne.__version__ == "${version}"`, -], { stdio: "ignore" }).status === 0; - -let runtime = candidates.find(pinned) || ""; -let disposableRoot = ""; -if (!runtime) { - disposableRoot = mkdtempSync(join(tmpdir(), "1helm-mnemosyne-test-")); - const venv = join(disposableRoot, "venv"); - const installers = [...new Set([process.env.PYTHON || "", "python3", ...(process.platform === "darwin" ? ["/usr/bin/python3"] : [])].filter(Boolean))]; - for (const installer of installers) { - // A failed interpreter can leave a partial venv whose Python symlinks - // poison the next fallback attempt. Each interpreter must start from its - // own clean disposable runtime, matching the production bootstrap. - if (existsSync(venv)) rmSync(venv, { recursive: true, force: true }); - if (spawnSync(installer, ["-m", "venv", venv], { stdio: "ignore" }).status !== 0) continue; - const candidate = join(venv, pythonName); - const installed = spawnSync(candidate, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--ignore-requires-python", `mnemosyne-memory==${version}`], { stdio: "inherit" }); - if (installed.status === 0 && pinned(candidate)) { runtime = candidate; break; } - } - if (!runtime) { - rmSync(disposableRoot, { recursive: true, force: true }); - throw new Error("The test suite could not prepare its pinned Mnemosyne runtime."); - } -} - -const env = { ...process.env, NODE_ENV: "test", MNEMOSYNE_PYTHON: runtime }; +const env = { ...process.env, NODE_ENV: "test", MNEMOSYNE_PYTHON: prepared.runtime }; const suites = [ ["test/native-world.mjs"], ["--test", @@ -47,7 +17,8 @@ const suites = [ "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", "test/feedback.mjs", "test/feedback-browser.mjs", "test/cowork-browser.mjs", "test/files-latency.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", - "test/notifications.mjs", "test/mobile-push.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs"], + "test/notifications.mjs", "test/mobile-push.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs", + "test/delivery-status.mjs", "test/cleanup-report.mjs", "test/delivery-governance.mjs", "test/phase1-tools.mjs", "test/phase2-candidate.mjs"], ]; let status = 0; @@ -57,6 +28,6 @@ try { if (result.status !== 0) { status = result.status || 1; break; } } } finally { - if (disposableRoot) rmSync(disposableRoot, { recursive: true, force: true }); + if (prepared.cleanupRoot) rmSync(prepared.cleanupRoot, { recursive: true, force: true }); } process.exit(status); diff --git a/test/cleanup-report.mjs b/test/cleanup-report.mjs new file mode 100644 index 0000000..bcd20f4 --- /dev/null +++ b/test/cleanup-report.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + formatAge, + formatBytes, + formatCleanupReport, + isGeneratedAgentBackup, + scanDirectory, +} from "../scripts/cleanup-report-lib.mjs"; + +test("cleanup report recognizes only the timestamped backup pattern", () => { + assert.equal(isGeneratedAgentBackup("agent.ts.bak-normal-terminal-20260804-024305"), true); + assert.equal(isGeneratedAgentBackup("agent.ts"), false); + assert.equal(isGeneratedAgentBackup("other.ts.bak-normal-terminal-20260804-024305"), false); +}); + +test("an existing generated directory remains present but incomplete when enumeration fails", async () => { + const directoryStat = { isDirectory: () => true, isSymbolicLink: () => false }; + const scan = await scanDirectory("/generated", "Generated", ".generated/", { + lstatImpl: async () => directoryStat, + readdirImpl: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); }, + }); + assert.equal(scan.exists, true); + assert.equal(scan.incomplete, true); + assert.equal(scan.fileCount, 0); + assert.match(formatCleanupReport({ checkedAt: new Date().toISOString(), paths: [scan] }), /present, but contents could not be fully enumerated[\s\S]*scan incomplete/); +}); + +test("cleanup report formats sizes and ages for a nontechnical reader", () => { + assert.equal(formatBytes(999), "999 B"); + assert.equal(formatBytes(1536), "1.5 KiB"); + assert.equal(formatAge(Date.UTC(2026, 7, 2), Date.UTC(2026, 7, 4, 12)), "2 days"); + + const text = formatCleanupReport({ + checkedAt: "2026-08-04T12:00:00.000Z", + readOnly: true, + removed: false, + paths: [ + { label: "Release scratch data", path: ".release-tmp/", exists: true, fileCount: 2, bytes: 1536, oldestMtimeMs: Date.UTC(2026, 7, 2), newestMtimeMs: Date.UTC(2026, 7, 4), incomplete: false }, + { label: "Native test data", path: ".native-test-data/", exists: false, fileCount: 0, bytes: 0, oldestMtimeMs: null, newestMtimeMs: null, incomplete: false }, + { label: "Timestamped backups", path: "src/server/agent.ts.bak-normal-terminal-", exists: true, fileCount: 1, bytes: 12, oldestMtimeMs: Date.UTC(2026, 7, 4), newestMtimeMs: Date.UTC(2026, 7, 4), incomplete: true }, + ], + }); + assert.match(text, /2 files, 1\.5 KiB/); + assert.match(text, /Status: not present/); + assert.match(text, /scan incomplete/); + assert.match(text, /nothing was removed/i); +}); diff --git a/test/delivery-governance.mjs b/test/delivery-governance.mjs new file mode 100644 index 0000000..07067ce --- /dev/null +++ b/test/delivery-governance.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +const root = join(import.meta.dirname, ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); + +test("the agent contract defaults to preview and requires a complete handoff", () => { + const contract = read("AGENTS.md"); + const claudeInstructions = read("CLAUDE.md"); + assert.match(contract, /default delivery mode[\s\S]*PREVIEW ONLY/i); + assert.match(claudeInstructions, /\[AGENTS\.md\]\(AGENTS\.md\)/); + assert.match(claudeInstructions, /authoritative delivery contract/i); + for (const boundary of ["bump versions", "tags", "releases", "deploy", "stable", "production data", "infrastructure", "broaden"]) { + assert.match(contract, new RegExp(boundary, "i")); + } + assert.match(contract, /npm run ci/); + for (const evidence of ["changed files", "checks run", "risks", "rollback", "stable"]) { + assert.match(contract, new RegExp(evidence, "i")); + } +}); + +test("the canary plan preserves the legacy fixture and constrains approved Phase 2", () => { + const plan = read("docs/canary-plan.md"); + assert.match(plan, /LXC 112[\s\S]*pve2[\s\S]*legacy[\s\S]*v0\.0\.38 updater fixture/i); + assert.match(plan, /Phase 2[\s\S]*fresh, unprivileged LXC/i); + assert.match(plan, /does not[\s\S]*create or modify infrastructure/i); + assert.match(plan, /source commit[\s\S]*artifact[\s\S]*SHA-256/i); + assert.match(plan, /rollback[\s\S]*LXC 112/i); +}); + +test("generated state is ignored and cleanup remains report-only", () => { + const ignores = read(".gitignore"); + const cleanup = read("scripts/cleanup-report-lib.mjs") + read("scripts/cleanup-report.mjs"); + for (const path of ["/.release-tmp/", "/.native-test-data/", "/src/server/agent.ts.bak-normal-terminal-"]) { + assert.match(ignores, new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.match(cleanup, /readOnly: true/); + assert.match(cleanup, /removed: false/); + assert.doesNotMatch(cleanup, /\brmSync\b|\bunlinkSync\b|\brmdirSync\b|\bwriteFileSync\b/); +}); diff --git a/test/delivery-status.mjs b/test/delivery-status.mjs new file mode 100644 index 0000000..8ad5e51 --- /dev/null +++ b/test/delivery-status.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collectEnvironmentStatus, + DEFAULT_STATUS_CONFIG, + formatEnvironmentStatus, + parseAppStatus, + parseCandidateEvidence, + parsePctStatus, + parseStableArtifact, + parseWebsiteStatus, + repositoryIdentity, + statusConfig, +} from "../scripts/delivery-status-lib.mjs"; + +const response = (status, body) => ({ ok: status >= 200 && status < 300, status, text: async () => body }); + +test("status parsers accept proven identities and reject lookalike responses", () => { + assert.deepEqual(parseAppStatus('{"product":"1Helm","version":"0.0.38"}'), { version: "0.0.38" }); + assert.equal(parseAppStatus('{"product":"something else","version":"0.0.38"}'), null); + assert.equal(parseAppStatus("not json"), null); + assert.deepEqual(parseWebsiteStatus('{"ok":true,"product":"1Helm","surface":"website","version":"0.0.41"}'), { version: "0.0.41" }); + assert.equal(parseWebsiteStatus('{"ok":true,"product":"1Helm","version":"0.0.41"}'), null); + assert.equal(parsePctStatus("status: running\n"), "running"); + assert.equal(parsePctStatus("unexpected output"), null); + + const artifact = parseStableArtifact(JSON.stringify({ + version: "0.0.41", + url: "https://github.com/gitcommit90/1Helm/releases/download/v0.0.41/1Helm-0.0.41-linux-node.tgz", + sha256: "a".repeat(64), + })); + assert.deepEqual(artifact, { version: "0.0.41", artifact: "1Helm-0.0.41-linux-node.tgz", sha256: "a".repeat(64) }); +}); + +test("candidate evidence parser requires complete honest install and rollback state", () => { + const candidate = { + commit: "a".repeat(40), digest: "b".repeat(64), version: "0.0.41", + build_identity: "candidate-1-2.1", ci: { workflow: "CI", run_id: "1", conclusion: "success" }, + }; + const evidence = { + schema: 1, kind: "1helm-dress-rehearsal-status", running_candidate: candidate, + last_attempt: candidate, previous_candidate: null, + install: { result: "healthy", health: "healthy", checked_at: "2026-08-04T12:00:00Z" }, + rollback: { result: "not_needed", checked_at: "2026-08-04T12:00:00Z" }, + last_rollback: { result: "not_needed", checked_at: "2026-08-04T12:00:00Z" }, + }; + assert.deepEqual(parseCandidateEvidence(JSON.stringify(evidence)), evidence); + assert.equal(parseCandidateEvidence(JSON.stringify({ ...evidence, install: { result: "maybe" } })), null); +}); + +test("status configuration rejects secret-bearing URLs and option-like SSH hosts", () => { + assert.throws(() => statusConfig({ HELM_STATUS_SITE_URL: "https://user:secret@example.com" }), /without credentials/); + assert.throws(() => statusConfig({ HELM_STATUS_FIXTURE_URL: "http://example.test/?token=secret", HELM_STATUS_FIXTURE_HOST: "fixture", HELM_STATUS_FIXTURE_ID: "112" }), /without credentials/); + assert.throws(() => statusConfig({ HELM_STATUS_FIXTURE_URL: "http://example.test", HELM_STATUS_FIXTURE_HOST: "-V", HELM_STATUS_FIXTURE_ID: "112" }), /HOST is invalid/); + assert.throws(() => statusConfig({ HELM_STATUS_FIXTURE_URL: "http://example.test" }), /requires.*together/); +}); + +test("private fixture identity is not tracked and is not probed until configured locally", async () => { + const config = statusConfig({}); + assert.equal(config.fixtureUrl, null); + assert.equal(config.fixtureHost, null); + assert.equal(config.fixtureId, null); + const urls = []; + const report = await collectEnvironmentStatus(config, { + fetchImpl: async (url) => { + urls.push(url); + if (url.endsWith("/health")) return response(200, '{"ok":true,"product":"1Helm","surface":"website","version":"0.0.41"}'); + if (url.endsWith("/api/releases/linux/latest")) return response(503, ""); + return response(200, '{"product":"1Helm","version":"0.0.41"}'); + }, + runCommand: async () => { throw new Error("fixture command must not run"); }, + sourceIdentity: { version: "0.0.41", commit: "abc123", dirty: false }, + }); + const fixture = report.environments.find(({ id }) => id === "fixture"); + assert.equal(fixture.health, "not_configured"); + assert.equal(fixture.target, "not configured"); + assert.equal(urls.some((url) => /192\.168\.|pve2/.test(url)), false); +}); + +test("status collection remains honest when remote state is unavailable", async () => { + const fetchImpl = async (url) => { + if (url.startsWith(DEFAULT_STATUS_CONFIG.localUrl)) return response(200, '{"product":"1Helm","version":"0.0.41"}'); + if (url === `${DEFAULT_STATUS_CONFIG.siteUrl}/health`) return response(200, '{"ok":true,"product":"1Helm","surface":"website","version":"0.0.41"}'); + if (url.includes("/api/releases/linux/latest")) return response(503, "unavailable"); + throw Object.assign(new Error("offline"), { name: "TypeError" }); + }; + const report = await collectEnvironmentStatus(DEFAULT_STATUS_CONFIG, { + fetchImpl, + runCommand: async () => ({ ok: false, stdout: "", timedOut: true }), + sourceIdentity: { version: "0.0.41", commit: "abc123", dirty: false }, + now: Date.UTC(2026, 7, 4, 12), + }); + + assert.equal(report.readOnly, true); + assert.deepEqual(report.environments.map(({ health }) => health), ["healthy", "healthy", "not_configured", "not_configured"]); + assert.equal(report.environments[1].artifact, null); + assert.equal(report.environments[2].version, null); + assert.equal(report.environments[2].lxcState, null); + + const text = formatEnvironmentStatus(report); + assert.match(text, /Stable artifact: unknown/); + assert.match(text, /NOT CONFIGURED/); + assert.match(text, /Nothing was changed/); +}); + +test("a responding fixture remains uncertain when its LXC identity cannot be read", async () => { + let command; + const fetchImpl = async (url) => { + if (url.includes("fixture.example")) return response(200, '{"product":"1Helm","version":"0.0.38"}'); + if (url.endsWith("/health")) return response(200, '{"ok":true,"product":"1Helm","surface":"website","version":"0.0.41"}'); + if (url.endsWith("/api/releases/linux/latest")) return response(503, ""); + if (url.includes("/api/mobile/compatibility")) return response(200, '{"product":"1Helm","version":"0.0.41"}'); + return response(200, "ok"); + }; + const report = await collectEnvironmentStatus({ + ...DEFAULT_STATUS_CONFIG, + fixtureUrl: "http://fixture.example:8123", + fixtureHost: "fixture-host", + fixtureId: "112", + }, { + fetchImpl, + runCommand: async (file, args) => { + command = { file, args }; + return { ok: false, stdout: "", timedOut: false }; + }, + sourceIdentity: { version: "0.0.41", commit: "abc123", dirty: true }, + }); + const fixture = report.environments.find(({ id }) => id === "fixture"); + assert.equal(fixture.health, "uncertain"); + assert.equal(fixture.version, "0.0.38"); + assert.equal(command.file, "ssh"); + assert.deepEqual(command.args.slice(-5), ["ConnectTimeout=3", "fixture-host", "pct", "status", "112"]); + assert.ok(command.args.includes("ClearAllForwardings=yes")); + assert.ok(command.args.includes("PermitLocalCommand=no")); + assert.ok(command.args.includes("UpdateHostKeys=no")); +}); + +test("repository Git identity commands run against the requested root", async () => { + const calls = []; + await repositoryIdentity(new URL("..", import.meta.url).pathname, async (file, args, timeout, options) => { + calls.push({ file, args, timeout, options }); + return { ok: true, stdout: args[0] === "status" ? " M package.json\n" : "abc123\n", timedOut: false }; + }); + assert.equal(calls.length, 2); + assert.ok(calls.every((call) => call.file === "git" && call.options?.cwd)); + assert.ok(calls.every((call) => call.options.cwd === new URL("..", import.meta.url).pathname)); +}); + +test("website probing uses the structured health identity instead of scraping HTML", async () => { + const urls = []; + const config = statusConfig({ HELM_STATUS_SITE_URL: "https://site.example" }); + const report = await collectEnvironmentStatus(config, { + fetchImpl: async (url) => { + urls.push(url); + if (url === "https://site.example/health") return response(200, '{"ok":true,"product":"1Helm","surface":"website","version":"0.0.41"}'); + if (url === "https://site.example/api/releases/linux/latest") return response(503, ""); + return response(200, '{"product":"1Helm","version":"0.0.41"}'); + }, + sourceIdentity: { version: "0.0.41", commit: "abc123", dirty: false }, + }); + const site = report.environments.find(({ id }) => id === "website"); + assert.equal(site.health, "healthy"); + assert.equal(site.version, "0.0.41"); + const requested = urls.map((value) => new URL(value)); + assert.ok(requested.some((url) => url.origin === "https://site.example" && url.pathname === "/health" && url.search === "" && url.hash === "")); + assert.ok(!requested.some((url) => url.origin === "https://site.example" && url.pathname === "/" && url.search === "" && url.hash === "")); +}); diff --git a/test/desktop.mjs b/test/desktop.mjs index 0a0836b..6329163 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -159,6 +159,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(helperInstall, /chmodSync\(helper, 0o755\)/, "Mac installs restore node-pty's executable spawn helper before terminals open"); const memoryRuntime = await readFile(join(root, "src", "server", "memory.ts"), "utf8"); const testRunner = await readFile(join(root, "scripts", "run-test-suite.mjs"), "utf8"); + const mnemosyneTestRuntime = await readFile(join(root, "scripts", "mnemosyne-test-runtime.mjs"), "utf8"); const feedbackBrowser = await readFile(join(root, "test", "feedback-browser.mjs"), "utf8"); const terminalBrowser = await readFile(join(root, "test", "terminal-reconnect-browser.mjs"), "utf8"); assert.match(memoryRuntime, /assert mnemosyne\.__version__/); @@ -168,8 +169,10 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(memoryRuntime, /process\.platform === "darwin" \? \["\/usr\/bin\/python3"\]/, "macOS retries its bundled Python when a preferred interpreter cannot create the app-managed memory runtime"); assert.match(memoryRuntime, /export function prepareMnemosyneRuntime\(\): Promise/, "fresh-host memory installation is asynchronous instead of blocking application startup"); assert.match(memoryRuntime, /export function cancelMnemosyneRuntimePreparation\(\)/, "host shutdown cancels an in-flight app-managed memory installation"); - assert.match(testRunner, /MNEMOSYNE_PYTHON: runtime/, "the full test suite shares one explicit pinned memory runtime instead of racing app-start installers"); - assert.match(testRunner, /if \(existsSync\(venv\)\) rmSync\(venv, \{ recursive: true, force: true \}\);[\s\S]*spawnSync\(installer/, "each test-runtime fallback starts clean after a preferred Python leaves a partial venv"); + assert.match(testRunner, /MNEMOSYNE_PYTHON: prepared\.runtime/, "the full test suite shares one explicit pinned memory runtime instead of racing app-start installers"); + assert.match(mnemosyneTestRuntime, /\.test-state[\s\S]*mnemosyne[\s\S]*MNEMOSYNE_VERSION/, "local full suites cache only the pinned memory runtime under ignored generated test state"); + assert.match(mnemosyneTestRuntime, /pinned\(paths\.cachePython[\s\S]*rename\(paths\.environmentRoot, quarantined\)/, "a mismatched test cache is validated and preserved before a clean replacement is prepared"); + assert.match(mnemosyneTestRuntime, /env\.CI \? "disposable" : "cache"/, "CI keeps an explicit disposable memory-runtime mode"); assert.match(feedbackBrowser, /skip: executablePath \? false :/, "the Feedback browser contract does not hang a Chrome-free release runner"); assert.match(await readFile(join(root, "src", "client", "app.ts"), "utf8"), /mailto:build@1helm\.com/, "the in-app Feedback surface exposes the company contact address"); assert.match(terminalBrowser, /HELM_CHANNEL_COMPUTER_BACKEND: "native"/, "the terminal browser contract uses the explicit development backend on CI hosts without an installed OCI runtime"); diff --git a/test/phase1-tools.mjs b/test/phase1-tools.mjs new file mode 100644 index 0000000..074764f --- /dev/null +++ b/test/phase1-tools.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:net"; +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { selectFastTests, DEFAULT_FAST_TESTS } from "../scripts/fast-test-lib.mjs"; +import { MNEMOSYNE_VERSION, mnemosyneTestPaths, selectMnemosyneRuntime } from "../scripts/mnemosyne-test-runtime.mjs"; +import { assertPortAvailable, assertSafePreviewData, previewConfig, ServerRestarter } from "../scripts/preview-lib.mjs"; + +const root = new URL("..", import.meta.url).pathname; + +test("preview defaults to a loopback non-Stable port and generated data", () => { + const config = previewConfig(root, [], {}); + assert.equal(config.port, 8124); + assert.equal(config.url, "http://127.0.0.1:8124"); + assert.equal(config.dataDir, join(root, ".preview-data")); + assert.throws(() => previewConfig(root, ["--port", "8123"], {}), /refuses Stable port/); + assert.throws(() => previewConfig(root, ["--port", "9000"], { HELM_STABLE_PORT: "9000" }), /refuses Stable port/); + for (const unsafe of ["data", "data-refactored", "/var/lib/1helm-oci-v1", "../other-data"]) { + assert.throws(() => previewConfig(root, ["--data-dir", unsafe], {}), /must stay inside.*preview-data/); + } +}); + +test("preview refuses symlinks that could redirect generated data", async (t) => { + const project = await mkdtemp(join(tmpdir(), "1helm-preview-safety-")); + const outside = await mkdtemp(join(tmpdir(), "1helm-preview-outside-")); + t.after(async () => { await rm(project, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); }); + await symlink(outside, join(project, ".preview-data")); + const config = previewConfig(project, [], {}); + await assert.rejects(assertSafePreviewData(config), /symbolic link/); +}); + +test("preview gives an actionable error when its chosen port is occupied", async (t) => { + const server = createServer(); + await new Promise((resolveListen, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolveListen); }); + t.after(() => server.close()); + const address = server.address(); + await assert.rejects(assertPortAvailable(address.port), /already occupied[\s\S]*--port/); +}); + +test("server restarts are debounced and all active children are stopped", async () => { + const started = []; + const stopped = []; + let scheduled; + const restarter = new ServerRestarter({ + start: async () => { const child = { id: started.length + 1 }; started.push(child); return child; }, + stop: async (child) => { stopped.push(child); }, + setTimer: (callback) => { scheduled = callback; return callback; }, + clearTimer: (timer) => { if (scheduled === timer) scheduled = undefined; }, + }); + await restarter.start(); + restarter.changed(); + const firstTimer = scheduled; + restarter.changed(); + assert.notEqual(scheduled, firstTimer); + scheduled(); + await restarter.queue; + assert.deepEqual(started.map(({ id }) => id), [1, 2]); + assert.deepEqual(stopped.map(({ id }) => id), [1]); + await restarter.close(); + assert.deepEqual(stopped.map(({ id }) => id), [1, 2]); +}); + +test("fast tests accept focused files and otherwise choose a small default", () => { + assert.deepEqual(selectFastTests(root, [], () => true), DEFAULT_FAST_TESTS); + assert.deepEqual(selectFastTests(root, ["test/phase1-tools.mjs"], () => true), ["test/phase1-tools.mjs"]); + assert.throws(() => selectFastTests(root, ["src/server/index.ts"], () => true), /inside test/); + assert.throws(() => selectFastTests(root, ["test/missing.mjs"], () => false), /does not exist/); +}); + +test("Mnemosyne cache decisions validate every reusable candidate without installing", () => { + const reusable = selectMnemosyneRuntime({ + explicitPython: "/explicit/python", cachePython: "/cache/python", mode: "cache", + validity: { explicit: false, cache: true }, + }); + assert.deepEqual(reusable, { action: "reuse", runtime: "/cache/python", source: "generated test cache" }); + assert.equal(selectMnemosyneRuntime({ + explicitPython: "", cachePython: "/bad-cache", mode: "cache", + validity: { explicit: false, cache: false }, + }).action, "prepare-cache"); + assert.equal(selectMnemosyneRuntime({ + explicitPython: "", cachePython: "/cache", mode: "disposable", + validity: { explicit: false, cache: true }, + }).action, "prepare-disposable"); + const paths = mnemosyneTestPaths(root); + assert.match(paths.environmentRoot, new RegExp(`\\.test-state[/\\\\]mnemosyne[/\\\\]${MNEMOSYNE_VERSION.replaceAll(".", "\\.")}$`)); +}); + +test("package commands and docs expose the private preview and fast inner loop", async () => { + const packageJson = (await import("../package.json", { with: { type: "json" } })).default; + assert.equal(packageJson.scripts.preview, "node scripts/preview.mjs"); + assert.equal(packageJson.scripts["test:fast"], "node scripts/run-fast-tests.mjs"); +}); diff --git a/test/phase2-candidate.mjs b/test/phase2-candidate.mjs new file mode 100644 index 0000000..6672d13 --- /dev/null +++ b/test/phase2-candidate.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { candidateIdentityFromArchive, createCandidateManifest } from "../scripts/candidate-manifest.mjs"; + +const root = join(import.meta.dirname, ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); +const sha = (value) => createHash("sha256").update(value).digest("hex"); + +function fixture() { + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase2-")); + const prefix = join(scratch, "source", "1Helm-0.0.41"); + const oci = Buffer.from("sealed OCI fixture"); + const identity = { + schema: 1, + kind: "1helm-dress-rehearsal-candidate", + repository: "gitcommit90/1Helm", + ref: "refs/heads/main", + commit: "a".repeat(40), + source_state: "trusted-main", + build_identity: "candidate-123-456.1", + created_at: "2026-08-04T12:00:00Z", + ci: { workflow: "CI", run_id: "123", conclusion: "success" }, + version: "0.0.41", + source_archive_sha256: "b".repeat(64), + sealed_oci_sha256: sha(oci), + }; + mkdirSync(join(prefix, "resources"), { recursive: true }); + mkdirSync(join(prefix, "container"), { recursive: true }); + writeFileSync(join(prefix, "resources", "candidate-build.json"), JSON.stringify(identity)); + writeFileSync(join(prefix, "package.json"), JSON.stringify({ version: identity.version })); + writeFileSync(join(prefix, "container", "channel-machine.oci.tar"), oci); + const archive = join(scratch, "1Helm-0.0.41-linux-node.tgz"); + execFileSync("tar", ["-czf", archive, "-C", join(scratch, "source"), "1Helm-0.0.41"]); + return { scratch, archive, identity }; +} + +test("candidate manifest binds the outer digest to the embedded trusted-main identity", () => { + const item = fixture(); + try { + assert.deepEqual(candidateIdentityFromArchive(item.archive), item.identity); + const output = join(item.scratch, "candidate.json"); + const manifest = createCandidateManifest({ archivePath: item.archive, outputPath: output }); + assert.equal(manifest.source.commit, item.identity.commit); + assert.equal(manifest.source.ref, "refs/heads/main"); + assert.equal(manifest.ci.conclusion, "success"); + assert.equal(manifest.sealed_oci.sha256, item.identity.sealed_oci_sha256); + assert.equal(manifest.artifact.sha256, sha(readFileSync(item.archive))); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("root boundary rejects digest, source, and sealed OCI mismatches", () => { + const item = fixture(); + try { + const manifestPath = join(item.scratch, "candidate.json"); + createCandidateManifest({ archivePath: item.archive, outputPath: manifestPath }); + const validator = join(root, "ops", "dress-rehearsal", "candidate-boundary.py"); + const output = join(item.scratch, "verified.json"); + execFileSync("python3", [validator, "validate", manifestPath, item.archive, output]); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.source.commit = "c".repeat(40); + writeFileSync(manifestPath, JSON.stringify(manifest)); + let failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, output], { encoding: "utf8" }); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /embedded candidate commit mismatch/); + manifest.source.commit = item.identity.commit; + manifest.artifact.sha256 = "d".repeat(64); + writeFileSync(manifestPath, JSON.stringify(manifest)); + failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, output], { encoding: "utf8" }); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /archive SHA-256 mismatch/); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("rollback fixtures remain local-only and cannot satisfy normal candidate validation", () => { + const item = fixture(); + try { + const source = join(item.scratch, "source", "1Helm-0.0.41", "resources", "candidate-build.json"); + const identity = JSON.parse(readFileSync(source, "utf8")); + identity.source_state = "rollback-fixture"; + identity.ci = { workflow: "local", run_id: "0", conclusion: "not_run" }; + writeFileSync(source, JSON.stringify(identity)); + execFileSync("tar", ["-czf", item.archive, "-C", join(item.scratch, "source"), "1Helm-0.0.41"]); + assert.throws(() => candidateIdentityFromArchive(item.archive), /not trusted main/); + assert.equal(candidateIdentityFromArchive(item.archive, { allowLocal: true }).source_state, "rollback-fixture"); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("candidate workflow and guest boundary exclude PR code and broad root access", () => { + const workflow = read(".github/workflows/candidate.yml"); + const helper = read("ops/dress-rehearsal/1helm-candidate-install"); + const hook = read("ops/dress-rehearsal/runner-job-started"); + const sudoersExample = "%actions ALL=(root) NOPASSWD: /usr/local/sbin/1helm-candidate-install \"\"\n"; + assert.match(workflow, /workflow_run:[\s\S]*workflows: \[CI\][\s\S]*branches: \[main\]/); + assert.match(workflow, /workflow_run\.event == 'push'/); + assert.match(workflow, /head_repository\.full_name == github\.repository/); + assert.match(workflow, /runs-on: \[1helm-dress-rehearsal-phase2\]/); + assert.match(workflow, /github\.sha == github\.event\.workflow_run\.head_sha/); + assert.match(workflow, /attest-build-provenance@[a-f0-9]{40}/); + assert.match(workflow, /candidate-download\/candidate-evidence\/candidate\.json/); + assert.match(workflow, /candidate-download\/candidate-evidence\/provenance\.bundle\.json/); + assert.match(helper, /--signer-workflow gitcommit90\/1Helm\/\.github\/workflows\/candidate\.yml/); + assert.match(helper, /--source-ref refs\/heads\/main/); + assert.match(helper, /--source-digest "\$commit"/); + assert.match(helper, /--deny-self-hosted-runners/); + assert.match(helper, /local-proof-authorized/); + assert.doesNotMatch(helper, /--local-proof/); + assert.match(helper, /awk -F\/.*!found.*found=1/, "large archive inspection consumes tar output instead of causing SIGPIPE under pipefail"); + assert.match(helper, /actions\\\.runner[\s\S]*systemd-run[\s\S]*\/usr\/local\/sbin\/1helm-candidate-install/); + assert.match(helper, /unlink "\$INBOX\/candidate\.json" "\$INBOX\/candidate\.tgz"/); + assert.match(read("ops/dress-rehearsal/runner.service.override.conf"), /ProtectSystem=strict[\s\S]*ReadWritePaths=.*candidate\/inbox/); + assert.match(hook, /GITHUB_EVENT_NAME.*workflow_run/); + assert.match(hook, /run\.get\("event"\) == "push"/); + assert.doesNotMatch(sudoersExample, /NOPASSWD:\s*ALL/); + assert.doesNotMatch(workflow, /pull_request:/); +}); + +test("candidate status evidence names running, previous, CI, install, and rollback fields", () => { + const boundary = read("ops/dress-rehearsal/candidate-boundary.py"); + for (const field of ["running_candidate", "last_attempt", "previous_candidate", "install", "rollback", "last_rollback", "checked_at"]) { + assert.match(boundary, new RegExp(`["]${field}["]`)); + } + assert.match(read("docs/dress-rehearsal.md"), /Teardown and rollback/); + assert.match(read("scripts/delivery-status-lib.mjs"), /Private dress-rehearsal candidate/); +}); + +test("a healthy reinstall preserves the latest proven rollback evidence", () => { + const boundary = read("ops/dress-rehearsal/candidate-boundary.py"); + assert.match(boundary, /previous\.get\("last_rollback"\) or previous\.get\("rollback"\)/); + assert.match(boundary, /previous_rollback\.get\("result"\) == "healthy"/); +});