diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index bee65cb..f6aa990 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -195,7 +195,7 @@ jobs: find _release -type f -name 'volcano-*' -exec cp {} dist/release-assets/ \; cp scripts/install-volcano.sh dist/release-assets/install.sh chmod +x dist/release-assets/install.sh - bash -n dist/release-assets/install.sh + sh -n dist/release-assets/install.sh - name: Sign installer script run: | diff --git a/Makefile b/Makefile index 9fffcd6..4062e2d 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ LDFLAGS := -s -w \ -X $(CONFIG_PKG).compiledFirstPartyDeviceClientID=$(FIRST_PARTY_DEVICE_CLIENT_ID) \ -X $(LOCALMODE_PKG).defaultVolcanoImage=$(DEFAULT_LOCAL_IMAGE) -.PHONY: all build local test api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help openapi-generate openapi-generated-check +.PHONY: all build local test test-installer api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help openapi-generate openapi-generated-check all: build @@ -50,9 +50,13 @@ local: ## Build volcano using variables loaded from .env.local fi; \ $(MAKE) build -test: ## Run unit tests +test: test-installer ## Run unit tests go test ./... +test-installer: ## Test the release installers + sh scripts/install-volcano.test.sh + node --test scripts/npm/download.test.js + openapi-generate: ## Regenerate the API client from the vendored OpenAPI contract go generate ./internal/apiclient diff --git a/README.md b/README.md index 0acfd54..b3baf2d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ volcano --help Or install manually: ```bash -curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | sh volcano --help ``` diff --git a/docs/installation.md b/docs/installation.md index d42edc8..d7491ce 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -34,10 +34,16 @@ volcano --help Or install manually: ```bash -curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | bash +curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | sh volcano --help ``` +To install the CLI and set up Volcano in detected coding agents: + +```bash +curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | sh -s -- --setup +``` + ## Upgrading `volcano upgrade` upgrades the CLI the same way it was installed: it delegates diff --git a/scripts/install-volcano.sh b/scripts/install-volcano.sh index c346df2..af35f95 100755 --- a/scripts/install-volcano.sh +++ b/scripts/install-volcano.sh @@ -1,5 +1,5 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +set -eu readonly VOLCANO_GITHUB_RELEASES_URL="${VOLCANO_GITHUB_RELEASES_URL:-https://github.com/Kong/volcano-cli/releases}" readonly VOLCANO_DEFAULT_VERSION="latest" @@ -7,6 +7,7 @@ readonly VOLCANO_SIGNATURE_WORKFLOW="https://github.com/Kong/volcano-cli/.github readonly VOLCANO_SIGNATURE_OIDC_ISSUER="https://token.actions.githubusercontent.com" readonly VOLCANO_STABLE_TAG_SIGNATURE_IDENTITY_RE="^https://github[.]com/Kong/volcano-cli/[.]github/workflows/publish-cli[.]yml@refs/tags/v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$" VOLCANO_INSTALL_DIR="${VOLCANO_INSTALL_DIR:-}" +RUN_SETUP=0 fail() { echo "Error: $*" >&2 @@ -17,36 +18,38 @@ have() { command -v "$1" >/dev/null 2>&1 } +is_semver() { + printf '%s\n' "$1" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +} + detect_os() { - local raw - raw="$(uname -s | tr '[:upper:]' '[:lower:]')" - case "$raw" in + detect_os_raw="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$detect_os_raw" in linux*) echo "linux" ;; darwin*) echo "macos" ;; mingw* | msys* | cygwin*) echo "windows" ;; - *) fail "unsupported operating system: ${raw}" ;; + *) fail "unsupported operating system: ${detect_os_raw}" ;; esac } detect_arch() { - local raw - raw="$(uname -m)" - case "$raw" in + detect_arch_raw="$(uname -m)" + case "$detect_arch_raw" in x86_64 | amd64) echo "amd64" ;; arm64 | aarch64) echo "arm64" ;; - *) fail "unsupported architecture: ${raw}" ;; + *) fail "unsupported architecture: ${detect_arch_raw}" ;; esac } download_file() { - local url="$1" - local output="$2" + download_file_url="$1" + download_file_output="$2" if have curl; then - curl --fail --location --silent --show-error "$url" --output "$output" + curl --fail --location --silent --show-error "$download_file_url" --output "$download_file_output" return fi if have wget; then - wget --quiet --output-document="$output" "$url" + wget --quiet --output-document="$download_file_output" "$download_file_url" return fi fail "curl or wget is required to download Volcano CLI" @@ -62,70 +65,65 @@ require_cosign_for_verification() { } verify_signature() { - local file="$1" - local bundle="$2" - local version="$3" - local semver_re - local identity + verify_signature_file="$1" + verify_signature_bundle="$2" + verify_signature_version="$3" if [ "${VOLCANO_SKIP_SIGNATURE_VERIFICATION:-}" = "1" ]; then echo "Skipping Volcano CLI signature verification because VOLCANO_SKIP_SIGNATURE_VERIFICATION=1." return fi - semver_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - case "$version" in + case "$verify_signature_version" in latest) - cosign verify-blob "$file" \ - --bundle "$bundle" \ + cosign verify-blob "$verify_signature_file" \ + --bundle "$verify_signature_bundle" \ --certificate-identity-regexp "$VOLCANO_STABLE_TAG_SIGNATURE_IDENTITY_RE" \ --certificate-oidc-issuer "$VOLCANO_SIGNATURE_OIDC_ISSUER" ;; *) - if [[ "$version" =~ $semver_re ]]; then - identity="${VOLCANO_SIGNATURE_WORKFLOW}@refs/tags/${version}" - cosign verify-blob "$file" \ - --bundle "$bundle" \ - --certificate-identity "$identity" \ + if is_semver "$verify_signature_version"; then + verify_signature_identity="${VOLCANO_SIGNATURE_WORKFLOW}@refs/tags/${verify_signature_version}" + cosign verify-blob "$verify_signature_file" \ + --bundle "$verify_signature_bundle" \ + --certificate-identity "$verify_signature_identity" \ --certificate-oidc-issuer "$VOLCANO_SIGNATURE_OIDC_ISSUER" else - fail "cannot verify signature for unsupported Volcano CLI version selector: ${version}; use latest or vMAJOR.MINOR.PATCH" + fail "cannot verify signature for unsupported Volcano CLI version selector: ${verify_signature_version}; use latest or vMAJOR.MINOR.PATCH" fi ;; esac - echo "Verified Volcano CLI signature for ${version}." + echo "Verified Volcano CLI signature for ${verify_signature_version}." } release_asset_url() { - local version="$1" - local asset="$2" - local semver_re + release_asset_url_version="$1" + release_asset_url_asset="$2" - semver_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - case "$version" in + case "$release_asset_url_version" in latest) - echo "${VOLCANO_GITHUB_RELEASES_URL%/}/latest/download/${asset}" + echo "${VOLCANO_GITHUB_RELEASES_URL%/}/latest/download/${release_asset_url_asset}" ;; *) - if [[ "$version" =~ $semver_re ]]; then - echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/${version}/${asset}" + if is_semver "$release_asset_url_version"; then + echo "${VOLCANO_GITHUB_RELEASES_URL%/}/download/${release_asset_url_version}/${release_asset_url_asset}" else - fail "unsupported Volcano CLI version selector: ${version}; use latest or vMAJOR.MINOR.PATCH" + fail "unsupported Volcano CLI version selector: ${release_asset_url_version}; use latest or vMAJOR.MINOR.PATCH" fi ;; esac } resolve_install_dir() { - local os="$1" + resolve_install_dir_os="$1" if [ -n "$VOLCANO_INSTALL_DIR" ]; then echo "$VOLCANO_INSTALL_DIR" return fi - if [ "$os" = "windows" ]; then + if [ "$resolve_install_dir_os" = "windows" ]; then echo "${HOME}/bin" return fi @@ -138,6 +136,13 @@ resolve_install_dir() { echo "${HOME}/.local/bin" } +for arg in "$@"; do + case "$arg" in + --setup) RUN_SETUP=1 ;; + *) fail "unknown option: ${arg}" ;; + esac +done + VERSION="${VOLCANO_VERSION:-$VOLCANO_DEFAULT_VERSION}" if [ -z "$VERSION" ]; then @@ -200,10 +205,14 @@ PATH_VOLCANO="$(command -v "$CLI_COMMAND" 2>/dev/null || true)" if [ -z "$PATH_VOLCANO" ]; then echo "Add ${INSTALL_DIR} to your PATH to run '${CLI_COMMAND}' from any shell." echo "Run: ${INSTALL_PATH} --help" -elif [ "$PATH_VOLCANO" != "$INSTALL_PATH" ] && ! [ "$PATH_VOLCANO" -ef "$INSTALL_PATH" ]; then +elif [ "$PATH_VOLCANO" != "$INSTALL_PATH" ]; then echo "Warning: '${CLI_COMMAND}' on your PATH resolves to ${PATH_VOLCANO}, not ${INSTALL_PATH}." echo "Move ${INSTALL_DIR} earlier in your PATH or run '${INSTALL_PATH}' directly." echo "Run: ${INSTALL_PATH} --help" -else +elif [ "$RUN_SETUP" != "1" ]; then echo "Run: ${CLI_COMMAND} --help" fi + +if [ "$RUN_SETUP" = "1" ]; then + "$INSTALL_PATH" setup +fi diff --git a/scripts/install-volcano.test.sh b/scripts/install-volcano.test.sh new file mode 100644 index 0000000..3b2265a --- /dev/null +++ b/scripts/install-volcano.test.sh @@ -0,0 +1,125 @@ +#!/bin/sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +mkdir -p "$TMP_DIR/bin" "$TMP_DIR/install" + +cat > "$TMP_DIR/bin/curl" <<'EOF' +#!/bin/sh +set -eu + +output="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output="$2" + shift 2 + ;; + https://*) + url="$1" + shift + ;; + *) shift ;; + esac +done + +printf '%s\n' "$url" > "$FAKE_CURL_LOG" +cp "$FAKE_VOLCANO_BINARY" "$output" +EOF +chmod +x "$TMP_DIR/bin/curl" + +cat > "$TMP_DIR/bin/uname" <<'EOF' +#!/bin/sh +case "$1" in + -s) printf '%s\n' "$FAKE_UNAME_S" ;; + -m) printf '%s\n' "$FAKE_UNAME_M" ;; + *) exit 1 ;; +esac +EOF +chmod +x "$TMP_DIR/bin/uname" + +cat > "$TMP_DIR/volcano" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_VOLCANO_LOG" +EOF +chmod +x "$TMP_DIR/volcano" + +export FAKE_VOLCANO_BINARY="$TMP_DIR/volcano" +export FAKE_VOLCANO_LOG="$TMP_DIR/volcano.log" +export FAKE_CURL_LOG="$TMP_DIR/curl.log" +export FAKE_UNAME_S="Darwin" +export FAKE_UNAME_M="arm64" +export PATH="$TMP_DIR/bin:$PATH" +export VOLCANO_INSTALL_DIR="$TMP_DIR/install" +export VOLCANO_SKIP_SIGNATURE_VERIFICATION=1 + +assert_asset() { + asset_os="$1" + asset_arch="$2" + asset_name="$3" + asset_dir="$TMP_DIR/install-$asset_os-$asset_arch" + + FAKE_UNAME_S="$asset_os" FAKE_UNAME_M="$asset_arch" VOLCANO_INSTALL_DIR="$asset_dir" \ + sh "$ROOT/scripts/install-volcano.sh" >/dev/null + grep -Fx "https://github.com/Kong/volcano-cli/releases/latest/download/$asset_name" "$FAKE_CURL_LOG" >/dev/null + case "$asset_name" in + *.exe) test -x "$asset_dir/volcano.exe" ;; + *) test -x "$asset_dir/volcano" ;; + esac +} + +assert_rejected() { + rejected_os="$1" + rejected_arch="$2" + rejected_message="$3" + + if FAKE_UNAME_S="$rejected_os" FAKE_UNAME_M="$rejected_arch" \ + sh "$ROOT/scripts/install-volcano.sh" >"$TMP_DIR/platform-error.log" 2>&1; then + echo "expected $rejected_os $rejected_arch to fail" >&2 + exit 1 + fi + grep -F "$rejected_message" "$TMP_DIR/platform-error.log" >/dev/null +} + +sh "$ROOT/scripts/install-volcano.sh" >/dev/null +test -x "$TMP_DIR/install/volcano" +test ! -e "$FAKE_VOLCANO_LOG" + +assert_asset Linux x86_64 volcano-linux-amd64 +assert_asset Linux amd64 volcano-linux-amd64 +assert_asset Linux arm64 volcano-linux-arm64 +assert_asset Linux aarch64 volcano-linux-arm64 +assert_asset Darwin x86_64 volcano-macos-amd64 +assert_asset Darwin arm64 volcano-macos-arm64 +assert_asset MINGW64_NT-10.0 x86_64 volcano-windows-amd64.exe +assert_rejected MINGW64_NT-10.0 arm64 "unsupported platform: windows-arm64" +assert_rejected Plan9 x86_64 "unsupported operating system: plan9" +assert_rejected Linux riscv64 "unsupported architecture: riscv64" + +VOLCANO_VERSION=v1.2.3 sh "$ROOT/scripts/install-volcano.sh" >/dev/null +if VOLCANO_VERSION=v01.2.3 sh "$ROOT/scripts/install-volcano.sh" >"$TMP_DIR/version-error.log" 2>&1; then + echo "expected invalid version to fail" >&2 + exit 1 +fi +grep -F "unsupported Volcano CLI version selector: v01.2.3" "$TMP_DIR/version-error.log" >/dev/null + +cat > "$TMP_DIR/bin/volcano" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x "$TMP_DIR/bin/volcano" + +setup_output="$(sh "$ROOT/scripts/install-volcano.sh" --setup)" +printf '%s\n' "$setup_output" | grep -F \ + "Warning: 'volcano' on your PATH resolves to $TMP_DIR/bin/volcano, not $TMP_DIR/install/volcano." >/dev/null +test "$(cat "$FAKE_VOLCANO_LOG")" = "setup" + +if sh "$ROOT/scripts/install-volcano.sh" --unknown >"$TMP_DIR/error.log" 2>&1; then + echo "expected unknown option to fail" >&2 + exit 1 +fi +grep -F "unknown option: --unknown" "$TMP_DIR/error.log" >/dev/null diff --git a/scripts/npm/download.js b/scripts/npm/download.js index 41dc8ad..9369797 100644 --- a/scripts/npm/download.js +++ b/scripts/npm/download.js @@ -32,8 +32,8 @@ const TARGETS = { 'win32-x64': 'windows-amd64', }; -function resolveTarget() { - const key = `${process.platform}-${process.arch}`; +function resolveTarget(platform = process.platform, arch = process.arch) { + const key = `${platform}-${arch}`; const target = TARGETS[key]; if (!target) { throw new Error( @@ -44,12 +44,12 @@ function resolveTarget() { return target; } -function binaryExt() { - return process.platform === 'win32' ? '.exe' : ''; +function binaryExt(platform = process.platform) { + return platform === 'win32' ? '.exe' : ''; } -function assetName() { - return `volcano-${resolveTarget()}${binaryExt()}`; +function assetName(platform = process.platform, arch = process.arch) { + return `volcano-${resolveTarget(platform, arch)}${binaryExt(platform)}`; } // Absolute path where the downloaded binary lives (next to the launcher shim). @@ -218,6 +218,7 @@ async function ensureBinary({ force = false } = {}) { module.exports = { resolveTarget, + binaryExt, assetName, binaryPath, releaseTag, diff --git a/scripts/npm/download.test.js b/scripts/npm/download.test.js new file mode 100644 index 0000000..8c7c23d --- /dev/null +++ b/scripts/npm/download.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { assetName } = require('./download.js'); + +test('maps supported Node platforms to published release assets', () => { + const cases = [ + ['linux', 'x64', 'volcano-linux-amd64'], + ['linux', 'arm64', 'volcano-linux-arm64'], + ['darwin', 'x64', 'volcano-macos-amd64'], + ['darwin', 'arm64', 'volcano-macos-arm64'], + ['win32', 'x64', 'volcano-windows-amd64.exe'], + ]; + + for (const [platform, arch, expected] of cases) { + assert.equal(assetName(platform, arch), expected); + } +}); + +test('rejects a platform without a published release asset', () => { + assert.throws(() => assetName('win32', 'arm64'), /Unsupported platform "win32-arm64"/); +});