diff --git a/Makefile b/Makefile index 9091d4d..7390aea 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ LDFLAGS := -ldflags "\ -X main.date=$(DATE) \ " -.PHONY: build test race vet fmt-check tidy-check staticcheck actionlint lint vulnerability verify clean release hardware-test vm-e2e vm-e2e-matrix vm-e2e-purge vm-lab-cleanup +.PHONY: build test race vet fmt-check tidy-check staticcheck actionlint lint vulnerability verify clean release hardware-test macos-lab-test macos-e2e vm-e2e vm-e2e-matrix vm-e2e-purge vm-lab-cleanup build: go build $(LDFLAGS) -o $(BINARY) . @@ -67,6 +67,12 @@ release: hardware-test: ./tests/hardware/run.sh +macos-lab-test: + ./tests/hardware/run-macos-lab.sh + +macos-e2e: + ./tests/e2e/run-macos-lab.sh + VM ?= appimage vm-e2e: diff --git a/tests/e2e/README.md b/tests/e2e/README.md index b39716d..b64ab1c 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -53,6 +53,14 @@ passes the original command and arguments to `/usr/bin/sudo`. The `pkexec` file is restored by the evidence trap even on failure. A real desktop session remains the appropriate test for the graphical PolicyKit dialog itself. +The physical Apple Silicon lane runs the same release-shaped portable contract, +attended PTY dashboard/management journey, and unattended lifecycle on native +Darwin ARM64. It begins from the lab's already-ready `omnideck-runtime` Podman +machine, so it deliberately fails if the CLI enters Podman installation or +machine-creation screens. The ARM64 CLI and fixture image are built on the +Linux controller and copied to the Mac; only execution and native Podman work +consume the resource-constrained host. + ## Run before a release The external lab path stays machine-local: @@ -61,6 +69,7 @@ The external lab path stays machine-local: export OMNIDECK_VM_LAB_DIR=/absolute/path/to/omnideck-release-lab make vm-e2e make vm-e2e-matrix YES=1 +make macos-e2e ``` The default lane is the Ubuntu `appimage` guest. The command requires that guest @@ -74,6 +83,12 @@ make vm-e2e VM=rpm make vm-e2e VM=windows ``` +`make macos-e2e` leases `macos-arm64`, selects unused `omnideckN` and loopback +ports at runtime, and records evidence under `artifacts/cli/macos-e2e/`. Its +`runtime-ready` reset removes only resources named by the lab ownership marker; +the user's normal CLI, config, container, volumes, and warm Podman machine are +preserved. + `vm-e2e-matrix` is the canonical complete command. Override its deterministic order with `LANES=appimage,windows`. Every lane runs the `release-clean` preflight. The pinned dev-container image builds reproducible archives into a @@ -132,15 +147,18 @@ make vm-lab-cleanup ALL=1 APPLY=1 ## Scope -The automated lanes cover mutable x64 Linux guests and the local Windows 11 x64 -guest. They do not claim stable-to-candidate production-image upgrades, -backup/restore, macOS installation prompts, subjective visual quality, or the -Windows **restart now** auto-reopen path. Run the checked-in manual procedures -for those release-candidate behaviors. +The automated lanes cover mutable x64 Linux guests, the local Windows 11 x64 +guest, and native CLI/TUI execution on the physical Apple Silicon host. They do +not claim stable-to-candidate production-image upgrades, backup/restore, macOS +Podman installation prompts, subjective visual quality, or the Windows +**restart now** auto-reopen path. Run the checked-in manual procedures for +those release-candidate behaviors. The terminal driver's dependency-free parser has a fast host check: ```sh python3 -m unittest tests/e2e/test_terminal_driver.py -bash -n tests/e2e/run.sh tests/e2e/run-windows.sh tests/e2e/guest.sh +python3 -m unittest tests/e2e/test_lab_contract.py +bash -n tests/e2e/run.sh tests/e2e/run-windows.sh tests/e2e/guest.sh \ + tests/e2e/run-macos-lab.sh tests/e2e/macos_guest.sh ``` diff --git a/tests/e2e/macos_guest.sh b/tests/e2e/macos_guest.sh new file mode 100755 index 0000000..05cbcad --- /dev/null +++ b/tests/e2e/macos_guest.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +export LANG=C +export LC_ALL=C + +work_dir="${1:?guest work directory is required}" +expected_version="${2:?expected version is required}" +fixture_image="${3:?fixture image is required}" +instance='' +test_instance_selected=0 +web_port=2337 +additional_name=omnideck2 +additional_port=2338 +registry_port=46864 +registry_container="omnideck-hw-macos-e2e-registry" +ownership_marker="$HOME/.omnideck-lab/state/cli-e2e-instance" +published_image="localhost:${registry_port}/${fixture_image#localhost/}" +result_dir="$work_dir/results" +archive="$work_dir/omnideck-darwin-arm64.tar.gz" +checksum_file="$work_dir/SHA256SUMS" +binary="$work_dir/bin/omnideck" +config_dir="$work_dir/config" +registries_conf="$work_dir/registries.conf" +started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +current_step=initialization +test_status=failed + +mkdir -p "$result_dir" "$work_dir/extracted" "$work_dir/bin" "$config_dir" +exec > >(tee -a "$result_dir/guest.log") 2>&1 + +inventory() { + local suffix="$1" + { + printf 'timestamp=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'os=%s\narchitecture=%s\n' "$(sw_vers -productVersion)" "$(uname -m)" + podman --version + podman machine list --format json + podman ps --all --format 'container={{.Names}}|{{.Status}}' | sort + podman volume ls --format 'volume={{.Name}}' | sort + } > "$result_dir/inventory-$suffix.txt" +} + +cleanup_resources() { + if [[ "$test_instance_selected" == 1 && "$instance" =~ ^omnideck[0-9]*$ ]]; then + podman rm -f "$instance" >/dev/null 2>&1 || true + podman volume rm -f "$instance-home" "$instance-state" >/dev/null 2>&1 || true + fi + rm -f -- "$ownership_marker" + podman rm -f "$registry_container" >/dev/null 2>&1 || true + podman rmi -f "$published_image" "$fixture_image" >/dev/null 2>&1 || true + rm -rf -- "$config_dir" +} + +write_evidence() { + local exit_code=$? + set +e + [[ ! -d "$config_dir" ]] || tar -czf "$result_dir/config-on-exit.tar.gz" -C "$config_dir" . + cleanup_resources + inventory after + printf '{\n "status": "%s",\n "lastStep": "%s",\n "expectedVersion": "%s",\n "fixtureImage": "%s",\n "platform": "darwin",\n "architecture": "arm64",\n "podmanSetup": "excluded-ready-runtime",\n "startedAt": "%s",\n "finishedAt": "%s"\n}\n' \ + "$test_status" "$current_step" "$expected_version" "$fixture_image" \ + "$started_at" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$result_dir/summary.json" + if [[ "$test_status" == passed ]]; then + printf '%s\n' '' \ + '' \ + > "$result_dir/junit.xml" + else + printf '%s\n' '' \ + "" \ + > "$result_dir/junit.xml" + fi + exit "$exit_code" +} +trap write_evidence EXIT +trap 'printf "ERROR step=%s line=%s command=%q\\n" "$current_step" "$LINENO" "$BASH_COMMAND" >&2' ERR + +current_step='ready-runtime precondition' +[[ "$(uname -s)/$(uname -m)" == Darwin/arm64 ]] +podman info >/dev/null +inventory before +for suffix in '' $(seq 2 9999); do + candidate="omnideck${suffix}" + if ! podman container inspect "$candidate" >/dev/null 2>&1; then instance="$candidate"; break; fi +done +[[ -n "$instance" ]] +test_instance_selected=1 +mkdir -p "$(dirname "$ownership_marker")" +printf '%s\n' "$instance" > "$ownership_marker" +for suffix in '' $(seq 2 9999); do + candidate="omnideck${suffix}" + [[ "$candidate" == "$instance" ]] && continue + if ! podman container inspect "$candidate" >/dev/null 2>&1; then additional_name="$candidate"; break; fi +done +web_port="$(python3 - <<'PY' +import socket +for port in range(2337, 65536): + sock=socket.socket() + try: sock.bind(('127.0.0.1', port)) + except OSError: continue + finally: sock.close() + print(port); break +PY +)" +additional_port="$(python3 - "$web_port" <<'PY' +import socket, sys +for port in range(int(sys.argv[1]) + 1, 65536): + sock=socket.socket() + try: sock.bind(('127.0.0.1', port)) + except OSError: continue + finally: sock.close() + print(port); break +PY +)" +[[ "$instance" =~ ^omnideck[0-9]*$ && "$additional_name" =~ ^omnideck[0-9]*$ ]] +[[ "$web_port" =~ ^[0-9]+$ && "$additional_port" =~ ^[0-9]+$ ]] +printf 'selectedInstance=%s\nselectedPort=%s\nadditionalName=%s\nadditionalPort=%s\n' \ + "$instance" "$web_port" "$additional_name" "$additional_port" > "$result_dir/isolation.txt" +[[ ! -e "$config_dir/instances/$instance.yaml" ]] + +current_step='install release archive' +(cd "$work_dir" && shasum -a 256 --check "$(basename "$checksum_file")") +tar -xzf "$archive" -C "$work_dir/extracted" +install -m 0755 "$work_dir/extracted/omnideck" "$binary" +"$binary" --version | tee "$result_dir/version.txt" +grep -Fq "omnideck version $expected_version" "$result_dir/version.txt" +"$binary" install --help > "$result_dir/install-help.txt" +grep -Fq 'Walks through setting up one Omnideck instance.' "$result_dir/install-help.txt" +grep -Fq 'add, install, setup' "$result_dir/install-help.txt" + +current_step='portable CLI contract' +tar -xzf "$work_dir/contracts.tar.gz" -C "$work_dir" +"$work_dir/releasecontract" \ + --binary "$binary" --mode portable --expected-version "$expected_version" \ + --expected-os darwin --expected-arch arm64 --contracts "$work_dir/contracts" \ + --report "$result_dir/portable-contract.json" --junit "$result_dir/portable-contract.xml" + +current_step='load ARM64 fixture image' +podman load --input "$work_dir/fixture.tar" | tee "$result_dir/fixture-load.txt" +printf '[[registry]]\nlocation = "localhost:%s"\ninsecure = true\n' "$registry_port" > "$registries_conf" +podman run -d --name "$registry_container" -p "127.0.0.1:${registry_port}:5000" \ + docker.io/library/registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 +for _ in $(seq 1 30); do + curl --fail --silent --max-time 2 "http://127.0.0.1:${registry_port}/v2/" >/dev/null && break + sleep 1 +done +curl --fail --silent --show-error --max-time 2 "http://127.0.0.1:${registry_port}/v2/" >/dev/null +podman tag "$fixture_image" "$published_image" +podman push --tls-verify=false "$published_image" + +current_step='guided install journey with Podman setup excluded' +python3 "$work_dir/terminal_driver.py" macos-install \ + --binary "$binary" --config-dir "$config_dir" --registries-conf "$registries_conf" \ + --fixture-image "$published_image" --artifact-dir "$result_dir" \ + --instance-name "$instance" --web-port "$web_port" \ + --additional-name "$additional_name" --additional-port "$additional_port" + +current_step='installed behavior' +env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \ + "$binary" --no-color --name "$instance" status | tee "$result_dir/status.txt" +grep -Fq running "$result_dir/status.txt" +curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:$web_port" > "$result_dir/web-ui.html" +grep -Fq 'omnideck hardware fixture ready' "$result_dir/web-ui.html" +podman container inspect "$instance" > "$result_dir/container-inspect.json" +podman volume inspect "$instance-home" "$instance-state" > "$result_dir/volume-inspect.json" +grep -Eq '^runtime:[[:space:]]+podman$' "$config_dir/settings.yaml" +grep -Eq "^container_name:[[:space:]]+$instance$" "$config_dir/instances/$instance.yaml" +grep -Fq "image: $published_image" "$config_dir/instances/$instance.yaml" + +current_step='unattended command and update contract' +env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \ + "$binary" --json list > "$result_dir/list.json" +env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \ + "$binary" --json --name "$instance" status > "$result_dir/status.json" +python3 - "$result_dir/list.json" "$result_dir/status.json" "$instance" <<'PY' +import json, sys +instances=json.load(open(sys.argv[1], encoding='utf-8')) +status=json.load(open(sys.argv[2], encoding='utf-8')) +assert len(instances) == 1 and instances[0]['name'] == sys.argv[3], instances +assert status['container'] == sys.argv[3] and status['status'] == 'running', status +PY +podman exec "$instance" sh -c 'printf "%s\n" update-volume-marker > /home/omnideck/update-volume-marker' +env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \ + "$binary" --no-color --name "$instance" config set memory 512m | tee "$result_dir/config-set.txt" +grep -Fq 'Set memory = 512m' "$result_dir/config-set.txt" +env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \ + "$binary" --no-color --name "$instance" update --plain | tee "$result_dir/update-plain.txt" +grep -Fq "Omnideck is up to date: http://localhost:$web_port" "$result_dir/update-plain.txt" +[[ "$(podman exec "$instance" cat /home/omnideck/update-volume-marker)" == update-volume-marker ]] + +current_step='TUI management journey' +python3 "$work_dir/terminal_driver.py" manage \ + --binary "$binary" --config-dir "$config_dir" --registries-conf "$registries_conf" \ + --fixture-image "$published_image" --artifact-dir "$result_dir" \ + --instance-name "$instance" --web-port "$web_port" \ + --additional-name "$additional_name" --additional-port "$additional_port" + +current_step='removal cleanup contract' +! podman container inspect "$instance" >/dev/null 2>&1 +! podman volume inspect "$instance-home" >/dev/null 2>&1 +! podman volume inspect "$instance-state" >/dev/null 2>&1 +[[ ! -e "$config_dir/instances/$instance.yaml" ]] + +current_step='unattended CLI lifecycle' +podman rm -f "$registry_container" >/dev/null +chmod +x "$work_dir/hardware-run.sh" +OMNIDECK_HARDWARE_CLI="$binary" \ +OMNIDECK_HARDWARE_ENGINE=podman \ +OMNIDECK_HARDWARE_REGISTRY_PORT=46864 \ +OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE="$work_dir/fixture.tar" \ +OMNIDECK_HARDWARE_ARCHIVE_IMAGE="$fixture_image" \ +OMNIDECK_HARDWARE_OUTPUT_DIR="$result_dir/unattended" \ + "$work_dir/hardware-run.sh" +python3 - "$result_dir/unattended/summary.json" <<'PY' +import json, sys +result=json.load(open(sys.argv[1], encoding='utf-8')) +assert result['status'] == 'passed', result +PY + +current_step=complete +test_status=passed +printf 'PASS: macOS portable, ready-runtime attended TUI, management, and unattended CLI journeys completed.\n' diff --git a/tests/e2e/run-macos-lab.sh b/tests/e2e/run-macos-lab.sh new file mode 100755 index 0000000..e6bc672 --- /dev/null +++ b/tests/e2e/run-macos-lab.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +lab_dir="${OMNIDECK_VM_LAB_DIR:-}" +target=macos-arm64 +profile="${OMNIDECK_VM_LAB_PROFILE:-release-clean}" +builder_image="${OMNIDECK_CLI_BUILDER_IMAGE:-omnideck-cli-builder:local}" + +[[ -n "$lab_dir" && -x "$lab_dir/lab.sh" ]] || { + printf 'Set OMNIDECK_VM_LAB_DIR to the deployed OmniDeck VM lab.\n' >&2 + exit 2 +} + +if [[ "${OMNIDECK_VM_LAB_LEASED:-}" != 1 ]]; then + "$lab_dir/lab.sh" preflight cli "$profile" --lanes "$target" >/dev/null + command -v docker >/dev/null 2>&1 || { printf 'Docker is required to build macOS ARM64 artifacts off-host.\n' >&2; exit 2; } + docker image inspect "$builder_image" >/dev/null 2>&1 || { + printf 'Builder image %q is unavailable. Build .devcontainer/Dockerfile first.\n' "$builder_image" >&2 + exit 2 + } + + fixture_key="$(shasum -a 256 "$repo_root/tests/hardware/fixture/Containerfile" "$repo_root/tests/hardware/fixture/index.html" | shasum -a 256 | awk '{print substr($1,1,20)}')" + fixture_cache="$($lab_dir/lab.sh cache-path cli "macos-arm64-fixture-${fixture_key}")" + fixture_archive="$fixture_cache/fixture.tar" + fixture_image="localhost/omnideck-hardware-fixture:macos-arm64-${fixture_key}" + if [[ ! -f "$fixture_archive" ]]; then + mkdir -p "$fixture_cache" + temporary_archive="$fixture_cache/.fixture.tar.$$" + docker buildx build --platform linux/arm64 \ + --file "$repo_root/tests/hardware/fixture/Containerfile" --tag "$fixture_image" \ + --output "type=docker,dest=${temporary_archive}" "$repo_root/tests/hardware/fixture" + mv -- "$temporary_archive" "$fixture_archive" + shasum -a 256 "$fixture_archive" > "$fixture_cache/SHA256SUMS" + fi + + run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" + source_commit="$(git -C "$repo_root" rev-parse HEAD)" + source_dirty=false + [[ -z "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=normal)" ]] || source_dirty=true + output_dir="$($lab_dir/lab.sh artifact-path cli macos-e2e "$run_id")" + build_dir="$output_dir/build" + builder_cache="$($lab_dir/lab.sh cache-path cli macos-go-builder-v1)" + mkdir -p "$build_dir/archive" + mkdir -p "$builder_cache/gocache" "$builder_cache/gopath" + short_commit="${source_commit:0:12}" + expected_version="vm-e2e-${short_commit}" + ldflags="-X main.version=${expected_version} -X main.commit=${short_commit} -X main.date=vm-e2e" + + docker run --rm --entrypoint /bin/zsh \ + --user "$(id -u):$(id -g)" \ + --env GOOS=darwin --env GOARCH=arm64 --env CGO_ENABLED=0 \ + --env GOCACHE=/tmp/omnideck-go-build --env GOPATH=/tmp/omnideck-go \ + --volume "$repo_root:/workspace:ro" --volume "$build_dir:/out" \ + --volume "$builder_cache/gocache:/tmp/omnideck-go-build" \ + --volume "$builder_cache/gopath:/tmp/omnideck-go" \ + --workdir /workspace "$builder_image" \ + -c "go build -trimpath -buildvcs=false -ldflags \"${ldflags}\" -o /out/archive/omnideck . && go build -trimpath -buildvcs=false -o /out/releasecontract ./tests/releasecontract" + chmod +x "$build_dir/archive/omnideck" "$build_dir/releasecontract" + file "$build_dir/archive/omnideck" "$build_dir/releasecontract" | grep -Eq 'Mach-O 64-bit.*arm64' + tar -czf "$build_dir/omnideck-darwin-arm64.tar.gz" -C "$build_dir/archive" omnideck + (cd "$build_dir" && shasum -a 256 omnideck-darwin-arm64.tar.gz > SHA256SUMS) + tar --sort=name --mtime="@$(git -C "$repo_root" show -s --format=%ct HEAD)" \ + --owner=0 --group=0 --numeric-owner -cf - -C "$repo_root" contracts | gzip -n > "$build_dir/contracts.tar.gz" + + "$lab_dir/lab.sh" evidence-init "$output_dir" cli macos-e2e "$run_id" \ + "$source_commit" "$target" runtime-ready "sourceDirty=$source_dirty" \ + "architecture=arm64" "podmanSetup=excluded-ready-runtime" "driver=pty" + trap '"$lab_dir/lab.sh" evidence-finish "$output_dir" failed >/dev/null 2>&1 || true' EXIT + status=0 + "$lab_dir/lab.sh" lease "$target" cli-e2e "$run_id" --cleanup-baseline runtime-ready -- env \ + OMNIDECK_MACOS_CLI_BUILD_DIR="$build_dir" \ + OMNIDECK_MACOS_FIXTURE_ARCHIVE="$fixture_archive" \ + OMNIDECK_MACOS_FIXTURE_IMAGE="$fixture_image" \ + OMNIDECK_MACOS_EXPECTED_VERSION="$expected_version" \ + OMNIDECK_VM_LAB_OUTPUT_DIR="$output_dir" "$0" || status=$? + trap - EXIT + [[ "$status" == 0 ]] && evidence_status=passed || evidence_status=failed + "$lab_dir/lab.sh" evidence-finish "$output_dir" "$evidence_status" + printf 'Evidence: %s\n' "$output_dir" + exit "$status" +fi + +[[ "${OMNIDECK_VM_LAB_VM:-}" == "$target" ]] || { printf 'The active lease does not own %s.\n' "$target" >&2; exit 2; } +build_dir="${OMNIDECK_MACOS_CLI_BUILD_DIR:?Prepared macOS CLI build is required}" +fixture_archive="${OMNIDECK_MACOS_FIXTURE_ARCHIVE:?Prepared ARM64 fixture is required}" +fixture_image="${OMNIDECK_MACOS_FIXTURE_IMAGE:?Prepared fixture image name is required}" +expected_version="${OMNIDECK_MACOS_EXPECTED_VERSION:?Expected CLI version is required}" +output_dir="${OMNIDECK_VM_LAB_OUTPUT_DIR:?Lab evidence directory is required}" +safe_run_id="$(printf '%s' "$OMNIDECK_VM_LAB_RUN_ID" | tr -cd '[:alnum:]_.-')" +remote_root="/private/tmp/omnideck-cli-macos-e2e-${safe_run_id}" +remote_staged=0 + +cleanup_remote() { + local status=$? + set +e + if [[ "$remote_staged" == 1 ]]; then + case "$remote_root" in + /private/tmp/omnideck-cli-macos-e2e-[[:alnum:]_.-]*) + "$lab_dir/lab.sh" run "$target" rm -rf -- "$remote_root" >/dev/null 2>&1 || true ;; + *) printf 'Refusing to remove unexpected remote path: %s\n' "$remote_root" >&2; status=1 ;; + esac + fi + exit "$status" +} +trap cleanup_remote EXIT + +"$lab_dir/lab.sh" reset "$target" runtime-ready +"$lab_dir/lab.sh" verify "$target" +"$lab_dir/lab.sh" run "$target" mkdir -p "$remote_root" +remote_staged=1 +for source in omnideck-darwin-arm64.tar.gz SHA256SUMS releasecontract contracts.tar.gz; do + "$lab_dir/lab.sh" copy-to "$target" "$build_dir/$source" "$remote_root/$source" +done +"$lab_dir/lab.sh" copy-to "$target" "$fixture_archive" "$remote_root/fixture.tar" +"$lab_dir/lab.sh" copy-to "$target" "$script_dir/macos_guest.sh" "$remote_root/macos_guest.sh" +"$lab_dir/lab.sh" copy-to "$target" "$script_dir/terminal_driver.py" "$remote_root/terminal_driver.py" +"$lab_dir/lab.sh" copy-to "$target" "$repo_root/tests/hardware/run.sh" "$remote_root/hardware-run.sh" +"$lab_dir/lab.sh" copy-to "$target" "$repo_root/tests/hardware/fixture" "$remote_root/hardware-fixture" +"$lab_dir/lab.sh" run "$target" chmod +x "$remote_root/macos_guest.sh" \ + "$remote_root/terminal_driver.py" "$remote_root/releasecontract" "$remote_root/hardware-run.sh" + +test_status=0 +"$lab_dir/lab.sh" run "$target" "$remote_root/macos_guest.sh" \ + "$remote_root" "$expected_version" "$fixture_image" || test_status=$? +mkdir -p "$output_dir/evidence" +"$lab_dir/lab.sh" copy-from "$target" "$remote_root/results/." "$output_dir/evidence/" || test_status=1 + +if [[ -f "$output_dir/evidence/summary.json" ]]; then + python3 - "$output_dir/evidence/summary.json" <<'PY' || test_status=1 +import json, sys +summary=json.load(open(sys.argv[1], encoding='utf-8')) +assert summary['status'] == 'passed', summary +assert summary['platform'] == 'darwin' and summary['architecture'] == 'arm64', summary +assert summary['podmanSetup'] == 'excluded-ready-runtime', summary +PY +fi +exit "$test_status" diff --git a/tests/e2e/terminal_driver.py b/tests/e2e/terminal_driver.py index 8aefbeb..d49d44e 100755 --- a/tests/e2e/terminal_driver.py +++ b/tests/e2e/terminal_driver.py @@ -286,13 +286,75 @@ def install_scenario(args: argparse.Namespace) -> None: raise AssertionError("install journey did not exit successfully") +def macos_install_scenario(args: argparse.Namespace) -> None: + """Drive first-run setup with an already-bootstrapped macOS Podman machine.""" + + env = base_environment(args) + command = scenario_command(args, [args.binary, "install", "--image", args.fixture_image]) + with TerminalSession(command, env=env, artifact_dir=args.artifact_dir, name="install") as terminal: + terminal.expect_all( + ["Welcome to omnideck", "Press Enter to set up omnideck.", "enter set up omnideck"], + timeout=30, + checkpoint="welcome", + ) + mark = terminal.send(ENTER, label="enter") + terminal.expect_all( + [ + "Preparing your environment", + "Setting omnideck up on this computer. This usually takes a few minutes.", + "Computer setup", + "Application files", + "Final checks", + ], + timeout=45, + since=mark, + checkpoint="ready-runtime-check", + ) + terminal.expect_all( + [ + "omnideck is ready", + "Everything is prepared. Open omnideck whenever you’re ready.", + "Open Omnideck in your browser:", + f"http://localhost:{args.web_port}", + "Your files and settings will be kept when Omnideck updates.", + "Press any key to return to the dashboard.", + ], + timeout=args.install_timeout, + since=mark, + checkpoint="ready", + fail_phrases=("The download didn’t finish", "Setup couldn’t finish"), + ) + transcript = strip_terminal_controls(bytes(terminal.raw[mark:])) + unexpected_runtime_setup = [ + phrase for phrase in ("Waiting for your permission", "Installing Podman", "Downloading Podman") + if contains_rendered_phrase(transcript, phrase) + ] + if unexpected_runtime_setup: + raise AssertionError( + f"macOS ready-runtime journey unexpectedly entered Podman setup: {unexpected_runtime_setup!r}" + ) + terminal.record("checkpoint", name="podman-setup-skipped") + mark = terminal.send(ENTER, label="enter") + terminal.expect_all( + ["Decks", args.instance_name, f":{args.web_port}", "running", "Open UI", "Logs", "Update", "Stop"], + timeout=45, + since=mark, + checkpoint="first-dashboard", + ) + terminal.send(b"q", label="q") + if terminal.wait() != 0: + raise AssertionError("macOS install journey did not exit successfully") + + def manage_scenario(args: argparse.Namespace) -> None: env = base_environment(args) command = scenario_command(args, [args.binary, "tui"]) log_match_counter = f"{args.expected_log_count} of {args.expected_log_count}" + instance = args.instance_name + web_port = args.web_port with TerminalSession(command, env=env, artifact_dir=args.artifact_dir, name="manage") as terminal: terminal.expect_all( - ["Decks", "omnideck", ":2337", "running", "Open UI", "Logs", "Update", "Stop"], + ["Decks", instance, f":{web_port}", "running", "Open UI", "Logs", "Update", "Stop"], timeout=45, checkpoint="dashboard", ) @@ -307,7 +369,7 @@ def manage_scenario(args: argparse.Namespace) -> None: mark = terminal.send(b"l", label="l") terminal.expect_all( - ["Decks › omnideck › Logs", "omnideck stdout + stderr", "omnideck-hardware-fixture-started", "/ search"], + [f"Decks › {instance} › Logs", f"{instance} stdout + stderr", "omnideck-hardware-fixture-started", "/ search"], timeout=30, since=mark, checkpoint="logs", @@ -335,44 +397,44 @@ def manage_scenario(args: argparse.Namespace) -> None: ) mark = terminal.send(ESCAPE, label="esc") terminal.expect_all( - ["omnideck stdout + stderr", "esc back", "/ search"], + [f"{instance} stdout + stderr", "esc back", "/ search"], timeout=15, since=mark, checkpoint="log-filter-cleared", ) mark = terminal.send(ESCAPE, label="esc") - terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="logs-back") + terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="logs-back") mark = terminal.send(b"c", label="c") terminal.expect_all( - ["Decks › omnideck › Settings", "File storage", "App storage", "Memory limit", "Browser port", "Container image"], + [f"Decks › {instance} › Settings", "File storage", "App storage", "Memory limit", "Browser port", "Container image"], timeout=20, since=mark, checkpoint="settings", ) mark = terminal.send(ESCAPE, label="esc") - terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="settings-back") + terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="settings-back") mark = terminal.send(b"u", label="u") terminal.expect_all( - ["Decks › omnideck › Update", "Update omnideck", "files and agent data are kept", "enter update"], + [f"Decks › {instance} › Update", f"Update {instance}", "files and agent data are kept", "enter update"], timeout=20, since=mark, checkpoint="update-review", ) mark = terminal.send(ESCAPE, label="esc") - terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="update-canceled") + terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="update-canceled") mark = terminal.send(b"s", label="s") terminal.expect_all( - ["Stopped omnideck", "Start"], timeout=45, since=mark, checkpoint="stopped" + [f"Stopped {instance}", "Start"], timeout=45, since=mark, checkpoint="stopped" ) mark = terminal.send(b"d", label="d") terminal.expect_all( [ "Doctor", "1 problem needs attention", - "Omnideck instance — omnideck is stopped", + f"Omnideck instance — {instance} is stopped", "Press Enter to start omnideck.", ], timeout=45, @@ -381,34 +443,34 @@ def manage_scenario(args: argparse.Namespace) -> None: ) mark = terminal.send(ENTER, label="enter") terminal.expect_all( - ["Everything required is working", "Omnideck instance — omnideck is running", "Browser — http://localhost:2337 is responding"], + ["Everything required is working", f"Omnideck instance — {instance} is running", f"Browser — http://localhost:{web_port} is responding"], timeout=60, since=mark, checkpoint="doctor-recovered", ) mark = terminal.send(ESCAPE, label="esc") - terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="doctor-back") + terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="doctor-back") mark = terminal.send(b"n", label="n") terminal.expect_all( - ["Setup · Settings", "Recommended settings are ready", "omnideck2", "http://localhost:2338"], + ["Setup · Settings", "Recommended settings are ready", args.additional_name, f"http://localhost:{args.additional_port}"], timeout=45, since=mark, checkpoint="additional-instance-defaults", ) mark = terminal.send(ENTER, label="enter") terminal.expect_all( - ["Setup · Review", "Ready to set up Omnideck", "Name", "omnideck2", "Press Enter to start setup"], + ["Setup · Review", "Ready to set up Omnideck", "Name", args.additional_name, "Press Enter to start setup"], timeout=20, since=mark, checkpoint="additional-instance-review", ) mark = terminal.send(b"q", label="q") - terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="additional-instance-canceled") + terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="additional-instance-canceled") mark = terminal.send(b"x", label="x") terminal.expect_all( - ["Remove omnideck", "Keep saved data — Recommended", "Permanently delete saved data"], + [f"Remove {instance}", "Keep saved data — Recommended", "Permanently delete saved data"], timeout=20, since=mark, checkpoint="removal-data-choice", @@ -424,15 +486,15 @@ def manage_scenario(args: argparse.Namespace) -> None: terminal.send(DOWN, label="down") mark = terminal.send(ENTER, label="enter") terminal.expect_all( - ["Confirm permanent data deletion", "Type omnideck below to confirm", "No backup will be created"], + ["Confirm permanent data deletion", f"Type {instance} below to confirm", "No backup will be created"], timeout=20, since=mark, checkpoint="removal-confirmation", ) - terminal.send(b"omnideck", label="omnideck") + terminal.send(instance.encode(), label=instance) mark = terminal.send(ENTER, label="enter") terminal.expect_all( - ["omnideck was removed", "saved data was permanently deleted", "Press any key to return to Decks"], + [f"{instance} was removed", "saved data was permanently deleted", "Press any key to return to Decks"], timeout=60, since=mark, checkpoint="removal-complete", @@ -558,7 +620,7 @@ def windows_install_scenario(args: argparse.Namespace) -> None: def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser = argparse.ArgumentParser() - parser.add_argument("scenario", choices=("install", "manage", "windows-bootstrap", "windows-install")) + parser.add_argument("scenario", choices=("install", "macos-install", "manage", "windows-bootstrap", "windows-install")) parser.add_argument("--binary", required=True) parser.add_argument("--config-dir", required=True) parser.add_argument("--registries-conf", required=True) @@ -566,6 +628,10 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser.add_argument("--artifact-dir", required=True, type=Path) parser.add_argument("--install-timeout", type=float, default=1200) parser.add_argument("--expected-log-count", type=int, default=1) + parser.add_argument("--instance-name", default="omnideck") + parser.add_argument("--web-port", default="2337") + parser.add_argument("--additional-name", default="omnideck2") + parser.add_argument("--additional-port", default="2338") parser.add_argument("--command-json") parser.add_argument("--hook-command-json") return parser.parse_args(argv) @@ -577,6 +643,8 @@ def main(argv: Sequence[str] | None = None) -> int: try: if args.scenario == "install": install_scenario(args) + elif args.scenario == "macos-install": + macos_install_scenario(args) elif args.scenario == "manage": manage_scenario(args) elif args.scenario == "windows-bootstrap": diff --git a/tests/e2e/test_lab_contract.py b/tests/e2e/test_lab_contract.py index 86bf6f1..5a7bd4b 100644 --- a/tests/e2e/test_lab_contract.py +++ b/tests/e2e/test_lab_contract.py @@ -37,6 +37,21 @@ def test_matrix_preflights_and_groups_lane_evidence(self) -> None: self.assertIn('OMNIDECK_VM_E2E_OUTPUT_DIR="$lane_dir"', script) self.assertIn("lane-status.tsv", script) + def test_macos_harness_uses_physical_host_contract_and_ready_runtime(self) -> None: + script = (ROOT / "tests/e2e/run-macos-lab.sh").read_text(encoding="utf-8") + guest = (ROOT / "tests/e2e/macos_guest.sh").read_text(encoding="utf-8") + self.assertIn('lease "$target" cli-e2e', script) + self.assertIn("artifact-path cli macos-e2e", script) + self.assertIn("GOOS=darwin", script) + self.assertIn("GOARCH=arm64", script) + self.assertIn("--cleanup-baseline runtime-ready", script) + self.assertIn("podmanSetup=excluded-ready-runtime", script) + self.assertIn("terminal_driver.py\" macos-install", guest) + self.assertIn("terminal_driver.py\" manage", guest) + self.assertIn("ownership_marker", guest) + self.assertNotIn("podman machine init", guest) + self.assertNotIn("podman machine start", guest) + def test_legacy_manual_helper_delegates_to_canonical_lane(self) -> None: script = (ROOT / "tests/manual/run-local-hardware.sh").read_text( encoding="utf-8" diff --git a/tests/hardware/README.md b/tests/hardware/README.md index 2dda701..d280147 100644 --- a/tests/hardware/README.md +++ b/tests/hardware/README.md @@ -63,6 +63,25 @@ macOS or Linux: OMNIDECK_HARDWARE_ENGINE=auto ./tests/hardware/run.sh ``` +The external release lab can lease its configured Apple Silicon host, cross- +build the exact current CLI source with the pinned builder, run this lifecycle +against the product-managed Podman machine, and copy marked evidence back: + +```sh +export OMNIDECK_VM_LAB_DIR=/mnt/data/VMs/omnideck-release-lab +make macos-lab-test +``` + +The macOS lab target is disposable at the application layer. The controller +resets it, installs the cross-built CLI into `~/.omnideck-lab/bin`, runs the +ARM64 container lifecycle, collects evidence, and removes only the lab-managed +CLI and namespaced state/resources as the lease exits. The user's normal CLI, +config, and resources are preserved. Podman remains installed and warm; +compilation and fixture-image construction stay on the Linux controller. + +This lane uses unique container, registry, volume, profile, and port names. It +does not reset the physical Mac or claim a clean-host installation test. + Windows PowerShell: ```powershell diff --git a/tests/hardware/fixture/Containerfile b/tests/hardware/fixture/Containerfile index a90a3e1..64a5271 100644 --- a/tests/hardware/fixture/Containerfile +++ b/tests/hardware/fixture/Containerfile @@ -1,6 +1,5 @@ FROM docker.io/library/busybox:1.37.0@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 -RUN mkdir -p /www /home/omnideck /var/lib/omnideck COPY index.html /www/index.html EXPOSE 8080 diff --git a/tests/hardware/macos-lab-install.sh b/tests/hardware/macos-lab-install.sh new file mode 100755 index 0000000..c7639d3 --- /dev/null +++ b/tests/hardware/macos-lab-install.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +export LANG=C +export LC_ALL=C + +source_binary="${1:?Staged CLI path is required}" +evidence_file="${2:-}" +[[ "$(uname -s)/$(uname -m)" == Darwin/arm64 ]] || { + printf 'The macOS lab installer requires native Apple Silicon.\n' >&2 + exit 2 +} +[[ -x "$source_binary" ]] || { printf 'CLI candidate is not executable: %s\n' "$source_binary" >&2; exit 1; } +file "$source_binary" | grep -Eq 'Mach-O 64-bit.*arm64' + +managed_root="$HOME/.omnideck-lab" +destination="$managed_root/bin/omnideck" +marker="$managed_root/managed-cli.sha256" +[[ ! -e "$destination" && ! -L "$destination" ]] || { + printf 'The disposable baseline was not clean; CLI already exists: %s\n' "$destination" >&2 + exit 1 +} +mkdir -p "$(dirname "$destination")" "$managed_root" +install -m 0755 "$source_binary" "$destination" +shasum -a 256 "$destination" > "$marker" +shasum -a 256 --check "$marker" >/dev/null +if [[ -n "$evidence_file" ]]; then + mkdir -p "$(dirname "$evidence_file")" + digest="$(shasum -a 256 "$destination" | awk '{print $1}')" + version="$($destination --version)" + node - "$evidence_file" "$destination" "$digest" "$version" <<'NODE' +const fs = require('node:fs'); +const [path, destination, sha256, version] = process.argv.slice(2); +fs.writeFileSync(path, `${JSON.stringify({ + schemaVersion: 1, + kind: 'cli', + destination, + sha256, + version, + architecture: 'arm64', +}, null, 2)}\n`); +NODE +fi +printf 'Installed CLI candidate: %s\n' "$destination" diff --git a/tests/hardware/run-macos-lab.sh b/tests/hardware/run-macos-lab.sh new file mode 100755 index 0000000..e9ec495 --- /dev/null +++ b/tests/hardware/run-macos-lab.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +lab_dir="${OMNIDECK_VM_LAB_DIR:-}" +target=macos-arm64 +profile="${OMNIDECK_VM_LAB_PROFILE:-release-clean}" +builder_image="${OMNIDECK_CLI_BUILDER_IMAGE:-omnideck-cli-builder:local}" + +[[ -n "$lab_dir" && -x "$lab_dir/lab.sh" ]] || { + printf 'Set OMNIDECK_VM_LAB_DIR to the deployed OmniDeck VM lab.\n' >&2 + exit 2 +} + +python3 -c 'import json,subprocess,sys; data=json.loads(subprocess.check_output([sys.argv[1], "capabilities", "--json"])); assert "remote-hosts" in data["features"]' "$lab_dir/lab.sh" || { + printf 'The deployed lab controller does not support physical hosts.\n' >&2 + exit 2 +} + +if [[ "${OMNIDECK_VM_LAB_LEASED:-}" != 1 ]]; then + "$lab_dir/lab.sh" preflight cli "$profile" --lanes "$target" >/dev/null + command -v docker >/dev/null 2>&1 || { printf 'Docker is required to run the pinned Go builder.\n' >&2; exit 2; } + docker image inspect "$builder_image" >/dev/null 2>&1 || { + printf 'Builder image %q is unavailable. Build .devcontainer/Dockerfile first.\n' "$builder_image" >&2 + exit 2 + } + + fixture_key="$(shasum -a 256 "$script_dir/fixture/Containerfile" "$script_dir/fixture/index.html" | shasum -a 256 | awk '{print substr($1,1,20)}')" + fixture_cache="$($lab_dir/lab.sh cache-path cli "macos-arm64-fixture-${fixture_key}")" + fixture_archive="$fixture_cache/fixture.tar" + fixture_image="localhost/omnideck-hardware-fixture:macos-arm64-${fixture_key}" + if [[ ! -f "$fixture_archive" ]]; then + mkdir -p "$fixture_cache" + temporary_archive="$fixture_cache/.fixture.tar.$$" + docker buildx build --platform linux/arm64 \ + --file "$script_dir/fixture/Containerfile" --tag "$fixture_image" \ + --output "type=docker,dest=${temporary_archive}" "$script_dir/fixture" + mv -- "$temporary_archive" "$fixture_archive" + shasum -a 256 "$fixture_archive" > "$fixture_cache/SHA256SUMS" + fi + + run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" + source_commit="$(git -C "$repo_root" rev-parse HEAD)" + source_dirty=false + [[ -z "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=normal)" ]] || source_dirty=true + output_dir="$($lab_dir/lab.sh artifact-path cli macos-hardware "$run_id")" + build_dir="$output_dir/build" + mkdir -p "$build_dir" + binary="$build_dir/omnideck" + short_commit="${source_commit:0:12}" + ldflags="-X main.version=local-macos-arm64 -X main.commit=${short_commit}" + + docker run --rm --entrypoint /bin/zsh \ + --user "$(id -u):$(id -g)" \ + --env GOOS=darwin --env GOARCH=arm64 --env CGO_ENABLED=0 \ + --env GOCACHE=/tmp/omnideck-go-build --env GOPATH=/tmp/omnideck-go \ + --volume "$repo_root:/workspace:ro" --volume "$build_dir:/out" \ + --workdir /workspace "$builder_image" \ + -c "go build -trimpath -buildvcs=false -ldflags \"${ldflags}\" -o /out/omnideck ." + chmod +x "$binary" + file "$binary" | grep -Eq 'Mach-O 64-bit.*arm64' + shasum -a 256 "$binary" > "$output_dir/SHA256SUMS" + + "$lab_dir/lab.sh" evidence-init "$output_dir" cli macos-hardware "$run_id" \ + "$source_commit" "$target" runtime-ready "sourceDirty=$source_dirty" "architecture=arm64" "artifactKind=source-build" + trap '"$lab_dir/lab.sh" evidence-finish "$output_dir" failed >/dev/null 2>&1 || true' EXIT + status=0 + "$lab_dir/lab.sh" lease "$target" cli "$run_id" --cleanup-baseline runtime-ready -- env \ + OMNIDECK_MACOS_CLI_BINARY="$binary" \ + OMNIDECK_MACOS_FIXTURE_ARCHIVE="$fixture_archive" \ + OMNIDECK_MACOS_FIXTURE_IMAGE="$fixture_image" \ + OMNIDECK_VM_LAB_OUTPUT_DIR="$output_dir" \ + "$0" || status=$? + trap - EXIT + if [[ "$status" == 0 ]]; then + "$lab_dir/lab.sh" evidence-finish "$output_dir" passed + else + "$lab_dir/lab.sh" evidence-finish "$output_dir" failed + fi + printf 'Evidence: %s\n' "$output_dir" + exit "$status" +fi + +[[ "${OMNIDECK_VM_LAB_VM:-}" == "$target" ]] || { + printf 'The active lab lease does not own %s.\n' "$target" >&2 + exit 2 +} +binary="${OMNIDECK_MACOS_CLI_BINARY:?Prepared macOS CLI binary is required}" +output_dir="${OMNIDECK_VM_LAB_OUTPUT_DIR:?Lab evidence directory is required}" +fixture_archive="${OMNIDECK_MACOS_FIXTURE_ARCHIVE:?Prepared ARM64 fixture archive is required}" +fixture_image="${OMNIDECK_MACOS_FIXTURE_IMAGE:?Prepared ARM64 fixture image name is required}" +safe_run_id="$(printf '%s' "${OMNIDECK_VM_LAB_RUN_ID}" | tr -cd '[:alnum:]_.-')" +remote_root="/tmp/omnideck-cli-macos-${safe_run_id}" +remote_staged=0 + +cleanup_remote() { + local status=$? + if [[ "$remote_staged" == 1 ]]; then + case "$remote_root" in + /tmp/omnideck-cli-macos-[[:alnum:]_.-]*) "$lab_dir/lab.sh" run "$target" rm -rf -- "$remote_root" >/dev/null 2>&1 || true ;; + *) printf 'Refusing to remove unexpected remote path: %s\n' "$remote_root" >&2; status=1 ;; + esac + fi + exit "$status" +} +trap cleanup_remote EXIT + +"$lab_dir/lab.sh" reset "$target" runtime-ready +"$lab_dir/lab.sh" verify "$target" +remote_home="$("$lab_dir/lab.sh" run "$target" /bin/zsh -c 'printf %s "$HOME"')" +case "$remote_home" in /Users/*) ;; *) printf 'Unexpected remote home: %s\n' "$remote_home" >&2; exit 1 ;; esac +"$lab_dir/lab.sh" run "$target" mkdir -p "$remote_root" +remote_staged=1 +"$lab_dir/lab.sh" copy-to "$target" "$binary" "$remote_root/omnideck" +"$lab_dir/lab.sh" copy-to "$target" "$fixture_archive" "$remote_root/fixture.tar" +"$lab_dir/lab.sh" copy-to "$target" "$script_dir" "$remote_root/hardware" +"$lab_dir/lab.sh" run "$target" chmod +x "$remote_root/omnideck" \ + "$remote_root/hardware/run.sh" "$remote_root/hardware/macos-lab-install.sh" +"$lab_dir/lab.sh" run "$target" "$remote_root/hardware/macos-lab-install.sh" \ + "$remote_root/omnideck" "$remote_root/artifacts/install.json" + +test_status=0 +"$lab_dir/lab.sh" run "$target" env \ + OMNIDECK_HARDWARE_CLI="$remote_home/.omnideck-lab/bin/omnideck" \ + OMNIDECK_HARDWARE_ENGINE=podman \ + OMNIDECK_HARDWARE_REGISTRY_PORT=46864 \ + OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE="$remote_root/fixture.tar" \ + OMNIDECK_HARDWARE_ARCHIVE_IMAGE="$fixture_image" \ + OMNIDECK_HARDWARE_OUTPUT_DIR="$remote_root/artifacts" \ + "$remote_root/hardware/run.sh" || test_status=$? + +mkdir -p "$output_dir/hardware" +if ! "$lab_dir/lab.sh" copy-from "$target" "$remote_root/artifacts/." "$output_dir/hardware/"; then + [[ "$test_status" != 0 ]] || test_status=1 +fi +if ! python3 - "$output_dir/hardware/install.json" "$binary" <<'PY' +import hashlib, json, sys +with open(sys.argv[1]) as handle: + installation = json.load(handle) +with open(sys.argv[2], "rb") as handle: + expected = hashlib.sha256(handle.read()).hexdigest() +assert installation["kind"] == "cli" +assert installation["destination"].endswith("/.omnideck-lab/bin/omnideck") +assert installation["architecture"] == "arm64" +assert installation["sha256"] == expected +PY +then + [[ "$test_status" != 0 ]] || test_status=1 +fi +exit "$test_status" diff --git a/tests/hardware/run.sh b/tests/hardware/run.sh index f428d2e..463125a 100755 --- a/tests/hardware/run.sh +++ b/tests/hardware/run.sh @@ -24,6 +24,8 @@ junit_file="${output_dir}/junit.xml" config_path="" engine="" fixture_image="${OMNIDECK_HARDWARE_TEST_IMAGE:-}" +fixture_archive="${OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE:-}" +fixture_archive_image="${OMNIDECK_HARDWARE_ARCHIVE_IMAGE:-}" local_fixture_image="" registry_container="${instance}-registry" built_fixture=0 @@ -150,6 +152,11 @@ fi if [[ -z "${provided_cli}" ]]; then command -v go >/dev/null 2>&1 || fail "Go is required to build the CLI. Set OMNIDECK_HARDWARE_CLI to test a prebuilt binary instead." fi +if [[ -n "${fixture_archive}" ]]; then + [[ -f "${fixture_archive}" ]] || fail "OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE does not exist." + [[ "${fixture_archive_image}" =~ ^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$ ]] || fail "OMNIDECK_HARDWARE_ARCHIVE_IMAGE is required and must be a safe image reference." + [[ -z "${fixture_image}" ]] || fail "Use either a fixture image or an image archive, not both." +fi command -v curl >/dev/null 2>&1 || fail "curl is required to verify the fixture web UI." select_engine @@ -191,12 +198,21 @@ current_step="build fixture image" if [[ -z "${fixture_image}" ]]; then local_fixture_image="localhost/omnideck-hardware-fixture:${safe_run_id}" fixture_image="localhost:${registry_port}/omnideck-hardware-fixture:${safe_run_id}" - "${engine}" build --file "${script_dir}/fixture/Containerfile" --tag "${local_fixture_image}" "${script_dir}/fixture" + if [[ -n "${fixture_archive}" ]]; then + "${engine}" load --input "${fixture_archive}" + "${engine}" tag "${fixture_archive_image}" "${local_fixture_image}" + else + "${engine}" build --file "${script_dir}/fixture/Containerfile" --tag "${local_fixture_image}" "${script_dir}/fixture" + fi built_fixture=1 "${engine}" run -d --name "${registry_container}" -p "127.0.0.1:${registry_port}:5000" docker.io/library/registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 wait_for_registry "${engine}" tag "${local_fixture_image}" "${fixture_image}" - "${engine}" push "${fixture_image}" + if [[ "${engine}" == podman ]]; then + "${engine}" push --tls-verify=false "${fixture_image}" + else + "${engine}" push "${fixture_image}" + fi fi current_step="setup" diff --git a/tests/manual/local-vm-lab.md b/tests/manual/local-vm-lab.md index fa7d6f0..c1df495 100644 --- a/tests/manual/local-vm-lab.md +++ b/tests/manual/local-vm-lab.md @@ -4,6 +4,22 @@ This is the CLI workflow for the external OmniDeck release lab. Prefer the automated E2E matrix for every deterministic Linux and Windows check. Open a graphical viewer only for behavior listed as manual in the applicable procedure. +The lab's leased physical Apple Silicon host has a separate automated source +lane: + +```sh +export OMNIDECK_VM_LAB_DIR=/mnt/data/VMs/omnideck-release-lab +make macos-lab-test +``` + +This lane installs the prepared CLI under `~/.omnideck-lab` on the dedicated +Mac and owns a `runtime-ready` cleanup baseline. It removes the lab-managed +binary and namespaced resources after every run while retaining the user's +normal CLI/config/resources and the costly Podman installation and machine. + +It runs isolated CLI/container lifecycle coverage and copies evidence back to +the lab without treating the physical Mac as a resettable VM. + ## Preflight and ownership Set the lab path without committing its machine-specific value: