diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/scripts/community-smoke.sh b/.github/scripts/community-smoke.sh new file mode 100755 index 0000000..458b1d6 --- /dev/null +++ b/.github/scripts/community-smoke.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + echo "usage: KUBEORCH_CORE_IMAGE= KUBEORCH_UI_IMAGE= $0 [amd64|arm64]" >&2 +} + +cli_input="${1:-${ORCHCLI_BIN:-}}" +expected_arch="${2:-${EXPECTED_ARCH:-}}" +core_image="${KUBEORCH_CORE_IMAGE:-}" +ui_image="${KUBEORCH_UI_IMAGE:-}" +smoke_mode="${KUBEORCH_SMOKE_MODE:-published}" +playwright_package="${PLAYWRIGHT_CLI_PACKAGE:-@playwright/cli@0.1.19}" + +if [[ -z "$cli_input" || -z "$core_image" || -z "$ui_image" ]]; then + usage + exit 2 +fi + +case "$smoke_mode" in + candidate | published) ;; + *) + echo "KUBEORCH_SMOKE_MODE must be candidate or published" >&2 + exit 2 + ;; +esac + +if [[ "$smoke_mode" == "published" ]]; then + immutable_ref='^.+@sha256:[0-9a-f]{64}$' + if [[ ! "$core_image" =~ $immutable_ref || ! "$ui_image" =~ $immutable_ref ]]; then + echo "Published smokes require digest-pinned Core and UI image references" >&2 + exit 2 + fi +fi + +for command_name in docker curl node npx; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required command is missing: $command_name" >&2 + exit 2 + fi +done + +if [[ "$cli_input" == */* ]]; then + cli_dir="$(cd "$(dirname "$cli_input")" && pwd)" + cli="$cli_dir/$(basename "$cli_input")" +else + cli="$(command -v "$cli_input" || true)" +fi +if [[ -z "$cli" || ! -x "$cli" ]]; then + echo "OrchCLI executable was not found: $cli_input" >&2 + exit 2 +fi + +host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" +host_machine="$(uname -m)" +case "$host_machine" in + x86_64 | amd64) host_arch="amd64" ;; + arm64 | aarch64) host_arch="arm64" ;; + *) + echo "Unsupported host architecture: $host_machine" >&2 + exit 2 + ;; +esac +if [[ -n "$expected_arch" && "$host_arch" != "$expected_arch" ]]; then + echo "Host architecture is $host_arch, expected $expected_arch" >&2 + exit 1 +fi +expected_arch="$host_arch" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +evidence_dir="${KUBEORCH_EVIDENCE_DIR:-$repo_root/output/playwright/community-smoke-$host_os-$host_arch}" +mkdir -p "$evidence_dir" +evidence_dir="$(cd "$evidence_dir" && pwd)" +project_dir="$(mktemp -d "${TMPDIR:-/tmp}/kubeorch-community-smoke.XXXXXX")" +session="kubeorch-community-${host_os}-${host_arch}-$$" +compose_file="$project_dir/docker/docker-compose.prod.yml" +start_epoch="$(date +%s)" +started=false + +pwcli() { + npx --yes --package "$playwright_package" playwright-cli -s="$session" "$@" +} + +collect_diagnostics() { + if [[ -f "$compose_file" ]]; then + ( + cd "$project_dir" + docker compose -f "$compose_file" ps --all + docker compose -f "$compose_file" logs --no-color --tail 200 + ) >"$evidence_dir/compose-diagnostics.log" 2>&1 || true + fi +} + +cleanup() { + exit_code="$?" + set +e + if [[ "$exit_code" -ne 0 ]]; then + collect_diagnostics + fi + pwcli close >/dev/null 2>&1 || true + if [[ "$started" == true ]]; then + ( + cd "$project_dir" + "$cli" stop --volumes + ) >"$evidence_dir/stop.log" 2>&1 || true + fi + rm -f "$project_dir/.kubeorch/project.json" + rmdir "$project_dir/.kubeorch" >/dev/null 2>&1 || true + rm -f "$project_dir/docker/"*.yml + rmdir "$project_dir/docker" "$project_dir/scripts" "$project_dir" >/dev/null 2>&1 || true + exit "$exit_code" +} +trap cleanup EXIT + +assert_platform() { + image_ref="$1" + image_name="$2" + + if [[ "$smoke_mode" == "published" ]]; then + docker pull --platform "linux/$expected_arch" "$image_ref" >/dev/null + raw_index="$(docker buildx imagetools inspect "$image_ref" --raw)" + # shellcheck disable=SC2016 + IMAGE_INDEX="$raw_index" node -e ' + const index = JSON.parse(process.env.IMAGE_INDEX); + const platforms = new Set( + (index.manifests || []) + .map((entry) => entry.platform || {}) + .filter((platform) => platform.os !== "unknown") + .map((platform) => `${platform.os}/${platform.architecture}`) + ); + for (const required of ["linux/amd64", "linux/arm64"]) { + if (!platforms.has(required)) { + throw new Error(`release index is missing ${required}`); + } + } + ' + fi + + actual_platform="$(docker image inspect "$image_ref" --format '{{.Os}}/{{.Architecture}}')" + if [[ "$actual_platform" != "linux/$expected_arch" ]]; then + echo "$image_name resolved to $actual_platform, expected linux/$expected_arch" >&2 + exit 1 + fi +} + +assert_platform "$core_image" Core +assert_platform "$ui_image" UI + +if [[ "$smoke_mode" == "candidate" ]]; then + export KUBEORCH_CORE_IMAGE="$core_image" + export KUBEORCH_UI_IMAGE="$ui_image" +else + # Published evidence must exercise the compatibility set embedded in the CLI. + unset KUBEORCH_CORE_IMAGE KUBEORCH_UI_IMAGE +fi +port_offset="$(( $$ % 400 ))" +export KUBEORCH_CORE_PORT="${KUBEORCH_CORE_PORT:-$((31000 + port_offset))}" +export KUBEORCH_UI_PORT="${KUBEORCH_UI_PORT:-$((31400 + port_offset))}" +export KUBEORCH_MONGO_PORT="${KUBEORCH_MONGO_PORT:-$((31800 + port_offset))}" +export KUBEORCH_BROWSER_API_URL="${KUBEORCH_BROWSER_API_URL:-http://localhost:$KUBEORCH_CORE_PORT/v1/api}" +export KUBEORCH_CORS_ALLOWED_ORIGINS="${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:$KUBEORCH_UI_PORT}" +export KUBEORCH_COMPOSE_PROJECT="${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra-smoke-$host_arch-$$}" +export KUBEORCH_NETWORK_NAME="${KUBEORCH_NETWORK_NAME:-kubeorchestra-smoke-$host_arch-$$}" +export KUBEORCH_MONGODB_DATA_VOLUME="${KUBEORCH_MONGODB_DATA_VOLUME:-kubeorchestra-smoke-data-$host_arch-$$}" +export KUBEORCH_MONGODB_CONFIG_VOLUME="${KUBEORCH_MONGODB_CONFIG_VOLUME:-kubeorchestra-smoke-config-$host_arch-$$}" +export KUBEORCH_MONGODB_CONTAINER="${KUBEORCH_MONGODB_CONTAINER:-kubeorchestra-smoke-mongodb-$host_arch-$$}" +export KUBEORCH_CORE_CONTAINER="${KUBEORCH_CORE_CONTAINER:-kubeorchestra-smoke-core-$host_arch-$$}" +export KUBEORCH_UI_CONTAINER="${KUBEORCH_UI_CONTAINER:-kubeorchestra-smoke-ui-$host_arch-$$}" + +( + cd "$project_dir" + "$cli" init +) | tee "$evidence_dir/init.log" + +if [[ ! -f "$compose_file" ]]; then + echo "orchcli init did not generate $compose_file" >&2 + exit 1 +fi + +( + cd "$project_dir" + docker compose -f "$compose_file" config --images +) | tee "$evidence_dir/compose-images.log" +grep -Fqx "$core_image" "$evidence_dir/compose-images.log" +grep -Fqx "$ui_image" "$evidence_dir/compose-images.log" + +started=true +( + cd "$project_dir" + "$cli" start -d +) | tee "$evidence_dir/start.log" + +wait_for_url() { + label="$1" + url="$2" + attempts=0 + while [[ "$attempts" -lt 90 ]]; do + if curl --fail --silent --show-error --output /dev/null "$url"; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 2 + done + echo "$label did not become healthy at $url" >&2 + return 1 +} + +api_origin="$KUBEORCH_BROWSER_API_URL" +ui_origin="http://localhost:$KUBEORCH_UI_PORT" +wait_for_url Core "http://127.0.0.1:$KUBEORCH_CORE_PORT/v1" +wait_for_url UI "http://127.0.0.1:$KUBEORCH_UI_PORT/login" + +wait_for_service_health() { + service="$1" + attempts=0 + while [[ "$attempts" -lt 90 ]]; do + container_id="$( + cd "$project_dir" + docker compose -f "$compose_file" ps --quiet "$service" + )" + if [[ -n "$container_id" ]]; then + health="$(docker inspect "$container_id" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}')" + if [[ "$health" == "healthy" ]]; then + return 0 + fi + if [[ "$health" == "unhealthy" ]]; then + echo "$service container became unhealthy" >&2 + return 1 + fi + fi + attempts=$((attempts + 1)) + sleep 2 + done + echo "$service container did not become healthy" >&2 + return 1 +} + +for service in mongodb core ui; do + wait_for_service_health "$service" +done + +( + cd "$project_dir" + "$cli" status +) | tee "$evidence_dir/status.log" + +npx --yes --package "$playwright_package" playwright-cli install-browser chromium \ + >"$evidence_dir/playwright-install.log" +pwcli open "$ui_origin/login" >"$evidence_dir/playwright-open.log" +pwcli snapshot >"$evidence_dir/playwright-snapshot.log" +pwcli requests >"$evidence_dir/playwright-requests.log" +if ! grep -F "$api_origin/auth/methods" "$evidence_dir/playwright-requests.log" | \ + grep -F '=> [200]' >/dev/null; then + echo "Browser did not complete the configured Core authentication request" >&2 + exit 1 +fi + +cli_version="$($cli --version | head -n 1 | tr -d '\r')" +duration_seconds="$(( $(date +%s) - start_epoch ))" +export SMOKE_HOST_OS="$host_os" +export SMOKE_HOST_ARCH="$host_arch" +export SMOKE_MODE="$smoke_mode" +export SMOKE_CLI_VERSION="$cli_version" +export SMOKE_CORE_IMAGE="$core_image" +export SMOKE_UI_IMAGE="$ui_image" +export SMOKE_DURATION_SECONDS="$duration_seconds" +export SMOKE_BROWSER_REQUEST="$api_origin/auth/methods" +# shellcheck disable=SC2016 +node -e ' + const fs = require("fs"); + const evidence = { + schema_version: 1, + mode: process.env.SMOKE_MODE, + host: { os: process.env.SMOKE_HOST_OS, arch: process.env.SMOKE_HOST_ARCH }, + cli_version: process.env.SMOKE_CLI_VERSION, + core_image: process.env.SMOKE_CORE_IMAGE, + ui_image: process.env.SMOKE_UI_IMAGE, + duration_seconds: Number(process.env.SMOKE_DURATION_SECONDS), + browser_request: process.env.SMOKE_BROWSER_REQUEST, + result: "passed" + }; + fs.writeFileSync(process.argv[1], `${JSON.stringify(evidence, null, 2)}\n`); +' "$evidence_dir/evidence.json" + +echo "Community smoke passed on $host_os/$host_arch in ${duration_seconds}s" diff --git a/.github/workflows/community-smoke.yml b/.github/workflows/community-smoke.yml new file mode 100644 index 0000000..f84fbfb --- /dev/null +++ b/.github/workflows/community-smoke.yml @@ -0,0 +1,259 @@ +name: Community Release Smoke + +on: + pull_request: + branches: [main] + paths: + - ".github/scripts/community-smoke.sh" + - ".github/workflows/community-smoke.yml" + - ".github/workflows/release-and-publish.yml" + - "cmd/**" + - "docker/**" + - "npm/**" + - "*.go" + - "go.mod" + - "go.sum" + - "install.sh" + - "package.json" + workflow_dispatch: + inputs: + cli_version: + description: Published CLI tag to verify (for example, v0.0.9) + required: true + type: string + core_image: + description: Digest-pinned multi-architecture Core image + required: true + type: string + ui_image: + description: Digest-pinned multi-architecture UI image + required: true + type: string + +permissions: + contents: read + +concurrency: + group: community-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BASELINE_CLI_VERSION: v0.0.8 + CORE_SOURCE_REF: 5ce2f3260ade492499b35e8e8d2bcf09d9329a9d + UI_SOURCE_REF: 0208a3267171cf9128cf146cead9cc9c1bc230ce + PLAYWRIGHT_CLI_PACKAGE: "@playwright/cli@0.1.19" + +jobs: + installer: + name: Installer (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux/amd64 + artifact: linux-amd64 + binary: orchcli + file_pattern: x86-64 + - runner: macos-15 + platform: darwin/arm64 + artifact: darwin-arm64 + binary: orchcli + file_pattern: arm64 + steps: + - name: Checkout CLI + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "20" + + - name: Run npm installer unit tests + run: npm test + + - name: Select smoke version + shell: bash + env: + REQUESTED_VERSION: ${{ inputs.cli_version }} + run: | + version="${REQUESTED_VERSION:-$BASELINE_CLI_VERSION}" + echo "SMOKE_CLI_VERSION=$version" >> "$GITHUB_ENV" + + - name: Verify native installer and checksum + shell: bash + run: | + install_dir="$RUNNER_TEMP/orchcli-native" + mkdir -p "$install_dir" + ORCHCLI_VERSION="$SMOKE_CLI_VERSION" \ + ORCHCLI_INSTALL_DIR="$install_dir" \ + ORCHCLI_NO_SUDO=1 \ + sh ./install.sh + "$install_dir/${{ matrix.binary }}" --version | tee native-version.log + file "$install_dir/${{ matrix.binary }}" | tee native-binary.log + grep -F "OrchCLI ${SMOKE_CLI_VERSION#v}" native-version.log + grep -F "${{ matrix.file_pattern }}" native-binary.log + + - name: Verify npm installer and platform selection + shell: bash + env: + PUBLISHED_MODE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + version="${SMOKE_CLI_VERSION#v}" + prefix="$RUNNER_TEMP/orchcli-npm" + if [[ "$PUBLISHED_MODE" == "true" ]]; then + npm install --global --prefix "$prefix" "@kubeorch/cli@$version" + else + npm version "$version" --no-git-tag-version --allow-same-version + package_file="$(npm pack --silent | tail -n 1)" + npm install --global --prefix "$prefix" "./$package_file" + fi + "$prefix/bin/orchcli" --version | tee npm-version.log + npm_binary="$(find "$prefix" -type f -name 'orchcli-bin*' -print -quit)" + test -n "$npm_binary" + file "$npm_binary" | tee npm-binary.log + grep -F "OrchCLI ${SMOKE_CLI_VERSION#v}" npm-version.log + grep -F "${{ matrix.file_pattern }}" npm-binary.log + + - name: Upload installer evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: installer-${{ matrix.artifact }} + path: | + native-version.log + native-binary.log + npm-version.log + npm-binary.log + if-no-files-found: warn + + candidate-runtime: + name: Candidate runtime (linux/${{ matrix.arch }}) + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + steps: + - name: Checkout CLI + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: cli + persist-credentials: false + + - name: Checkout Core release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: KubeOrch/core + ref: ${{ env.CORE_SOURCE_REF }} + path: core + persist-credentials: false + + - name: Checkout UI release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: KubeOrch/ui + ref: ${{ env.UI_SOURCE_REF }} + path: ui + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.2.0 + with: + go-version: "1.22" + cache-dependency-path: cli/go.sum + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "20" + + - name: Build candidate release set + shell: bash + run: | + docker build --tag kubeorch/core:community-candidate ./core + docker build --tag kubeorch/ui:community-candidate ./ui + CGO_ENABLED=0 go -C cli build \ + -ldflags "-s -w -X github.com/kubeorch/cli/cmd.version=candidate-${GITHUB_SHA::12}" \ + -o "$RUNNER_TEMP/orchcli" . + + - name: Run Community candidate smoke + shell: bash + env: + KUBEORCH_CORE_IMAGE: kubeorch/core:community-candidate + KUBEORCH_UI_IMAGE: kubeorch/ui:community-candidate + KUBEORCH_SMOKE_MODE: candidate + KUBEORCH_EVIDENCE_DIR: ${{ github.workspace }}/cli/output/playwright/community-smoke-linux-${{ matrix.arch }} + run: bash cli/.github/scripts/community-smoke.sh "$RUNNER_TEMP/orchcli" "${{ matrix.arch }}" + + - name: Upload candidate evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: candidate-runtime-linux-${{ matrix.arch }} + path: cli/output/playwright/community-smoke-linux-${{ matrix.arch }}/ + if-no-files-found: warn + + published-runtime: + name: Published runtime (linux/${{ matrix.arch }}) + if: github.event_name == 'workflow_dispatch' + needs: installer + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + steps: + - name: Checkout CLI + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "20" + + - name: Install published CLI + shell: bash + env: + CLI_VERSION: ${{ inputs.cli_version }} + run: | + install_dir="$RUNNER_TEMP/orchcli-published" + mkdir -p "$install_dir" + ORCHCLI_VERSION="$CLI_VERSION" \ + ORCHCLI_INSTALL_DIR="$install_dir" \ + ORCHCLI_NO_SUDO=1 \ + sh ./install.sh + echo "ORCHCLI_BIN=$install_dir/orchcli" >> "$GITHUB_ENV" + + - name: Run published Community smoke + shell: bash + env: + KUBEORCH_CORE_IMAGE: ${{ inputs.core_image }} + KUBEORCH_UI_IMAGE: ${{ inputs.ui_image }} + KUBEORCH_SMOKE_MODE: published + KUBEORCH_EVIDENCE_DIR: ${{ github.workspace }}/output/playwright/community-smoke-linux-${{ matrix.arch }} + run: bash .github/scripts/community-smoke.sh "$ORCHCLI_BIN" "${{ matrix.arch }}" + + - name: Upload published evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: published-runtime-linux-${{ matrix.arch }} + path: output/playwright/community-smoke-linux-${{ matrix.arch }}/ + if-no-files-found: error diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 41ea92e..aa6badf 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -15,23 +15,24 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.2.0 with: go-version: '1.22' - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' - - name: Ensure latest npm (OIDC requires npm 11.5.1+) - run: npm install -g npm@latest + - name: Install OIDC-capable npm + run: npm install -g npm@12.0.2 - name: Get version from tag id: version @@ -47,6 +48,11 @@ jobs: echo "Updated package.json to version $VERSION" cat package.json | grep version + - name: Test release sources + run: | + go test ./... + npm test + - name: Build binaries for all platforms run: | mkdir -p dist @@ -63,6 +69,7 @@ jobs: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o dist/orchcli_linux_amd64 main.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o dist/orchcli_linux_arm64 main.go CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o dist/orchcli_windows_amd64.exe main.go + CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o dist/orchcli_windows_arm64.exe main.go cd dist sha256sum * > checksums.txt @@ -71,7 +78,7 @@ jobs: cd .. - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: dist/* generate_release_notes: true diff --git a/.gitignore b/.gitignore index b1287bd..56a345b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ npm/bin/orchcli-bin npm/bin/*.exe *.tgz package/ +/output/ +/.playwright-cli/ # Environment .env diff --git a/README.md b/README.md index 9c14a79..18ef6a5 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,11 @@ The currently published Core and UI `v0.0.3` images are pinned by digest and are available for AMD64 only. Use source development mode on ARM64 until multi-arch release images are published. +The shell and npm installers verify the selected release asset against the +published `checksums.txt`; they fail instead of compiling an unverified fallback +binary. Release compatibility is validated using the documented +[Community release smoke tests](docs/COMMUNITY-SMOKE.md). + ### Development Mode ```bash # Clone repositories, or adopt checkouts that already exist diff --git a/cmd/docker/docker-compose.dev.yml b/cmd/docker/docker-compose.dev.yml index adcecbb..364142d 100644 --- a/cmd/docker/docker-compose.dev.yml +++ b/cmd/docker/docker-compose.dev.yml @@ -1,6 +1,8 @@ # Development mode - both UI and Core run on host # Only MongoDB runs in Docker +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + services: mongodb: image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a diff --git a/cmd/docker/docker-compose.hybrid-core.yml b/cmd/docker/docker-compose.hybrid-core.yml index dcc38d3..d3b3c1f 100644 --- a/cmd/docker/docker-compose.hybrid-core.yml +++ b/cmd/docker/docker-compose.hybrid-core.yml @@ -1,6 +1,8 @@ # Hybrid Core mode - Core repo cloned, UI from Docker image # MongoDB and UI run in Docker; Core runs on the host. +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge @@ -28,7 +30,7 @@ services: start_period: 10s ui: - image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 + image: ${KUBEORCH_UI_IMAGE:-ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3} container_name: kubeorchestra-ui-hybrid restart: unless-stopped networks: diff --git a/cmd/docker/docker-compose.hybrid-ui.yml b/cmd/docker/docker-compose.hybrid-ui.yml index 0072095..bfc6ec8 100644 --- a/cmd/docker/docker-compose.hybrid-ui.yml +++ b/cmd/docker/docker-compose.hybrid-ui.yml @@ -1,6 +1,8 @@ # Hybrid UI mode - UI runs on host, Core from Docker image # MongoDB and Core run in Docker +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge @@ -28,7 +30,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e + image: ${KUBEORCH_CORE_IMAGE:-ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e} container_name: kubeorchestra-core-hybrid restart: unless-stopped networks: @@ -37,9 +39,16 @@ services: - "3000:3000" environment: KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra + KUBEORCH_CORS_ALLOWED_ORIGINS: ${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:3001} depends_on: mongodb: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --quiet --output-document=- http://127.0.0.1:3000/v1 | grep -q '\"status\":\"success\"'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s # UI runs on host: cd ui && npm install && npm run dev (port 3001) # UI connects to Core at localhost:3000/v1/api diff --git a/cmd/docker/docker-compose.prod.yml b/cmd/docker/docker-compose.prod.yml index 68fb700..22684b7 100644 --- a/cmd/docker/docker-compose.prod.yml +++ b/cmd/docker/docker-compose.prod.yml @@ -1,19 +1,21 @@ +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge - name: kubeorchestra-network + name: ${KUBEORCH_NETWORK_NAME:-kubeorchestra-network} services: mongodb: image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a - container_name: kubeorchestra-mongodb + container_name: ${KUBEORCH_MONGODB_CONTAINER:-kubeorchestra-mongodb} restart: unless-stopped networks: - kubeorchestra-net environment: MONGO_INITDB_DATABASE: kubeorchestra ports: - - "27017:27017" + - "${KUBEORCH_MONGO_PORT:-27017}:27017" expose: - "27017" volumes: @@ -27,38 +29,46 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e - container_name: kubeorchestra-core + image: ${KUBEORCH_CORE_IMAGE:-ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e} + container_name: ${KUBEORCH_CORE_CONTAINER:-kubeorchestra-core} restart: unless-stopped networks: - kubeorchestra-net ports: - - "3000:3000" + - "${KUBEORCH_CORE_PORT:-3000}:3000" expose: - "3000" environment: KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra + KUBEORCH_CORS_ALLOWED_ORIGINS: ${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:3001} depends_on: mongodb: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --quiet --output-document=- http://127.0.0.1:3000/v1 | grep -q '\"status\":\"success\"'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s ui: - image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 - container_name: kubeorchestra-ui + image: ${KUBEORCH_UI_IMAGE:-ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3} + container_name: ${KUBEORCH_UI_CONTAINER:-kubeorchestra-ui} restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3000" + - "${KUBEORCH_UI_PORT:-3001}:3000" expose: - "3000" environment: - NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api + NEXT_PUBLIC_API_URL: ${KUBEORCH_BROWSER_API_URL:-http://localhost:3000/v1/api} depends_on: - - core + core: + condition: service_healthy volumes: mongodb_data: - name: kubeorchestra_mongodb_data + name: ${KUBEORCH_MONGODB_DATA_VOLUME:-kubeorchestra_mongodb_data} mongodb_config: - name: kubeorchestra_mongodb_config + name: ${KUBEORCH_MONGODB_CONFIG_VOLUME:-kubeorchestra_mongodb_config} diff --git a/cmd/init.go b/cmd/init.go index 6e2c850..93f197d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -13,8 +13,10 @@ import ( ) const ( - defaultUIRepo = "KubeOrch/ui" - defaultCoreRepo = "KubeOrch/core" + defaultUIRepo = "KubeOrch/ui" + defaultCoreRepo = "KubeOrch/core" + defaultCoreImage = "ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e" + defaultUIImage = "ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3" ) var ( @@ -148,12 +150,19 @@ func setupProduction() error { fmt.Println("\nāœ… Production environment ready!") fmt.Printf("šŸ“ Project initialized at: %s\n", projectPath) fmt.Println("\nšŸ“ Docker images that will be used:") - fmt.Println(" - ghcr.io/kubeorch/core:v0.0.3 (digest pinned)") - fmt.Println(" - ghcr.io/kubeorch/ui:v0.0.3 (digest pinned)") + fmt.Printf(" - %s\n", configuredEnvironmentValue("KUBEORCH_CORE_IMAGE", defaultCoreImage)) + fmt.Printf(" - %s\n", configuredEnvironmentValue("KUBEORCH_UI_IMAGE", defaultUIImage)) fmt.Println("\n Run 'orchcli start' to start the pinned release images") return nil } +func configuredEnvironmentValue(environmentName, defaultValue string) string { + if configured := strings.TrimSpace(os.Getenv(environmentName)); configured != "" { + return configured + } + return defaultValue +} + type developmentSetup struct { projectPath string uiPath string diff --git a/cmd/runtime_contract_test.go b/cmd/runtime_contract_test.go index 86ffe4f..4ec5475 100644 --- a/cmd/runtime_contract_test.go +++ b/cmd/runtime_contract_test.go @@ -300,12 +300,29 @@ func TestEmbeddedComposeContract(t *testing.T) { } content := string(prod) for _, expected := range []string{ + "name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra}", "KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra", - `- "3001:3000"`, - "NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api", + "${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:3001}", + `${KUBEORCH_UI_PORT:-3001}:3000`, + "${KUBEORCH_BROWSER_API_URL:-http://localhost:3000/v1/api}", + "${KUBEORCH_CORE_IMAGE:-" + defaultCoreImage + "}", + "${KUBEORCH_UI_IMAGE:-" + defaultUIImage + "}", + "http://127.0.0.1:3000/v1", } { if !strings.Contains(content, expected) { t.Fatalf("production Compose is missing %q", expected) } } } + +func TestConfiguredEnvironmentValue(t *testing.T) { + t.Setenv("KUBEORCH_TEST_IMAGE", " ghcr.io/kubeorch/test@sha256:abc ") + if actual := configuredEnvironmentValue("KUBEORCH_TEST_IMAGE", "default"); actual != "ghcr.io/kubeorch/test@sha256:abc" { + t.Fatalf("unexpected configured image: %q", actual) + } + + t.Setenv("KUBEORCH_TEST_IMAGE", " ") + if actual := configuredEnvironmentValue("KUBEORCH_TEST_IMAGE", "default"); actual != "default" { + t.Fatalf("expected default image, got %q", actual) + } +} diff --git a/cmd/start.go b/cmd/start.go index ef55b30..18c4ed9 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -94,7 +94,7 @@ func runStart(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Println("ā³ waiting for mongodb to be ready...") - if err := waitForMongoDB(); err != nil { + if err := waitForMongoDB(projectConfig.Path, composeFile); err != nil { fmt.Printf("āš ļø warning: %v\n", err) fmt.Println(" services may take a moment to be fully ready") } else { @@ -129,9 +129,9 @@ func runStart(cmd *cobra.Command, args []string) error { fmt.Println(" mongodb: localhost:27017 (docker)") default: fmt.Println("šŸ“Š all services running in docker:") - fmt.Println(" ui: http://localhost:3001") - fmt.Println(" api: http://localhost:3000/v1/api") - fmt.Println(" mongodb: localhost:27017") + fmt.Printf(" ui: http://localhost:%s\n", configuredEnvironmentValue("KUBEORCH_UI_PORT", "3001")) + fmt.Printf(" api: http://localhost:%s/v1/api\n", configuredEnvironmentValue("KUBEORCH_CORE_PORT", "3000")) + fmt.Printf(" mongodb: localhost:%s\n", configuredEnvironmentValue("KUBEORCH_MONGO_PORT", "27017")) } fmt.Println() @@ -143,21 +143,21 @@ func runStart(cmd *cobra.Command, args []string) error { return nil } -func waitForMongoDB() error { - maxRetries := 30 - containerNames := []string{ - "kubeorchestra-mongodb", - "kubeorchestra-mongodb-dev", - "kubeorchestra-mongodb-hybrid", - } - - for i := 0; i < maxRetries; i++ { - for _, name := range containerNames { - // #nosec G204 -- name is from a hardcoded list of known container names - cmd := exec.Command("docker", "exec", name, "mongosh", "--eval", "db.adminCommand('ping')") - if err := cmd.Run(); err == nil { - return nil - } +func waitForMongoDB(projectPath, composeFile string) error { + dockerCompose := getDockerComposeCommand() + args := append([]string{}, dockerCompose...) + args = append(args, + "-f", composeFile, + "exec", "-T", "mongodb", + "mongosh", "--eval", "db.adminCommand('ping')", + ) + + for i := 0; i < 30; i++ { + // #nosec G204 -- the executable is selected from hardcoded Docker Compose command names. + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = projectPath + if err := cmd.Run(); err == nil { + return nil } time.Sleep(time.Second) diff --git a/cmd/status.go b/cmd/status.go index 58f65be..f0b3023 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -66,35 +66,16 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Println(string(psOutput)) fmt.Println("šŸ’¾ database status:") - dbCheckCmd := exec.CommandContext( - commandContext, - "docker", - "exec", - "kubeorchestra-mongodb", - "mongosh", - "--eval", - "db.adminCommand('ping')", + dbArgs := append([]string{}, dockerCompose...) + dbArgs = append(dbArgs, + "-f", composeFile, + "exec", "-T", "mongodb", + "mongosh", "--eval", "db.adminCommand('ping')", ) + // #nosec G204 -- the executable is selected from hardcoded Docker Compose command names. + dbCheckCmd := exec.CommandContext(commandContext, dbArgs[0], dbArgs[1:]...) + dbCheckCmd.Dir = projectConfig.Path dbOutput, dbErr := dbCheckCmd.Output() - if dbErr != nil { - for _, name := range []string{"kubeorchestra-mongodb-dev", "kubeorchestra-mongodb-hybrid"} { - // #nosec G204 -- name is selected from the hardcoded container names above. - altCmd := exec.CommandContext( - commandContext, - "docker", - "exec", - name, - "mongosh", - "--eval", - "db.adminCommand('ping')", - ) - if output, err := altCmd.Output(); err == nil { - dbOutput = output - dbErr = nil - break - } - } - } if dbErr != nil { fmt.Println(" āŒ mongodb is not healthy or not running") @@ -109,13 +90,16 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Println("🩺 application status:") - printApplicationStatus(commandContext) + corePort := configuredEnvironmentValue("KUBEORCH_CORE_PORT", "3000") + uiPort := configuredEnvironmentValue("KUBEORCH_UI_PORT", "3001") + mongoPort := configuredEnvironmentValue("KUBEORCH_MONGO_PORT", "27017") + printApplicationStatus(commandContext, corePort, uiPort) fmt.Println() fmt.Println("🌐 service endpoints:") - fmt.Println(" ui: http://localhost:3001") - fmt.Println(" api: http://localhost:3000/v1/api") - fmt.Println(" mongodb: localhost:27017") + fmt.Printf(" ui: http://localhost:%s\n", uiPort) + fmt.Printf(" api: http://localhost:%s/v1/api\n", corePort) + fmt.Printf(" mongodb: localhost:%s\n", mongoPort) fmt.Println() fmt.Println("šŸ’” tips:") @@ -126,13 +110,13 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } -func printApplicationStatus(ctx context.Context) { +func printApplicationStatus(ctx context.Context, corePort, uiPort string) { checks := []struct { name string url string }{ - {name: "core", url: "http://localhost:3000/v1/"}, - {name: "ui", url: "http://localhost:3001/"}, + {name: "core", url: "http://localhost:" + corePort + "/v1"}, + {name: "ui", url: "http://localhost:" + uiPort + "/"}, } type healthResult struct { err error diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index adcecbb..364142d 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,6 +1,8 @@ # Development mode - both UI and Core run on host # Only MongoDB runs in Docker +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + services: mongodb: image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a diff --git a/docker/docker-compose.hybrid-core.yml b/docker/docker-compose.hybrid-core.yml index dcc38d3..d3b3c1f 100644 --- a/docker/docker-compose.hybrid-core.yml +++ b/docker/docker-compose.hybrid-core.yml @@ -1,6 +1,8 @@ # Hybrid Core mode - Core repo cloned, UI from Docker image # MongoDB and UI run in Docker; Core runs on the host. +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge @@ -28,7 +30,7 @@ services: start_period: 10s ui: - image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 + image: ${KUBEORCH_UI_IMAGE:-ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3} container_name: kubeorchestra-ui-hybrid restart: unless-stopped networks: diff --git a/docker/docker-compose.hybrid-ui.yml b/docker/docker-compose.hybrid-ui.yml index 0072095..bfc6ec8 100644 --- a/docker/docker-compose.hybrid-ui.yml +++ b/docker/docker-compose.hybrid-ui.yml @@ -1,6 +1,8 @@ # Hybrid UI mode - UI runs on host, Core from Docker image # MongoDB and Core run in Docker +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge @@ -28,7 +30,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e + image: ${KUBEORCH_CORE_IMAGE:-ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e} container_name: kubeorchestra-core-hybrid restart: unless-stopped networks: @@ -37,9 +39,16 @@ services: - "3000:3000" environment: KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra + KUBEORCH_CORS_ALLOWED_ORIGINS: ${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:3001} depends_on: mongodb: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --quiet --output-document=- http://127.0.0.1:3000/v1 | grep -q '\"status\":\"success\"'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s # UI runs on host: cd ui && npm install && npm run dev (port 3001) # UI connects to Core at localhost:3000/v1/api diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 68fb700..22684b7 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,19 +1,21 @@ +name: ${KUBEORCH_COMPOSE_PROJECT:-kubeorchestra} + networks: kubeorchestra-net: driver: bridge - name: kubeorchestra-network + name: ${KUBEORCH_NETWORK_NAME:-kubeorchestra-network} services: mongodb: image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a - container_name: kubeorchestra-mongodb + container_name: ${KUBEORCH_MONGODB_CONTAINER:-kubeorchestra-mongodb} restart: unless-stopped networks: - kubeorchestra-net environment: MONGO_INITDB_DATABASE: kubeorchestra ports: - - "27017:27017" + - "${KUBEORCH_MONGO_PORT:-27017}:27017" expose: - "27017" volumes: @@ -27,38 +29,46 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e - container_name: kubeorchestra-core + image: ${KUBEORCH_CORE_IMAGE:-ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e} + container_name: ${KUBEORCH_CORE_CONTAINER:-kubeorchestra-core} restart: unless-stopped networks: - kubeorchestra-net ports: - - "3000:3000" + - "${KUBEORCH_CORE_PORT:-3000}:3000" expose: - "3000" environment: KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra + KUBEORCH_CORS_ALLOWED_ORIGINS: ${KUBEORCH_CORS_ALLOWED_ORIGINS:-http://localhost:3001} depends_on: mongodb: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --quiet --output-document=- http://127.0.0.1:3000/v1 | grep -q '\"status\":\"success\"'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s ui: - image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 - container_name: kubeorchestra-ui + image: ${KUBEORCH_UI_IMAGE:-ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3} + container_name: ${KUBEORCH_UI_CONTAINER:-kubeorchestra-ui} restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3000" + - "${KUBEORCH_UI_PORT:-3001}:3000" expose: - "3000" environment: - NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api + NEXT_PUBLIC_API_URL: ${KUBEORCH_BROWSER_API_URL:-http://localhost:3000/v1/api} depends_on: - - core + core: + condition: service_healthy volumes: mongodb_data: - name: kubeorchestra_mongodb_data + name: ${KUBEORCH_MONGODB_DATA_VOLUME:-kubeorchestra_mongodb_data} mongodb_config: - name: kubeorchestra_mongodb_config + name: ${KUBEORCH_MONGODB_CONFIG_VOLUME:-kubeorchestra_mongodb_config} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cbb3ef..3335d66 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -59,6 +59,14 @@ KubeOrch Core and UI `v0.0.3` images contain AMD64 manifests only; production and hybrid modes on ARM64 remain dependent on new multi-arch component releases. Full source development works independently of those release images. +Release validation may set `KUBEORCH_CORE_IMAGE` and `KUBEORCH_UI_IMAGE` to +test an explicit candidate or digest-pinned compatibility set. These overrides +are not a user-facing version-selection mechanism. See +[Community Release Smoke Tests](COMMUNITY-SMOKE.md). + +Release smokes also set isolated Compose project, network, port, and volume +names. Normal commands retain the documented defaults. + ## Project Discovery Every project-scoped command resolves the nearest `.kubeorch/project.json` diff --git a/docs/COMMUNITY-SMOKE.md b/docs/COMMUNITY-SMOKE.md new file mode 100644 index 0000000..568f03a --- /dev/null +++ b/docs/COMMUNITY-SMOKE.md @@ -0,0 +1,64 @@ +# Community Release Smoke Tests + +The Community release gate validates one CLI, Core, and UI release set together. +It covers installation, generated Compose configuration, service health, and a +real browser request from UI to Core. + +## Evidence Levels + +Pull requests run candidate validation on native Linux AMD64 and Linux ARM64 +runners. The workflow builds Core and UI from the commits recorded in +`.github/workflows/community-smoke.yml`, builds the candidate CLI, and exercises: + +```text +orchcli init -> orchcli start -d -> orchcli status -> browser request -> orchcli stop +``` + +The same workflow tests the shell and npm installers on Linux AMD64 and hosted +macOS ARM64 against an existing published CLI release. Both paths must select +the host-native release asset and verify its SHA256 entry from `checksums.txt`. + +After compatible component releases exist, run **Community Release Smoke** +manually with: + +- the published CLI tag; +- the digest-pinned Core multi-architecture image; and +- the digest-pinned UI multi-architecture image. + +Published mode rejects mutable image references and verifies that both indexes +contain `linux/amd64` and `linux/arm64`. Its evidence is uploaded separately for +each native Linux architecture. The supplied image references are expectations: +published mode unsets the candidate overrides and fails unless the released CLI +generates those exact defaults. + +## Apple Silicon Runtime Gate + +GitHub-hosted ARM64 macOS runners do not support nested virtualization, so they +cannot run Docker Desktop or the full Community Compose stack. They still run +the native and npm installation checks. The complete macOS ARM64 gate must run +on a physical Apple Silicon host with Docker Desktop, Node.js, and `npx`: + +```bash +export KUBEORCH_CORE_IMAGE='ghcr.io/kubeorch/core:vX.Y.Z@sha256:' +export KUBEORCH_UI_IMAGE='ghcr.io/kubeorch/ui:vX.Y.Z@sha256:' +export KUBEORCH_SMOKE_MODE=published +export KUBEORCH_EVIDENCE_DIR="$PWD/output/playwright/community-smoke-darwin-arm64" + +bash .github/scripts/community-smoke.sh "$(command -v orchcli)" arm64 +``` + +The script always tears down the Compose stack. On failure it preserves bounded +container diagnostics. On success it records `evidence.json`, CLI output, +resolved images, status output, and Playwright request evidence under the chosen +evidence directory. These files do not contain credentials. + +## Image Overrides + +Generated production and hybrid Compose files retain immutable defaults. +`KUBEORCH_CORE_IMAGE` and `KUBEORCH_UI_IMAGE` exist only so release engineering +can validate candidate or newly published component images before changing +those defaults. Normal installations should not set them. + +The default pins are updated only after all published-mode evidence passes for +the compatibility set. The release issue must record the exact CLI version, +component digests, host architecture, duration, and evidence run URLs. diff --git a/install.sh b/install.sh index 007fbd1..4c55bd3 100755 --- a/install.sh +++ b/install.sh @@ -15,6 +15,7 @@ set -e GITHUB_REPO="KubeOrch/cli" BINARY_NAME="orchcli" DEFAULT_INSTALL_DIR="/usr/local/bin" +TEMP_DIR="" # Colors for output RED='\033[0;31m' @@ -36,6 +37,18 @@ warning() { printf "${YELLOW}[WARN]${NC} %s\n" "$1" } +cleanup_temp() { + if [ -n "${TEMP_DIR:-}" ] && [ -d "$TEMP_DIR" ]; then + rm -f "$TEMP_DIR/$BINARY_NAME" "$TEMP_DIR/checksums.txt" + rmdir "$TEMP_DIR" 2>/dev/null || true + fi +} + +trap cleanup_temp EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + # Detect OS and architecture detect_platform() { OS=$(uname -s | tr '[:upper:]' '[:lower:]') @@ -87,46 +100,81 @@ get_latest_version() { info "Using specified version: $VERSION" else info "Fetching latest version..." - VERSION=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') - + if command -v curl >/dev/null 2>&1; then + RELEASE_JSON=$(curl -fsSL --retry 3 \ + "https://api.github.com/repos/${GITHUB_REPO}/releases/latest") + elif command -v wget >/dev/null 2>&1; then + RELEASE_JSON=$(wget -q -O - \ + "https://api.github.com/repos/${GITHUB_REPO}/releases/latest") + else + error "Neither curl nor wget found. Please install one of them." + exit 1 + fi + VERSION=$(printf '%s\n' "$RELEASE_JSON" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "$VERSION" ]; then error "Failed to fetch latest version" exit 1 fi info "Latest version: $VERSION" fi + + case "$VERSION" in + v*) ;; + *) VERSION="v$VERSION" ;; + esac } -# Download binary from GitHub releases -download_binary() { - DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}/orchcli_${BINARY_SUFFIX}" - TEMP_DIR=$(mktemp -d) - TEMP_BINARY="$TEMP_DIR/$BINARY_NAME" - - info "Downloading OrchCLI from: $DOWNLOAD_URL" - +download_file() { + source_url="$1" + destination="$2" + if command -v curl >/dev/null 2>&1; then - curl -L -o "$TEMP_BINARY" "$DOWNLOAD_URL" || { - error "Failed to download binary" - rm -rf "$TEMP_DIR" - exit 1 - } + curl -fsSL --retry 3 --output "$destination" "$source_url" elif command -v wget >/dev/null 2>&1; then - wget -q -O "$TEMP_BINARY" "$DOWNLOAD_URL" || { - error "Failed to download binary" - rm -rf "$TEMP_DIR" - exit 1 - } + wget -q -O "$destination" "$source_url" else error "Neither curl nor wget found. Please install one of them." - rm -rf "$TEMP_DIR" exit 1 fi +} + +sha256_file() { + target="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$target" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$target" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$target" | awk '{print $NF}' + else + error "A SHA256 tool is required (sha256sum, shasum, or openssl)." + exit 1 + fi +} + +# Download and verify the binary from GitHub Releases. +download_binary() { + BINARY_ASSET="orchcli_${BINARY_SUFFIX}" + RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/download/${VERSION}" + DOWNLOAD_URL="${RELEASE_URL}/${BINARY_ASSET}" + TEMP_DIR=$(mktemp -d) + TEMP_BINARY="$TEMP_DIR/$BINARY_NAME" + TEMP_CHECKSUMS="$TEMP_DIR/checksums.txt" + + info "Downloading OrchCLI from: $DOWNLOAD_URL" + download_file "${RELEASE_URL}/checksums.txt" "$TEMP_CHECKSUMS" || { + error "Failed to download checksums.txt for $VERSION" + exit 1 + } + download_file "$DOWNLOAD_URL" "$TEMP_BINARY" || { + error "Failed to download $BINARY_ASSET" + exit 1 + } # Check if download was successful and file is valid if [ ! -f "$TEMP_BINARY" ]; then error "Download failed - file not created" - rm -rf "$TEMP_DIR" exit 1 fi @@ -134,11 +182,26 @@ download_binary() { if [ "$FILE_SIZE" -lt 1000 ]; then error "Downloaded file is too small ($FILE_SIZE bytes) - possibly a 404 error page" error "The release $VERSION may not have binaries uploaded yet" - rm -rf "$TEMP_DIR" exit 1 fi - + + EXPECTED_CHECKSUM=$(awk -v asset="$BINARY_ASSET" \ + '$2 == asset || $2 == "*" asset { print tolower($1); exit }' \ + "$TEMP_CHECKSUMS") + if [ -z "$EXPECTED_CHECKSUM" ]; then + error "checksums.txt does not contain $BINARY_ASSET" + exit 1 + fi + ACTUAL_CHECKSUM=$(sha256_file "$TEMP_BINARY" | tr '[:upper:]' '[:lower:]') + if [ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]; then + error "Checksum mismatch for $BINARY_ASSET" + error "Expected: $EXPECTED_CHECKSUM" + error "Received: $ACTUAL_CHECKSUM" + exit 1 + fi + chmod +x "$TEMP_BINARY" + info "Verified SHA256 checksum: $ACTUAL_CHECKSUM" info "Binary downloaded successfully (size: $FILE_SIZE bytes)" } @@ -156,7 +219,6 @@ install_binary() { else error "Cannot write to $INSTALL_DIR and sudo is not available" error "Try running as root or set ORCHCLI_INSTALL_DIR to a writable location" - rm -rf "$TEMP_DIR" exit 1 fi fi @@ -172,8 +234,8 @@ install_binary() { $SUDO mv "$TEMP_BINARY" "$INSTALL_DIR/$BINARY_NAME" $SUDO chmod +x "$INSTALL_DIR/$BINARY_NAME" - # Clean up - rm -rf "$TEMP_DIR" + # Clean up the checksum file and now-empty temporary directory. + cleanup_temp # Verify installation if [ -f "$INSTALL_DIR/$BINARY_NAME" ]; then @@ -250,4 +312,4 @@ main() { } # Run main function -main "$@" \ No newline at end of file +main "$@" diff --git a/npm/scripts/postinstall.js b/npm/scripts/postinstall.js index afc41e6..5022051 100755 --- a/npm/scripts/postinstall.js +++ b/npm/scripts/postinstall.js @@ -1,172 +1,262 @@ #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const { execSync } = require('child_process'); -const os = require('os'); +const crypto = require("crypto"); +const fs = require("fs"); +const https = require("https"); +const os = require("os"); +const path = require("path"); -const REPO = 'KubeOrch/cli'; -const VERSION = require('../../package.json').version; - -function getPlatform() { - const platform = os.platform(); - const arch = os.arch(); +const REPO = "KubeOrch/cli"; +const MAX_REDIRECTS = 5; +const MIN_BINARY_SIZE = 1000; +function getPlatform(platform = os.platform(), arch = os.arch()) { const platformMap = { - 'darwin': 'darwin', - 'linux': 'linux', - 'win32': 'windows' + darwin: "darwin", + linux: "linux", + win32: "windows", }; - const archMap = { - 'x64': 'amd64', - 'arm64': 'arm64' + x64: "amd64", + arm64: "arm64", }; const mappedPlatform = platformMap[platform]; const mappedArch = archMap[arch]; - if (!mappedPlatform || !mappedArch) { throw new Error(`Unsupported platform: ${platform}-${arch}`); } - return { platform: mappedPlatform, arch: mappedArch }; } -function getBinaryExt() { - return os.platform() === 'win32' ? '.exe' : ''; +function getBinaryName(platform, arch) { + const extension = platform === "windows" ? ".exe" : ""; + return `orchcli_${platform}_${arch}${extension}`; } -function downloadBinary(url, dest) { - return new Promise((resolve, reject) => { - console.log(`Downloading OrchCLI binary from ${url}...`); +function parseChecksums(content) { + const checksums = new Map(); + for (const line of content.split(/\r?\n/)) { + const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} - function followRedirects(currentUrl, redirectCount) { - if (redirectCount > 5) { - reject(new Error('Too many redirects')); - return; - } +function sha256File(filename) { + const hash = crypto.createHash("sha256"); + hash.update(fs.readFileSync(filename)); + return hash.digest("hex"); +} - https.get(currentUrl, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { +function verifyChecksum(filename, binaryName, checksums) { + const expected = checksums.get(binaryName); + if (!expected) { + throw new Error(`checksums.txt does not contain ${binaryName}`); + } + const actual = sha256File(filename); + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${binaryName}: expected ${expected}, received ${actual}`, + ); + } + return actual; +} + +function request(url, onResponse, redirectCount = 0) { + if (redirectCount > MAX_REDIRECTS) { + return Promise.reject( + new Error(`Too many redirects while downloading ${url}`), + ); + } + + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:") { + reject(new Error(`Refusing non-HTTPS download URL: ${url}`)); + return; + } + const req = https.get( + parsedUrl, + { headers: { "User-Agent": "kubeorch-cli-npm-installer" } }, + (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + const location = response.headers.location; response.resume(); - followRedirects(response.headers.location, redirectCount + 1); - } else if (response.statusCode === 200) { - const file = fs.createWriteStream(dest); - response.pipe(file); - file.on('finish', () => { - file.close(resolve); - }); - file.on('error', (err) => { - fs.unlink(dest, () => {}); - reject(err); - }); - } else { + if (!location) { + reject( + new Error(`Redirect from ${url} did not include a location`), + ); + return; + } + resolve( + request( + new URL(location, parsedUrl).toString(), + onResponse, + redirectCount + 1, + ), + ); + return; + } + if (response.statusCode !== 200) { response.resume(); - reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + reject( + new Error(`Failed to download ${url}: HTTP ${response.statusCode}`), + ); + return; } - }).on('error', reject); - } - - followRedirects(url, 0); + resolve(onResponse(response)); + }, + ); + req.setTimeout(30000, () => + req.destroy(new Error(`Timed out downloading ${url}`)), + ); + req.on("error", reject); }); } -function buildFromSource() { - console.log('Building OrchCLI from source...'); +function downloadText(url) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")), + ); + response.on("error", reject); + }), + ); +} - const ext = getBinaryExt(); - const projectRoot = path.join(__dirname, '..', '..'); - const buildOutput = path.join(projectRoot, `orchcli${ext}`); - const binPath = path.join(__dirname, '..', 'bin', `orchcli-bin${ext}`); +function downloadFile(url, destination) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const file = fs.createWriteStream(destination, { flags: "wx" }); + response.pipe(file); + response.on("aborted", () => + reject(new Error(`Download aborted for ${url}`)), + ); + response.on("error", reject); + file.on("finish", () => file.close(resolve)); + file.on("error", (error) => { + response.destroy(); + reject(error); + }); + }), + ); +} + +function replaceFile(temporaryPath, destination) { + if (!fs.existsSync(destination)) { + fs.renameSync(temporaryPath, destination); + return; + } try { - execSync('go version', { stdio: 'pipe' }); - - console.log('Running go build...'); - const version = require('../../package.json').version; - const buildDate = new Date().toISOString(); - const ldflags = `-X 'github.com/kubeorch/cli/cmd.version=${version}' -X 'github.com/kubeorch/cli/cmd.buildDate=${buildDate}'`; - - execSync(`go build -ldflags "${ldflags}" -o "${buildOutput}" main.go`, { - cwd: projectRoot, - stdio: 'inherit' - }); - - if (fs.existsSync(buildOutput)) { - fs.renameSync(buildOutput, binPath); - if (os.platform() !== 'win32') { - fs.chmodSync(binPath, '755'); - } - console.log('OrchCLI built successfully from source!'); - return true; - } + fs.renameSync(temporaryPath, destination); + return; } catch (error) { - console.log('Failed to build from source:', error.message); - return false; + if ( + error.code !== "EEXIST" && + error.code !== "EPERM" && + error.code !== "EACCES" + ) { + throw error; + } } - return false; + const backupPath = `${destination}.previous-${process.pid}`; + fs.rmSync(backupPath, { force: true }); + fs.renameSync(destination, backupPath); + try { + fs.renameSync(temporaryPath, destination); + fs.rmSync(backupPath, { force: true }); + } catch (error) { + fs.renameSync(backupPath, destination); + throw error; + } } -async function install() { - const ext = getBinaryExt(); - const binDir = path.join(__dirname, '..', 'bin'); - const binPath = path.join(binDir, `orchcli-bin${ext}`); - - // Skip if binary already exists and is valid - if (fs.existsSync(binPath)) { - const stats = fs.statSync(binPath); - if (stats.size > 1000) { - console.log('OrchCLI binary already exists, skipping download.'); - return; - } - // Remove invalid binary - fs.unlinkSync(binPath); - } +async function install(options = {}) { + const version = options.version || require("../../package.json").version; + const detected = options.detected || getPlatform(); + const binaryName = getBinaryName(detected.platform, detected.arch); + const extension = detected.platform === "windows" ? ".exe" : ""; + const binDir = options.binDir || path.join(__dirname, "..", "bin"); + const binPath = path.join(binDir, `orchcli-bin${extension}`); + const temporaryPath = `${binPath}.download-${process.pid}`; + const releaseBaseUrl = + options.releaseBaseUrl || + `https://github.com/${REPO}/releases/download/v${version}`; + const fetchText = options.downloadText || downloadText; + const fetchFile = options.downloadFile || downloadFile; - if (!fs.existsSync(binDir)) { - fs.mkdirSync(binDir, { recursive: true }); - } + fs.mkdirSync(binDir, { recursive: true }); + fs.rmSync(temporaryPath, { force: true }); try { - const { platform, arch } = getPlatform(); - const winExt = platform === 'windows' ? '.exe' : ''; - const binaryName = `orchcli_${platform}_${arch}${winExt}`; - const downloadUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${binaryName}`; - - await downloadBinary(downloadUrl, binPath); - - // Validate download - const stats = fs.statSync(binPath); - if (stats.size < 1000) { - fs.unlinkSync(binPath); - throw new Error(`Downloaded file is too small (${stats.size} bytes) - release v${VERSION} may not have binaries`); + console.log( + `Verifying OrchCLI v${version} for ${detected.platform}/${detected.arch}...`, + ); + const checksums = parseChecksums( + await fetchText(`${releaseBaseUrl}/checksums.txt`), + ); + + if (fs.existsSync(binPath)) { + try { + verifyChecksum(binPath, binaryName, checksums); + console.log(`OrchCLI v${version} is already installed and verified.`); + return { binaryName, binPath }; + } catch { + // Replace an incomplete or stale binary only after its replacement verifies. + } } - if (os.platform() !== 'win32') { - fs.chmodSync(binPath, '755'); + await fetchFile(`${releaseBaseUrl}/${binaryName}`, temporaryPath); + const size = fs.statSync(temporaryPath).size; + if (size < MIN_BINARY_SIZE) { + throw new Error( + `Downloaded ${binaryName} is unexpectedly small (${size} bytes)`, + ); } - console.log(`OrchCLI v${VERSION} installed successfully!`); - } catch (error) { - console.log('Failed to download pre-built binary:', error.message); - console.log('Attempting to build from source...'); - - if (!buildFromSource()) { - console.error('\n==============================================='); - console.error('Failed to install OrchCLI automatically.'); - console.error('Please install manually:'); - console.error(' curl -sfL https://kubeorch.dev/install.sh | sh'); - console.error('Or download from:'); - console.error(` https://github.com/${REPO}/releases`); - console.error('===============================================\n'); - process.exit(1); + verifyChecksum(temporaryPath, binaryName, checksums); + + if (detected.platform !== "windows") { + fs.chmodSync(temporaryPath, 0o755); } + replaceFile(temporaryPath, binPath); + console.log( + `OrchCLI v${version} installed with a verified SHA256 checksum.`, + ); + return { binaryName, binPath }; + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw new Error( + `Unable to install the published OrchCLI v${version} binary for ` + + `${detected.platform}/${detected.arch}: ${error.message}`, + ); } } -install().catch(error => { - console.error('Installation failed:', error); - process.exit(1); -}); +if (require.main === module) { + install().catch((error) => { + console.error(error.message); + console.error(`Release assets: https://github.com/${REPO}/releases`); + process.exit(1); + }); +} + +module.exports = { + getBinaryName, + getPlatform, + install, + parseChecksums, + sha256File, + verifyChecksum, +}; diff --git a/npm/scripts/postinstall.test.js b/npm/scripts/postinstall.test.js new file mode 100644 index 0000000..514db5e --- /dev/null +++ b/npm/scripts/postinstall.test.js @@ -0,0 +1,113 @@ +const assert = require("assert"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const test = require("node:test"); + +const { + getBinaryName, + getPlatform, + install, + parseChecksums, + verifyChecksum, +} = require("./postinstall"); + +test("maps supported npm platforms to release asset names", () => { + assert.deepStrictEqual(getPlatform("linux", "x64"), { + platform: "linux", + arch: "amd64", + }); + assert.deepStrictEqual(getPlatform("darwin", "arm64"), { + platform: "darwin", + arch: "arm64", + }); + assert.strictEqual(getBinaryName("linux", "amd64"), "orchcli_linux_amd64"); + assert.strictEqual( + getBinaryName("windows", "arm64"), + "orchcli_windows_arm64.exe", + ); +}); + +test("rejects unsupported platforms and architectures", () => { + assert.throws(() => getPlatform("freebsd", "x64"), /Unsupported platform/); + assert.throws(() => getPlatform("linux", "ppc64"), /Unsupported platform/); +}); + +test("parses GNU and BSD checksum formats", () => { + const first = "a".repeat(64); + const second = "b".repeat(64); + const parsed = parseChecksums( + `${first} orchcli_linux_amd64\n${second} *orchcli_darwin_arm64\r\n`, + ); + assert.strictEqual(parsed.get("orchcli_linux_amd64"), first); + assert.strictEqual(parsed.get("orchcli_darwin_arm64"), second); +}); + +test("verifies the selected release asset checksum", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "orchcli-checksum-")); + const binary = path.join(dir, "orchcli"); + fs.writeFileSync(binary, "verified bytes"); + const digest = crypto + .createHash("sha256") + .update("verified bytes") + .digest("hex"); + const checksums = new Map([["orchcli_linux_amd64", digest]]); + + assert.strictEqual( + verifyChecksum(binary, "orchcli_linux_amd64", checksums), + digest, + ); + assert.throws( + () => verifyChecksum(binary, "orchcli_darwin_arm64", checksums), + /does not contain/, + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("installs only a binary matching the published checksum", async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "orchcli-install-")); + const bytes = Buffer.alloc(2048, 7); + const digest = crypto.createHash("sha256").update(bytes).digest("hex"); + + const result = await install({ + version: "9.9.9", + detected: { platform: "linux", arch: "amd64" }, + binDir, + downloadText: async () => `${digest} orchcli_linux_amd64\n`, + downloadFile: async (_url, destination) => + fs.writeFileSync(destination, bytes), + }); + + assert.strictEqual(result.binaryName, "orchcli_linux_amd64"); + assert.deepStrictEqual(fs.readFileSync(result.binPath), bytes); + fs.rmSync(binDir, { recursive: true, force: true }); +}); + +test("removes a corrupt download and preserves an existing binary", async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "orchcli-corrupt-")); + const binPath = path.join(binDir, "orchcli-bin"); + const existing = Buffer.alloc(2048, 3); + const expected = Buffer.alloc(2048, 4); + const digest = crypto.createHash("sha256").update(expected).digest("hex"); + fs.writeFileSync(binPath, existing); + + await assert.rejects( + install({ + version: "9.9.9", + detected: { platform: "linux", arch: "amd64" }, + binDir, + downloadText: async () => `${digest} orchcli_linux_amd64\n`, + downloadFile: async (_url, destination) => + fs.writeFileSync(destination, Buffer.alloc(2048, 5)), + }), + /Checksum mismatch/, + ); + + assert.deepStrictEqual(fs.readFileSync(binPath), existing); + assert.strictEqual( + fs.existsSync(`${binPath}.download-${process.pid}`), + false, + ); + fs.rmSync(binDir, { recursive: true, force: true }); +}); diff --git a/package.json b/package.json index 32ce86a..6fb7711 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "postinstall": "node npm/scripts/postinstall.js", "prepack": "node npm/scripts/prepack.js", - "test": "echo \"Tests are run via Go test suite\" && exit 0" + "test": "npm run test:installer", + "test:installer": "node --test npm/scripts/postinstall.test.js" }, "repository": { "type": "git", @@ -49,7 +50,8 @@ }, "files": [ "npm/", + "!npm/**/*.test.js", "README.md", "LICENSE" ] -} \ No newline at end of file +}