From e5d3633a86562a85be0a2d69d18fa93b0cc65256 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 9 Aug 2026 01:25:41 +0530 Subject: [PATCH 1/2] feat: add cross-platform CLI installers --- .github/workflows/ci.yml | 34 ++ .github/workflows/release.yml | 69 +++- .github/workflows/windows-installer.yml | 41 +++ INSTALL.md | 62 ++++ install.ps1 | 255 +++++++++++++++ install.sh | 104 ++++++ install_release_contract_test.go | 130 ++++++++ install_windows_contract_test.go | 141 +++++++++ tests/install_unix/install_test.go | 405 ++++++++++++++++++++++++ tests/install_windows/install_test.go | 314 ++++++++++++++++++ 10 files changed, 1550 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/windows-installer.yml create mode 100644 INSTALL.md create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 install_release_contract_test.go create mode 100644 install_windows_contract_test.go create mode 100644 tests/install_unix/install_test.go create mode 100644 tests/install_windows/install_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..908578c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test CLI and installers + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.25.x" + cache: true + + - name: Check Unix installer syntax + run: sh -n install.sh + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -count=1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31b7608..2aaa836 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,17 +4,53 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build or repair (for example, v0.0.1-alpha)" + required: true + type: string + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false permissions: contents: read jobs: + prepare: + name: Validate release tag + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release-tag.outputs.tag }} + tag_ref: ${{ steps.release-tag.outputs.tag_ref }} + steps: + - name: Resolve release tag + id: release-tag + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + if [[ ! "${RELEASE_TAG}" =~ ^v[A-Za-z0-9._-]+$ ]]; then + echo "Release tag must start with 'v' and contain only letters, numbers, dots, underscores, or hyphens: ${RELEASE_TAG}" >&2 + exit 1 + fi + if ! git check-ref-format "refs/tags/${RELEASE_TAG}"; then + echo "Invalid release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + printf 'tag=%s\n' "${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" + printf 'tag_ref=refs/tags/%s\n' "${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" + test: name: Test + needs: prepare runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag_ref }} - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -33,7 +69,9 @@ jobs: build: name: Build ${{ matrix.goos }}/${{ matrix.goarch }} - needs: test + needs: + - prepare + - test runs-on: ubuntu-latest strategy: fail-fast: false @@ -54,6 +92,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag_ref }} - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -63,11 +103,12 @@ jobs: - name: Build archive env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: | - version="${GITHUB_REF_NAME}" + version="${RELEASE_TAG}" commit="$(git rev-parse --short HEAD)" build_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" mkdir -p package dist @@ -88,7 +129,7 @@ jobs: - name: Verify release metadata if: matrix.goos == 'linux' && matrix.goarch == 'amd64' env: - EXPECTED_VERSION: ${{ github.ref_name }} + EXPECTED_VERSION: ${{ needs.prepare.outputs.tag }} run: | expected_commit="$(git rev-parse --short HEAD)" version_json="$(./package/stackdome version -o json)" @@ -107,13 +148,17 @@ jobs: publish: name: Publish GitHub release - needs: build + needs: + - prepare + - build runs-on: ubuntu-latest permissions: contents: write steps: - name: Check out repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag_ref }} - name: Download archives uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -129,4 +174,18 @@ jobs: - name: Publish release env: GH_TOKEN: ${{ github.token }} - run: gh release create "${GITHUB_REF_NAME}" dist/* --verify-tag --generate-notes --title "Stackdome CLI ${GITHUB_REF_NAME}" + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + release_is_draft="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" + else + gh release create "${RELEASE_TAG}" --draft --verify-tag --generate-notes --title "Stackdome CLI ${RELEASE_TAG}" + release_is_draft=true + fi + + gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip --clobber + gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber + + if [[ "${release_is_draft}" == "true" ]]; then + gh release edit "${RELEASE_TAG}" --draft=false + fi diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml new file mode 100644 index 0000000..9159c1b --- /dev/null +++ b/.github/workflows/windows-installer.yml @@ -0,0 +1,41 @@ +name: Test Windows installer + +on: + pull_request: + paths: + - "install.ps1" + - "install_windows_contract_test.go" + - "tests/install_windows/**" + - ".github/workflows/windows-installer.yml" + push: + branches: + - main + paths: + - "install.ps1" + - "install_windows_contract_test.go" + - "tests/install_windows/**" + - ".github/workflows/windows-installer.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: PowerShell 5.1 and PowerShell 7 + runs-on: windows-latest + env: + STACKDOME_REQUIRE_BOTH_POWERSHELLS: "1" + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.25.x" + cache: true + + - name: Test Windows installer + shell: pwsh + run: go test ./tests/install_windows -count=1 -v diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..2a5e3dc --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,62 @@ +# Install the Stackdome CLI + +The installers download the latest GitHub release for the current platform and +verify its SHA-256 checksum before installing it. + +## macOS and Linux + +```sh +curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh | sh +``` + +For agents and CI, download first so a network failure cannot be hidden by +pipeline exit-status behavior: + +```sh +installer_file=$(mktemp) +trap 'rm -f "$installer_file"' EXIT +curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh -o "$installer_file" +sh "$installer_file" +``` + +The installer supports Intel/AMD64 and ARM64. It writes to `/usr/local/bin` +when that directory is writable, otherwise it uses `$HOME/.local/bin`. + +To install a specific version or directory: + +```sh +curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh \ + | STACKDOME_VERSION=v0.0.1-alpha STACKDOME_INSTALL_DIR="$HOME/.local/bin" sh +``` + +## Windows PowerShell + +```powershell +irm https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 | iex +``` + +For agents and CI, fetch the script before evaluating it so download errors are +terminal: + +```powershell +$installer = Invoke-RestMethod -ErrorAction Stop https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 +& ([ScriptBlock]::Create([string]$installer)) +``` + +The installer supports AMD64 and ARM64. By default it installs to +`%LOCALAPPDATA%\Programs\Stackdome\bin` and adds that directory to the user +`PATH`. + +To install a specific version or directory: + +```powershell +$env:STACKDOME_VERSION = 'v0.0.1-alpha' +$env:STACKDOME_INSTALL_DIR = "$env:LOCALAPPDATA\Programs\Stackdome\bin" +irm https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 | iex +``` + +## Branded URLs + +A branded CLI installation URL can proxy `install.sh` and `install.ps1` from +this repository. The self-hosted Stackdome installer remains a separate +artifact owned by the Hub repository and should not point at these CLI scripts. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e741674 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,255 @@ +& { +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$previousProgressPreference = $ProgressPreference +$ProgressPreference = 'SilentlyContinue' + +function Get-ConfiguredValue { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$DefaultValue + ) + + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + return $DefaultValue + } + return $value.Trim() +} + +function Get-StackdomeArchitecture { + $architectureOverride = [Environment]::GetEnvironmentVariable('STACKDOME_ARCH') + if (-not [string]::IsNullOrWhiteSpace($architectureOverride)) { + switch ($architectureOverride.Trim().ToLowerInvariant()) { + 'amd64' { return 'amd64' } + 'x64' { return 'amd64' } + 'arm64' { return 'arm64' } + default { + throw "Unsupported Windows architecture override '$architectureOverride'. Set STACKDOME_ARCH to AMD64 or ARM64." + } + } + } + + # PROCESSOR_ARCHITEW6432 reports the native machine architecture when a + # 32-bit or x64 process is emulated on ARM64 Windows. + $detectedArchitecture = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432') + if ([string]::IsNullOrWhiteSpace($detectedArchitecture)) { + $detectedArchitecture = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE') + } + if ([string]::IsNullOrWhiteSpace($detectedArchitecture)) { + try { + $detectedArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } + catch { + throw 'Unable to detect the Windows architecture. Set STACKDOME_ARCH to AMD64 or ARM64.' + } + } + + switch ($detectedArchitecture.Trim().ToUpperInvariant()) { + 'AMD64' { 'amd64' } + 'X64' { 'amd64' } + 'ARM64' { 'arm64' } + default { + throw "Unsupported Windows architecture '$detectedArchitecture'. Stackdome provides installers for AMD64 and ARM64." + } + } +} + +function Get-LatestStackdomeVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ApiBaseUrl, + + [Parameter(Mandatory = $true)] + [string]$Repository + ) + + $latestReleaseUrl = '{0}/repos/{1}/releases/latest' -f $ApiBaseUrl.TrimEnd('/'), $Repository + try { + $release = Invoke-RestMethod -Uri $latestReleaseUrl -UseBasicParsing -Headers @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'stackdome-installer' + } + } + catch { + throw "Unable to discover the latest Stackdome release for repository '$Repository'. Check network access or set STACKDOME_VERSION explicitly." + } + + $tagNameProperty = $release.PSObject.Properties['tag_name'] + if ($null -eq $tagNameProperty) { + throw "The latest-release response for repository '$Repository' did not contain a tag_name. Set STACKDOME_VERSION explicitly." + } + $version = [string]$tagNameProperty.Value + if ([string]::IsNullOrWhiteSpace($version)) { + throw "The latest-release response for repository '$Repository' contained an empty tag_name. Set STACKDOME_VERSION explicitly." + } + return $version.Trim() +} + +function Save-StackdomeFile { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + + [Parameter(Mandatory = $true)] + [string]$Destination, + + [Parameter(Mandatory = $true)] + [string]$Description + ) + + try { + Invoke-WebRequest -Uri $Uri -OutFile $Destination -UseBasicParsing + } + catch { + throw "Unable to download $Description. Verify that the release and asset exist, then retry." + } +} + +function Test-PathContains { + param( + [AllowNull()] + [string]$PathValue, + + [Parameter(Mandatory = $true)] + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $normalizedEntry = $Entry.Trim().Trim('"').TrimEnd([char[]]'\/') + foreach ($candidate in ($PathValue -split [IO.Path]::PathSeparator)) { + $normalizedCandidate = $candidate.Trim().Trim('"').TrimEnd([char[]]'\/') + if ([StringComparer]::OrdinalIgnoreCase.Equals($normalizedCandidate, $normalizedEntry)) { + return $true + } + } + return $false +} + +$repository = Get-ConfiguredValue -Name 'STACKDOME_REPOSITORY' -DefaultValue 'Stackdome/stackdome-cli' +if ($repository -notmatch '^[^/\s]+/[^/\s]+$') { + throw "STACKDOME_REPOSITORY must use the owner/repository format; received '$repository'." +} + +$version = [Environment]::GetEnvironmentVariable('STACKDOME_VERSION') +if ([string]::IsNullOrWhiteSpace($version)) { + $apiBaseUrl = Get-ConfiguredValue -Name 'STACKDOME_API_BASE_URL' -DefaultValue 'https://api.github.com' + $version = Get-LatestStackdomeVersion -ApiBaseUrl $apiBaseUrl -Repository $repository +} +else { + $version = $version.Trim() +} +if ($version -notmatch '^[A-Za-z0-9._-]+$') { + throw "Invalid release version '$version'. Use a tag containing only letters, numbers, dots, underscores, or hyphens." +} + +$architecture = Get-StackdomeArchitecture +$assetName = 'stackdome_{0}_windows_{1}.zip' -f $version, $architecture +$releaseBaseUrl = Get-ConfiguredValue -Name 'STACKDOME_RELEASE_BASE_URL' -DefaultValue "https://github.com/$repository/releases/download" +$releaseBaseUrl = $releaseBaseUrl.TrimEnd('/') +$assetUrl = '{0}/{1}/{2}' -f $releaseBaseUrl, $version, $assetName +$checksumsUrl = '{0}/{1}/checksums.txt' -f $releaseBaseUrl, $version + +$configuredInstallDir = [Environment]::GetEnvironmentVariable('STACKDOME_INSTALL_DIR') +if ([string]::IsNullOrWhiteSpace($configuredInstallDir)) { + $localAppData = [Environment]::GetFolderPath('LocalApplicationData') + if ([string]::IsNullOrWhiteSpace($localAppData)) { + $localAppData = $env:LOCALAPPDATA + } + if ([string]::IsNullOrWhiteSpace($localAppData)) { + throw 'Unable to determine a user-writable install directory. Set STACKDOME_INSTALL_DIR explicitly.' + } + $installDir = Join-Path $localAppData 'Programs\Stackdome\bin' +} +else { + $installDir = [Environment]::ExpandEnvironmentVariables($configuredInstallDir.Trim()) +} + +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ('stackdome-install-{0}' -f [Guid]::NewGuid().ToString('N')) +$archivePath = Join-Path $tempRoot $assetName +$checksumsPath = Join-Path $tempRoot 'checksums.txt' +$extractPath = Join-Path $tempRoot 'extracted' + +try { + New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null + Save-StackdomeFile -Uri $assetUrl -Destination $archivePath -Description $assetName + Save-StackdomeFile -Uri $checksumsUrl -Destination $checksumsPath -Description 'checksums.txt' + + $checksumManifest = Get-Content -LiteralPath $checksumsPath -Raw + $checksumPattern = '(?im)^(?[0-9a-f]{64})[ \t]+\*?' + [regex]::Escape($assetName) + '[ \t]*$' + $checksumMatch = [regex]::Match($checksumManifest, $checksumPattern) + if (-not $checksumMatch.Success) { + throw "checksums.txt does not contain an exact SHA-256 entry for $assetName. The release may be incomplete." + } + + $expectedHash = $checksumMatch.Groups['hash'].Value.ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "Checksum mismatch for $assetName. Expected $expectedHash but downloaded $actualHash. Delete any cached copy and retry." + } + + try { + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + } + catch { + throw "Unable to extract the verified archive $assetName. Check available disk space and retry." + } + + $executablePath = Join-Path $extractPath 'stackdome.exe' + if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { + throw "The verified archive $assetName does not contain stackdome.exe." + } + + try { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Copy-Item -LiteralPath $executablePath -Destination (Join-Path $installDir 'stackdome.exe') -Force + } + catch { + throw "Unable to install stackdome.exe in '$installDir'. Set STACKDOME_INSTALL_DIR to a writable directory and retry." + } + + $skipPathUpdate = [Environment]::GetEnvironmentVariable('STACKDOME_SKIP_PATH_UPDATE') + if ($skipPathUpdate -ne '1') { + if (-not (Test-PathContains -PathValue $env:Path -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($env:Path)) { + $env:Path = $installDir + } + else { + $env:Path = $installDir + [IO.Path]::PathSeparator + $env:Path + } + } + + try { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not (Test-PathContains -PathValue $userPath -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($userPath)) { + $updatedUserPath = $installDir + } + else { + $updatedUserPath = $userPath + [IO.Path]::PathSeparator + $installDir + } + [Environment]::SetEnvironmentVariable('Path', $updatedUserPath, 'User') + } + } + catch { + Write-Warning "Stackdome was installed, but the user PATH could not be updated. Add '$installDir' to your user PATH manually." + } + } + + Write-Host "Stackdome CLI $version was installed to $installDir\stackdome.exe" + Write-Host "Restart your terminal, then run 'stackdome version' to verify the installation." +} +finally { + $ProgressPreference = $previousProgressPreference + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4b0c3fd --- /dev/null +++ b/install.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +set -eu + +repository=${STACKDOME_REPOSITORY:-Stackdome/stackdome-cli} +release_base_url=${STACKDOME_RELEASE_BASE_URL:-https://github.com/${repository}/releases/download} +api_base_url=${STACKDOME_API_BASE_URL:-https://api.github.com} +version=${STACKDOME_VERSION:-} +tmp_dir= + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$tmp_dir" ] && [ -d "$tmp_dir" ]; then + rm -rf "$tmp_dir" + fi +} + +download() { + source_url=$1 + destination=$2 + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$source_url" -o "$destination" + elif command -v wget >/dev/null 2>&1; then + wget -q "$source_url" -O "$destination" + else + fail "curl or wget is required to download Stackdome CLI" + fi +} + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) fail "unsupported operating system: $(uname -s) (supported: macOS and Linux)" ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) fail "unsupported architecture: $(uname -m) (supported: amd64 and arm64)" ;; +esac + +if [ -n "${STACKDOME_INSTALL_DIR:-}" ]; then + install_dir=$STACKDOME_INSTALL_DIR +elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then + install_dir=/usr/local/bin +else + [ -n "${HOME:-}" ] || fail "HOME is not set; set STACKDOME_INSTALL_DIR" + install_dir=$HOME/.local/bin +fi + +tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/stackdome-install.XXXXXX") || fail "could not create a temporary directory" +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +if [ -z "$version" ]; then + release_metadata=$tmp_dir/latest-release.json + download "${api_base_url}/repos/${repository}/releases/latest" "$release_metadata" || fail "could not discover the latest Stackdome CLI release; check network access or set STACKDOME_VERSION explicitly" + version=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$release_metadata" | sed -n '1p') + [ -n "$version" ] || fail "latest release response did not contain a tag_name; set STACKDOME_VERSION explicitly" +fi + +case "$version" in + *[!A-Za-z0-9._-]*) fail "invalid release version: $version" ;; +esac + +asset="stackdome_${version}_${os}_${arch}.tar.gz" +asset_url="${release_base_url}/${version}/${asset}" +checksums_url="${release_base_url}/${version}/checksums.txt" + +archive_path=$tmp_dir/$asset +checksums_path=$tmp_dir/checksums.txt + +printf 'Downloading Stackdome CLI %s (%s/%s)...\n' "$version" "$os" "$arch" +download "$asset_url" "$archive_path" || fail "could not download release asset $asset" +download "$checksums_url" "$checksums_path" || fail "could not download checksums.txt for $version" + +checksum=$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksums_path") +[ -n "$checksum" ] || fail "checksums.txt does not contain an exact entry for $asset" + +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum=$(sha256sum "$archive_path" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + actual_checksum=$(shasum -a 256 "$archive_path" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required to verify the download" +fi + +[ "$actual_checksum" = "$checksum" ] || fail "checksum verification failed for $asset" +printf 'Verified checksum for %s.\n' "$asset" + +tar -xzf "$archive_path" -C "$tmp_dir" stackdome || fail "could not extract stackdome from $asset" +mkdir -p "$install_dir" || fail "could not create install directory: $install_dir" +install -m 0755 "$tmp_dir/stackdome" "$install_dir/stackdome" || fail "could not install stackdome to $install_dir" + +printf 'Installed Stackdome CLI to %s/stackdome\n' "$install_dir" +case ":${PATH:-}:" in + *":$install_dir:"*) ;; + *) printf 'warning: %s is not on your PATH; add it before running stackdome\n' "$install_dir" >&2 ;; +esac diff --git a/install_release_contract_test.go b/install_release_contract_test.go new file mode 100644 index 0000000..048eda4 --- /dev/null +++ b/install_release_contract_test.go @@ -0,0 +1,130 @@ +package installer + +import ( + "os" + "strings" + "testing" +) + +func TestInstallersMatchReleaseArchiveContract(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + unixInstaller := readContractFile(t, "install.sh") + windowsInstaller := readContractFile(t, "install.ps1") + + requireContractText(t, "release workflow", workflow, + `stackdome_${version}_${GOOS}_${GOARCH}.tar.gz`, + `stackdome_${version}_${GOOS}_${GOARCH}.zip`, + `sha256sum *.tar.gz *.zip > checksums.txt`, + ) + requireContractText(t, "Unix installer", unixInstaller, + `asset="stackdome_${version}_${os}_${arch}.tar.gz"`, + `checksums_url="${release_base_url}/${version}/checksums.txt"`, + ) + requireContractText(t, "Windows installer", windowsInstaller, + `stackdome_{0}_windows_{1}.zip`, + `$checksumsUrl = '{0}/{1}/checksums.txt'`, + ) +} + +func TestReleaseWorkflowCanRepairExistingRelease(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + requireContractText(t, "release workflow", workflow, + "workflow_dispatch:", + `gh release view "${RELEASE_TAG}"`, + `gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip --clobber`, + `gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber`, + `gh release create "${RELEASE_TAG}"`, + ) +} + +func TestReleaseWorkflowUsesInstallerSafeQualifiedTagRefs(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + requireContractText(t, "release workflow", workflow, + `[[ ! "${RELEASE_TAG}" =~ ^v[A-Za-z0-9._-]+$ ]]`, + `printf 'tag_ref=refs/tags/%s\n' "${RELEASE_TAG}"`, + ) + + const qualifiedCheckout = `ref: ${{ needs.prepare.outputs.tag_ref }}` + if count := strings.Count(workflow, qualifiedCheckout); count != 3 { + t.Errorf("release workflow has %d qualified tag checkouts, want 3", count) + } +} + +func TestReleaseWorkflowSerializesRepairsAndPublishesChecksumsLast(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + requireContractText(t, "release workflow", workflow, + "concurrency:", + `group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}`, + "cancel-in-progress: false", + `gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip --clobber`, + `gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber`, + ) + + archives := strings.Index(workflow, `gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip --clobber`) + checksums := strings.Index(workflow, `gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber`) + if archives >= checksums { + t.Error("release repair must finish uploading archives before publishing checksums.txt") + } +} + +func TestPullRequestsRunInstallerAndReleaseContractTests(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/ci.yml") + requireContractText(t, "CI workflow", workflow, + "pull_request:", + "go test ./...", + "go vet ./...", + "sh -n install.sh", + ) +} + +func TestNewReleaseIsPublishedOnlyAfterAllAssetsExist(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + requireContractText(t, "release workflow", workflow, + `gh release create "${RELEASE_TAG}" --draft --verify-tag --generate-notes --title "Stackdome CLI ${RELEASE_TAG}"`, + `gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip`, + `gh release upload "${RELEASE_TAG}" dist/checksums.txt`, + `gh release edit "${RELEASE_TAG}" --draft=false`, + ) + + createDraft := strings.Index(workflow, `gh release create "${RELEASE_TAG}" --draft`) + uploadArchives := strings.LastIndex(workflow, `gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip`) + uploadChecksums := strings.LastIndex(workflow, `gh release upload "${RELEASE_TAG}" dist/checksums.txt`) + publish := strings.Index(workflow, `gh release edit "${RELEASE_TAG}" --draft=false`) + if !(createDraft < uploadArchives && uploadArchives < uploadChecksums && uploadChecksums < publish) { + t.Error("new release must remain a draft until archives and checksums are uploaded") + } +} + +func TestRepairPublishesAPreviouslyFailedDraftRelease(t *testing.T) { + workflow := readContractFile(t, ".github/workflows/release.yml") + requireContractText(t, "release workflow", workflow, + `gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft`, + `release_is_draft=true`, + `if [[ "${release_is_draft}" == "true" ]]; then`, + `gh release edit "${RELEASE_TAG}" --draft=false`, + ) + + uploadChecksums := strings.LastIndex(workflow, `gh release upload "${RELEASE_TAG}" dist/checksums.txt`) + publishDraft := strings.LastIndex(workflow, `gh release edit "${RELEASE_TAG}" --draft=false`) + if uploadChecksums < 0 || publishDraft < uploadChecksums { + t.Error("draft release repair must publish only after checksums are uploaded") + } +} + +func readContractFile(t *testing.T, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(contents) +} + +func requireContractText(t *testing.T, sourceName, source string, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(source, value) { + t.Errorf("%s is missing release contract %q", sourceName, value) + } + } +} diff --git a/install_windows_contract_test.go b/install_windows_contract_test.go new file mode 100644 index 0000000..6228142 --- /dev/null +++ b/install_windows_contract_test.go @@ -0,0 +1,141 @@ +package installer + +import ( + "os" + "strings" + "testing" +) + +func readWindowsInstaller(t *testing.T) string { + t.Helper() + + script, err := os.ReadFile("install.ps1") + if err != nil { + t.Fatalf("read install.ps1: %v", err) + } + return string(script) +} + +func requireInstallerContracts(t *testing.T, script string, contracts ...string) { + t.Helper() + + for _, contract := range contracts { + if !strings.Contains(script, contract) { + t.Errorf("install.ps1 is missing contract %q", contract) + } + } +} + +func TestWindowsInstallerSupportsReleaseAndTestOverrides(t *testing.T) { + script := readWindowsInstaller(t) + + requireInstallerContracts(t, script, + "STACKDOME_VERSION", + "STACKDOME_INSTALL_DIR", + "STACKDOME_REPOSITORY", + "STACKDOME_RELEASE_BASE_URL", + "STACKDOME_API_BASE_URL", + "STACKDOME_ARCH", + "api.github.com", + "/repos/{1}/releases/latest", + "Invalid release version", + ) + + for _, incompatible := range []string{"$PSScriptRoot", "Read-Host"} { + if strings.Contains(script, incompatible) { + t.Errorf("install.ps1 uses %q, which is incompatible with non-interactive `irm ... | iex` installation", incompatible) + } + } +} + +func TestWindowsInstallerSelectsVersionedWindowsArchive(t *testing.T) { + script := readWindowsInstaller(t) + + requireInstallerContracts(t, script, + "PROCESSOR_ARCHITEW6432", + "PROCESSOR_ARCHITECTURE", + "[Runtime.InteropServices.RuntimeInformation]::OSArchitecture", + "'X64' { 'amd64' }", + "'ARM64' { 'arm64' }", + "Unsupported Windows architecture", + "stackdome_{0}_windows_{1}.zip", + "checksums.txt", + ) +} + +func TestWindowsInstallerDoesNotLogOverrideURLsOrNestedDownloadErrors(t *testing.T) { + script := readWindowsInstaller(t) + + for _, leakedValue := range []string{ + "from $Uri", + "from $latestReleaseUrl", + "$($_.Exception.Message)", + } { + if strings.Contains(script, leakedValue) { + t.Errorf("install.ps1 can expose credentials through error text %q", leakedValue) + } + } + + requireInstallerContracts(t, script, + "Check network access or set STACKDOME_VERSION explicitly", + "Verify that the release and asset exist, then retry", + ) +} + +func TestWindowsInstallerVerifiesExactArchiveSHA256(t *testing.T) { + script := readWindowsInstaller(t) + + requireInstallerContracts(t, script, + "Get-FileHash", + "-Algorithm SHA256", + "[regex]::Escape($assetName)", + "(?im)^", + "Checksum mismatch", + "Expand-Archive", + ) + + if strings.Index(script, "Get-FileHash") > strings.Index(script, "Expand-Archive") { + t.Error("install.ps1 must verify the archive before extracting it") + } +} + +func TestWindowsInstallerUpdatesProcessAndPersistentUserPath(t *testing.T) { + script := readWindowsInstaller(t) + + requireInstallerContracts(t, script, + "LocalApplicationData", + "Programs\\Stackdome\\bin", + "STACKDOME_SKIP_PATH_UPDATE", + "if ([string]::IsNullOrWhiteSpace($PathValue))", + "[Environment]::GetEnvironmentVariable('Path', 'User')", + "[Environment]::SetEnvironmentVariable('Path', $updatedUserPath, 'User')", + "$env:Path", + "Test-PathContains", + "Restart your terminal", + ) +} + +func TestWindowsInstallerHasRealWindowsExecutionCoverage(t *testing.T) { + testSource, err := os.ReadFile("tests/install_windows/install_test.go") + if err != nil { + t.Fatalf("read Windows execution tests: %v", err) + } + workflow, err := os.ReadFile(".github/workflows/windows-installer.yml") + if err != nil { + t.Fatalf("read Windows installer workflow: %v", err) + } + + requireInstallerContracts(t, string(testSource), + "httptest.NewServer", + "archive/zip", + "powershell.exe", + "pwsh", + "STACKDOME_SKIP_PATH_UPDATE", + "STACKDOME_REQUIRE_BOTH_POWERSHELLS", + ) + requireInstallerContracts(t, string(workflow), + "windows-latest", + "go test ./tests/install_windows", + "STACKDOME_REQUIRE_BOTH_POWERSHELLS", + ) +} diff --git a/tests/install_unix/install_test.go b/tests/install_unix/install_test.go new file mode 100644 index 0000000..5076e21 --- /dev/null +++ b/tests/install_unix/install_test.go @@ -0,0 +1,405 @@ +package installunix_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +const testVersion = "v1.2.3" + +func TestInstallsVersionedAssetForSupportedPlatforms(t *testing.T) { + tests := []struct { + name string + unameOS string + unameCPU string + wantOS string + wantArch string + }{ + {name: "macOS Intel", unameOS: "Darwin", unameCPU: "x86_64", wantOS: "darwin", wantArch: "amd64"}, + {name: "macOS Apple Silicon", unameOS: "Darwin", unameCPU: "arm64", wantOS: "darwin", wantArch: "arm64"}, + {name: "Linux Intel", unameOS: "Linux", unameCPU: "x86_64", wantOS: "linux", wantArch: "amd64"}, + {name: "Linux ARM", unameOS: "Linux", unameCPU: "aarch64", wantOS: "linux", wantArch: "arm64"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + asset := fmt.Sprintf("stackdome_%s_%s_%s.tar.gz", testVersion, tt.wantOS, tt.wantArch) + binary := []byte("#!/bin/sh\nprintf 'installed test binary\\n'\n") + archive := makeArchive(t, binary) + digest := sha256.Sum256(archive) + checksums := fmt.Sprintf("%x %s\n", digest, asset) + + var requestMu sync.Mutex + var requested []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMu.Lock() + requested = append(requested, r.URL.Path) + requestMu.Unlock() + switch r.URL.Path { + case "/downloads/" + testVersion + "/" + asset: + _, _ = w.Write(archive) + case "/downloads/" + testVersion + "/checksums.txt": + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + installDir := t.TempDir() + result := runInstaller(t, installerEnv{ + installDir: installDir, + version: testVersion, + releaseURL: server.URL + "/downloads", + unameOS: tt.unameOS, + unameCPU: tt.unameCPU, + }) + if result.err != nil { + t.Fatalf("installer failed: %v\noutput:\n%s", result.err, result.output) + } + + installed, err := os.ReadFile(filepath.Join(installDir, "stackdome")) + if err != nil { + t.Fatalf("read installed binary: %v", err) + } + if !bytes.Equal(installed, binary) { + t.Fatalf("installed binary = %q, want %q", installed, binary) + } + info, err := os.Stat(filepath.Join(installDir, "stackdome")) + if err != nil { + t.Fatalf("stat installed binary: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("installed binary mode %v is not executable", info.Mode()) + } + wantWarning := "warning: " + installDir + " is not on your PATH; add it before running stackdome" + if !strings.Contains(result.output, wantWarning) { + t.Fatalf("output %q does not contain PATH warning %q", result.output, wantWarning) + } + + wantRequests := []string{ + "/downloads/" + testVersion + "/" + asset, + "/downloads/" + testVersion + "/checksums.txt", + } + requestMu.Lock() + defer requestMu.Unlock() + if strings.Join(requested, "\n") != strings.Join(wantRequests, "\n") { + t.Fatalf("requested paths = %q, want %q", requested, wantRequests) + } + }) + } +} + +func TestDiscoversLatestReleaseWithoutJQ(t *testing.T) { + const repository = "example/stackdome-cli" + asset := "stackdome_v9.8.7_linux_arm64.tar.gz" + binary := []byte("latest release binary\n") + archive := makeArchive(t, binary) + digest := sha256.Sum256(archive) + checksums := fmt.Sprintf("%x %s\n", digest, asset) + + var requestMu sync.Mutex + var requested []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMu.Lock() + requested = append(requested, r.URL.Path) + requestMu.Unlock() + switch r.URL.Path { + case "/api/repos/" + repository + "/releases/latest": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "{\n \"tag_name\": \"v9.8.7\",\n \"name\": \"Stackdome v9.8.7\"\n}\n") + case "/downloads/v9.8.7/" + asset: + _, _ = w.Write(archive) + case "/downloads/v9.8.7/checksums.txt": + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + installDir := t.TempDir() + result := runInstaller(t, installerEnv{ + installDir: installDir, + releaseURL: server.URL + "/downloads", + apiURL: server.URL + "/api", + repository: repository, + unameOS: "Linux", + unameCPU: "aarch64", + }) + if result.err != nil { + t.Fatalf("installer failed: %v\noutput:\n%s", result.err, result.output) + } + + installed, err := os.ReadFile(filepath.Join(installDir, "stackdome")) + if err != nil { + t.Fatalf("read installed binary: %v", err) + } + if !bytes.Equal(installed, binary) { + t.Fatalf("installed binary = %q, want %q", installed, binary) + } + wantRequests := []string{ + "/api/repos/" + repository + "/releases/latest", + "/downloads/v9.8.7/" + asset, + "/downloads/v9.8.7/checksums.txt", + } + requestMu.Lock() + defer requestMu.Unlock() + if strings.Join(requested, "\n") != strings.Join(wantRequests, "\n") { + t.Fatalf("requested paths = %q, want %q", requested, wantRequests) + } +} + +func TestRejectsChecksumMismatchAndCleansTemporaryFiles(t *testing.T) { + asset := "stackdome_v1.2.3_linux_amd64.tar.gz" + archive := makeArchive(t, []byte("must not be installed\n")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/downloads/v1.2.3/" + asset: + _, _ = w.Write(archive) + case "/downloads/v1.2.3/checksums.txt": + _, _ = io.WriteString(w, strings.Repeat("0", 64)+" "+asset+"\n") + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + installDir := t.TempDir() + temporaryRoot := t.TempDir() + result := runInstaller(t, installerEnv{ + installDir: installDir, + tmpDir: temporaryRoot, + version: testVersion, + releaseURL: server.URL + "/downloads", + unameOS: "Linux", + unameCPU: "x86_64", + }) + if result.err == nil { + t.Fatalf("installer succeeded with mismatched checksum; output:\n%s", result.output) + } + if !strings.Contains(result.output, "checksum verification failed") { + t.Fatalf("output %q does not explain checksum failure", result.output) + } + if _, err := os.Stat(filepath.Join(installDir, "stackdome")); !os.IsNotExist(err) { + t.Fatalf("stackdome was installed after checksum failure (stat err: %v)", err) + } + entries, err := os.ReadDir(temporaryRoot) + if err != nil { + t.Fatalf("read temporary root: %v", err) + } + if len(entries) != 0 { + t.Fatalf("temporary files were not cleaned up: %v", entries) + } +} + +func TestRequiresExactChecksumFilename(t *testing.T) { + asset := "stackdome_v1.2.3_linux_amd64.tar.gz" + archive := makeArchive(t, []byte("binary with checksum under wrong name\n")) + digest := sha256.Sum256(archive) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/downloads/v1.2.3/" + asset: + _, _ = w.Write(archive) + case "/downloads/v1.2.3/checksums.txt": + _, _ = fmt.Fprintf(w, "%x %s.backup\n", digest, asset) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + installDir := t.TempDir() + result := runInstaller(t, installerEnv{ + installDir: installDir, + version: testVersion, + releaseURL: server.URL + "/downloads", + unameOS: "Linux", + unameCPU: "x86_64", + }) + if result.err == nil { + t.Fatalf("installer accepted checksum for a different filename; output:\n%s", result.output) + } + if !strings.Contains(result.output, "does not contain an exact entry for "+asset) { + t.Fatalf("output %q does not explain the missing exact checksum entry", result.output) + } +} + +func TestRejectsUnsupportedPlatformsWithActionableError(t *testing.T) { + tests := []struct { + name string + unameOS string + unameCPU string + want string + }{ + {name: "operating system", unameOS: "FreeBSD", unameCPU: "x86_64", want: "unsupported operating system: FreeBSD (supported: macOS and Linux)"}, + {name: "architecture", unameOS: "Linux", unameCPU: "ppc64le", want: "unsupported architecture: ppc64le (supported: amd64 and arm64)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runInstaller(t, installerEnv{ + installDir: t.TempDir(), + version: testVersion, + unameOS: tt.unameOS, + unameCPU: tt.unameCPU, + }) + if result.err == nil { + t.Fatalf("installer succeeded on unsupported platform; output:\n%s", result.output) + } + if !strings.Contains(result.output, tt.want) { + t.Fatalf("output %q does not contain %q", result.output, tt.want) + } + }) + } +} + +func TestDownloadFailureDoesNotLogReleaseURLSecrets(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + + const secret = "token-super-secret" + result := runInstaller(t, installerEnv{ + installDir: t.TempDir(), + version: testVersion, + releaseURL: server.URL + "/" + secret, + unameOS: "Linux", + unameCPU: "x86_64", + }) + if result.err == nil { + t.Fatalf("installer succeeded without a release asset; output:\n%s", result.output) + } + if strings.Contains(result.output, secret) { + t.Fatalf("installer logged secret-bearing release URL: %q", result.output) + } + if !strings.Contains(result.output, "could not download release asset stackdome_v1.2.3_linux_amd64.tar.gz") { + t.Fatalf("output %q does not contain an actionable download error", result.output) + } +} + +func TestMalformedLatestResponseSuggestsVersionOverrideWithoutLoggingAPIURL(t *testing.T) { + const secret = "api-token-super-secret" + const repository = "example/stackdome-cli" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+secret+"/repos/"+repository+"/releases/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "{\"message\":\"unexpected response\"}\n") + })) + t.Cleanup(server.Close) + + result := runInstaller(t, installerEnv{ + installDir: t.TempDir(), + apiURL: server.URL + "/" + secret, + repository: repository, + unameOS: "Linux", + unameCPU: "x86_64", + }) + if result.err == nil { + t.Fatalf("installer succeeded with malformed latest-release response; output:\n%s", result.output) + } + if !strings.Contains(result.output, "set STACKDOME_VERSION explicitly") { + t.Fatalf("output %q does not suggest the version override", result.output) + } + if strings.Contains(result.output, secret) { + t.Fatalf("installer logged secret-bearing API URL: %q", result.output) + } +} + +type installerEnv struct { + installDir string + tmpDir string + version string + releaseURL string + apiURL string + repository string + unameOS string + unameCPU string +} + +type commandResult struct { + output string + err error +} + +func runInstaller(t *testing.T, env installerEnv) commandResult { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX installer test") + } + + script, err := filepath.Abs(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatalf("resolve installer path: %v", err) + } + fakeBin := t.TempDir() + unamePath := filepath.Join(fakeBin, "uname") + unameScript := "#!/bin/sh\ncase \"$1\" in\n -s) printf '%s\\n' \"$TEST_UNAME_S\" ;;\n -m) printf '%s\\n' \"$TEST_UNAME_M\" ;;\n *) exit 2 ;;\nesac\n" + if err := os.WriteFile(unamePath, []byte(unameScript), 0o755); err != nil { + t.Fatalf("write uname shim: %v", err) + } + jqPath := filepath.Join(fakeBin, "jq") + if err := os.WriteFile(jqPath, []byte("#!/bin/sh\nexit 97\n"), 0o755); err != nil { + t.Fatalf("write jq shim: %v", err) + } + + path := fakeBin + string(os.PathListSeparator) + os.Getenv("PATH") + shell := os.Getenv("STACKDOME_TEST_SHELL") + if shell == "" { + shell = "sh" + } + cmd := exec.Command(shell, script) + cmd.Env = []string{ + "PATH=" + path, + "HOME=" + os.Getenv("HOME"), + "STACKDOME_INSTALL_DIR=" + env.installDir, + "STACKDOME_VERSION=" + env.version, + "STACKDOME_RELEASE_BASE_URL=" + env.releaseURL, + "STACKDOME_API_BASE_URL=" + env.apiURL, + "STACKDOME_REPOSITORY=" + env.repository, + "TMPDIR=" + env.tmpDir, + "TEST_UNAME_S=" + env.unameOS, + "TEST_UNAME_M=" + env.unameCPU, + } + output, runErr := cmd.CombinedOutput() + return commandResult{output: string(output), err: runErr} +} + +func makeArchive(t *testing.T, binary []byte) []byte { + t.Helper() + var output bytes.Buffer + gzipWriter := gzip.NewWriter(&output) + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{ + Name: "stackdome", + Mode: 0o755, + Size: int64(len(binary)), + }); err != nil { + t.Fatalf("write archive header: %v", err) + } + if _, err := tarWriter.Write(binary); err != nil { + t.Fatalf("write archive body: %v", err) + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("close tar writer: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("close gzip writer: %v", err) + } + return output.Bytes() +} diff --git a/tests/install_windows/install_test.go b/tests/install_windows/install_test.go new file mode 100644 index 0000000..7915354 --- /dev/null +++ b/tests/install_windows/install_test.go @@ -0,0 +1,314 @@ +package installwindows_test + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +const windowsTestVersion = "v1.2.3" + +func TestInstallsARM64ArchiveWhenX64PowerShellIsEmulated(t *testing.T) { + script := readInstaller(t) + assetName := "stackdome_" + windowsTestVersion + "_windows_arm64.zip" + binary := []byte("fake Windows ARM64 executable\r\n") + archive := makeZipArchive(t, binary) + digest := sha256.Sum256(archive) + checksums := fmt.Sprintf("%x %s\n", digest, assetName) + + var requestMu sync.Mutex + var requested []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestMu.Lock() + requested = append(requested, r.URL.Path) + requestMu.Unlock() + + switch r.URL.Path { + case "/install.ps1": + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write(script) + case "/downloads/" + windowsTestVersion + "/" + assetName: + _, _ = w.Write(archive) + case "/downloads/" + windowsTestVersion + "/checksums.txt": + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + for _, shell := range powershells(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + installDir := t.TempDir() + result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ + "STACKDOME_VERSION": windowsTestVersion, + "STACKDOME_INSTALL_DIR": installDir, + "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", + "STACKDOME_ARCH": "", + "STACKDOME_SKIP_PATH_UPDATE": "1", + "PROCESSOR_ARCHITEW6432": "ARM64", + "PROCESSOR_ARCHITECTURE": "AMD64", + }) + if result.err != nil { + t.Fatalf("installer failed: %v\noutput:\n%s", result.err, result.output) + } + + installed, err := os.ReadFile(filepath.Join(installDir, "stackdome.exe")) + if err != nil { + t.Fatalf("read installed executable: %v", err) + } + if !bytes.Equal(installed, binary) { + t.Fatalf("installed executable = %q, want %q", installed, binary) + } + }) + } + + requestMu.Lock() + defer requestMu.Unlock() + joinedRequests := strings.Join(requested, "\n") + for _, path := range []string{ + "/downloads/" + windowsTestVersion + "/" + assetName, + "/downloads/" + windowsTestVersion + "/checksums.txt", + } { + if !strings.Contains(joinedRequests, path) { + t.Errorf("installer did not request %s; requests:\n%s", path, joinedRequests) + } + } +} + +func TestRejectsChecksumMismatch(t *testing.T) { + script := readInstaller(t) + assetName := "stackdome_" + windowsTestVersion + "_windows_amd64.zip" + archive := makeZipArchive(t, []byte("must not be installed\r\n")) + checksums := strings.Repeat("0", 64) + " " + assetName + "\n" + + server := newInstallerServer(t, script, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/downloads/" + windowsTestVersion + "/" + assetName: + _, _ = w.Write(archive) + case "/downloads/" + windowsTestVersion + "/checksums.txt": + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + }) + + for _, shell := range powershells(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + installDir := t.TempDir() + result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ + "STACKDOME_VERSION": windowsTestVersion, + "STACKDOME_INSTALL_DIR": installDir, + "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", + "STACKDOME_ARCH": "amd64", + "STACKDOME_SKIP_PATH_UPDATE": "1", + }) + if result.err == nil { + t.Fatalf("installer accepted a mismatched checksum; output:\n%s", result.output) + } + if !strings.Contains(result.output, "Checksum mismatch") { + t.Fatalf("output %q does not explain the checksum mismatch", result.output) + } + if _, err := os.Stat(filepath.Join(installDir, "stackdome.exe")); !os.IsNotExist(err) { + t.Fatalf("executable was installed after checksum mismatch (stat error: %v)", err) + } + }) + } +} + +func TestDownloadFailureDoesNotExposeReleaseURL(t *testing.T) { + script := readInstaller(t) + const secret = "token-super-secret" + server := newInstallerServer(t, script, http.NotFoundHandler().ServeHTTP) + + for _, shell := range powershells(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ + "STACKDOME_VERSION": windowsTestVersion, + "STACKDOME_INSTALL_DIR": t.TempDir(), + "STACKDOME_RELEASE_BASE_URL": server.URL + "/" + secret, + "STACKDOME_ARCH": "amd64", + "STACKDOME_SKIP_PATH_UPDATE": "1", + }) + if result.err == nil { + t.Fatalf("installer succeeded without a release asset; output:\n%s", result.output) + } + if strings.Contains(result.output, secret) { + t.Fatalf("installer exposed a secret-bearing release URL: %q", result.output) + } + if !strings.Contains(result.output, "Unable to download stackdome_v1.2.3_windows_amd64.zip") { + t.Fatalf("output %q does not identify the unavailable asset", result.output) + } + }) + } +} + +func TestMalformedLatestResponseIsActionableAndRedacted(t *testing.T) { + script := readInstaller(t) + const secret = "api-token-super-secret" + const repository = "example/stackdome-cli" + server := newInstallerServer(t, script, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+secret+"/repos/"+repository+"/releases/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "{}\n") + }) + + for _, shell := range powershells(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ + "STACKDOME_VERSION": "", + "STACKDOME_INSTALL_DIR": t.TempDir(), + "STACKDOME_API_BASE_URL": server.URL + "/" + secret, + "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", + "STACKDOME_REPOSITORY": repository, + "STACKDOME_ARCH": "amd64", + "STACKDOME_SKIP_PATH_UPDATE": "1", + }) + if result.err == nil { + t.Fatalf("installer accepted malformed latest-release metadata; output:\n%s", result.output) + } + if strings.Contains(result.output, secret) { + t.Fatalf("installer exposed a secret-bearing API URL: %q", result.output) + } + if !strings.Contains(result.output, "Set STACKDOME_VERSION explicitly") { + t.Fatalf("output %q does not suggest the version override", result.output) + } + if strings.Contains(result.output, "Property 'tag_name' cannot be found") { + t.Fatalf("strict-mode property error escaped instead of actionable remediation: %q", result.output) + } + }) + } +} + +type commandResult struct { + output string + err error +} + +func powershells(t *testing.T) []string { + t.Helper() + if runtime.GOOS != "windows" { + t.Skip("Windows PowerShell execution test") + } + + var shells []string + for _, name := range []string{"powershell.exe", "pwsh"} { + path, err := exec.LookPath(name) + if err == nil { + shells = append(shells, path) + } + } + if os.Getenv("STACKDOME_REQUIRE_BOTH_POWERSHELLS") == "1" && len(shells) != 2 { + t.Fatalf("Windows CI requires both powershell.exe 5.1 and pwsh; found %d", len(shells)) + } + if len(shells) == 0 { + t.Skip("powershell.exe and pwsh are unavailable") + } + return shells +} + +func runInstaller(t *testing.T, shell, scriptURL string, overrides map[string]string) commandResult { + t.Helper() + testEnvironment := map[string]string{ + "STACKDOME_VERSION": "", + "STACKDOME_INSTALL_DIR": "", + "STACKDOME_RELEASE_BASE_URL": "", + "STACKDOME_API_BASE_URL": "", + "STACKDOME_REPOSITORY": "Stackdome/stackdome-cli", + "STACKDOME_ARCH": "", + "STACKDOME_SKIP_PATH_UPDATE": "1", + } + for name, value := range overrides { + testEnvironment[name] = value + } + + command := fmt.Sprintf("Invoke-RestMethod -Uri '%s' | Invoke-Expression", scriptURL) + cmd := exec.Command(shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command) + cmd.Env = environmentWithOverrides(os.Environ(), testEnvironment) + output, err := cmd.CombinedOutput() + return commandResult{output: string(output), err: err} +} + +func environmentWithOverrides(base []string, overrides map[string]string) []string { + environment := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + name, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if _, overridden := lookupFold(overrides, name); !overridden { + environment = append(environment, entry) + } + } + for name, value := range overrides { + environment = append(environment, name+"="+value) + } + return environment +} + +func lookupFold(values map[string]string, name string) (string, bool) { + for key, value := range values { + if strings.EqualFold(key, name) { + return value, true + } + } + return "", false +} + +func readInstaller(t *testing.T) []byte { + t.Helper() + path, err := filepath.Abs(filepath.Join("..", "..", "install.ps1")) + if err != nil { + t.Fatalf("resolve install.ps1: %v", err) + } + script, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read install.ps1: %v", err) + } + return script +} + +func newInstallerServer(t *testing.T, script []byte, releaseHandler http.HandlerFunc) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/install.ps1" { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write(script) + return + } + releaseHandler(w, r) + })) + t.Cleanup(server.Close) + return server +} + +func makeZipArchive(t *testing.T, binary []byte) []byte { + t.Helper() + var output bytes.Buffer + archive := zip.NewWriter(&output) + file, err := archive.Create("stackdome.exe") + if err != nil { + t.Fatalf("create executable in ZIP: %v", err) + } + if _, err := file.Write(binary); err != nil { + t.Fatalf("write executable to ZIP: %v", err) + } + if err := archive.Close(); err != nil { + t.Fatalf("close ZIP: %v", err) + } + return output.Bytes() +} From 58975a25e696564b19d5d71e0d45a2a9a5a7a6f8 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 9 Aug 2026 01:25:45 +0530 Subject: [PATCH 2/2] fix: make build diagnostics actionable --- cmd/stackdome/build.go | 92 ++++++++--- cmd/stackdome/build_test.go | 287 +++++++++++++++++++++++++++++++++ cmd/stackdome/helpers.go | 41 ++++- internal/client/client.go | 9 +- internal/client/logs_test.go | 39 +++++ internal/errors/errors_test.go | 16 ++ 6 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 internal/client/logs_test.go create mode 100644 internal/errors/errors_test.go diff --git a/cmd/stackdome/build.go b/cmd/stackdome/build.go index c4b166b..8040c5a 100644 --- a/cmd/stackdome/build.go +++ b/cmd/stackdome/build.go @@ -2,8 +2,11 @@ package main import ( "context" + "errors" "fmt" + "io" "os" + "strings" "time" "github.com/Stackdome/stackdome-cli/internal/client" @@ -47,10 +50,11 @@ func newBuildLogsCmd() *cobra.Command { return err } - buildID, err := resolveBuildID(ctx, cmd, stackID, args[0]) + build, err := resolveBuild(ctx, cmd, stackID, args[0]) if err != nil { return err } + buildID := build.GetId() stream, err := ctx.Client.StreamBuildLogs(cmd.Context(), stackID, buildID, client.LogOptions{ Follow: flagFollow, @@ -61,7 +65,7 @@ func newBuildLogsCmd() *cobra.Command { if cmd.Context().Err() == context.Canceled { return clierrors.ErrUserCanceled } - return err + return friendlyBuildLogsError(args[0], build, err) } defer stream.Close() @@ -88,6 +92,31 @@ func newBuildLogsCmd() *cobra.Command { return cmd } +func friendlyBuildLogsError(buildRef string, build openapi.ImageBuild, err error) error { + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.Code != "NOT_FOUND" { + return err + } + + reason := strings.ToLower(cliErr.Detail) + if !strings.Contains(reason, "logs have been pruned") && + !strings.Contains(reason, "no longer exists in the cluster") { + return err + } + + message := fmt.Sprintf("Logs for build %q are not available yet; the build has not started.", buildRef) + if buildIsTerminal(build) { + message = fmt.Sprintf("Logs for build %q are no longer available; they were pruned after the build completed.", buildRef) + } + + return &clierrors.CLIError{ + Message: message, + Code: "NOT_FOUND", + ExitCode: clierrors.ExitNotFound, + Cause: err, + } +} + func newBuildListCmd() *cobra.Command { var ( flagResource string @@ -122,10 +151,10 @@ func newBuildListCmd() *cobra.Command { return nil } - tbl := ctx.Formatter.NewTable("ID", "RESOURCE", "STATE", "SOURCE", "STARTED", "DURATION") + tbl := ctx.Formatter.NewTable("BUILD", "RESOURCE", "STATE", "SOURCE", "STARTED", "DURATION") for _, b := range builds { tbl.AddRow( - shortID(b.GetId()), + buildReference(b), b.StackResourceName, buildStateColor(b), buildSource(b), @@ -171,7 +200,7 @@ func newBuildInfoCmd() *cobra.Command { return ctx.Formatter.PrintStructured(build) } - renderBuildInfo(build) + renderBuildInfo(ctx.Formatter.Writer, build) return nil })), } @@ -180,37 +209,38 @@ func newBuildInfoCmd() *cobra.Command { return cmd } -func renderBuildInfo(b *openapi.ImageBuild) { - fmt.Printf("Build: %s\n", b.GetId()) - fmt.Printf("Resource: %s\n", b.StackResourceName) - fmt.Printf("State: %s\n", buildStateColor(*b)) - fmt.Printf("Source: %s\n", buildSource(*b)) +func renderBuildInfo(w io.Writer, b *openapi.ImageBuild) { + fmt.Fprintf(w, "Build: %s\n", buildReference(*b)) + fmt.Fprintf(w, "ID: %s\n", b.GetId()) + fmt.Fprintf(w, "Resource: %s\n", b.StackResourceName) + fmt.Fprintf(w, "State: %s\n", buildStateColor(*b)) + fmt.Fprintf(w, "Source: %s\n", buildSource(*b)) if b.Status != nil && b.Status.ImageUrl != nil && *b.Status.ImageUrl != "" { - fmt.Printf("Image: %s\n", *b.Status.ImageUrl) + fmt.Fprintf(w, "Image: %s\n", *b.Status.ImageUrl) } if start := buildStartTime(*b); start != nil { - fmt.Printf("Started: %s\n", start.Local().Format(time.RFC3339)) + fmt.Fprintf(w, "Started: %s\n", start.Local().Format(time.RFC3339)) } - fmt.Printf("Duration: %s\n", buildDuration(*b)) + fmt.Fprintf(w, "Duration: %s\n", buildDuration(*b)) if b.Status != nil && b.Status.LastBuildFailureDetail != nil { f := b.Status.LastBuildFailureDetail - fmt.Println() - fmt.Println("Failure:") + fmt.Fprintln(w) + fmt.Fprintln(w, "Failure:") if f.FailureType != nil { - fmt.Printf(" Type: %s\n", *f.FailureType) + fmt.Fprintf(w, " Type: %s\n", *f.FailureType) } if f.Reason != nil { - fmt.Printf(" Reason: %s\n", *f.Reason) + fmt.Fprintf(w, " Reason: %s\n", *f.Reason) } if f.Message != nil { - fmt.Printf(" Message: %s\n", *f.Message) + fmt.Fprintf(w, " Message: %s\n", *f.Message) } if f.ExitCode != nil { - fmt.Printf(" Exit: %d\n", *f.ExitCode) + fmt.Fprintf(w, " Exit: %d\n", *f.ExitCode) } } } @@ -222,6 +252,30 @@ func shortID(id string) string { return id } +// buildReference is the short, copyable identifier shown by build list. +// Build IDs begin with the resource name, so the generic first-eight-character +// shortID hides every distinguishing character for builds of one resource. +// The source revision identifies what was built; the ID suffix distinguishes +// repeated builds of the same revision. +func buildReference(b openapi.ImageBuild) string { + idSuffix := b.GetId() + if len(idSuffix) > 8 { + idSuffix = idSuffix[len(idSuffix)-8:] + } + + if git := b.SourceRevision.GitRepoRevision; git != nil && git.Commit != nil && *git.Commit != "" { + commit := *git.Commit + if len(commit) > 7 { + commit = commit[:7] + } + return commit + "-" + idSuffix + } + if b.SourceRevision.VolumeSourceRevision != nil { + return "volume-" + idSuffix + } + return idSuffix +} + func buildStateColor(b openapi.ImageBuild) string { if b.Status == nil || b.Status.State == nil { return "Unknown" diff --git a/cmd/stackdome/build_test.go b/cmd/stackdome/build_test.go index 4da483f..2d589b9 100644 --- a/cmd/stackdome/build_test.go +++ b/cmd/stackdome/build_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -71,6 +72,192 @@ func buildWith(state string, conds []openapi.Condition) openapi.ImageBuild { return b } +func TestBuildReferenceUsesCommitAndUniqueBuildSuffix(t *testing.T) { + commit := "7bc067e829eb9380539878b72d8b64ac017b487a" + builds := []struct { + name string + build openapi.ImageBuild + want string + }{ + { + name: "git build", + build: openapi.ImageBuild{ + Id: openapi.PtrString("api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e849a"), + SourceRevision: openapi.BuildSourceRevision{ + GitRepoRevision: &openapi.GitRepoRevision{Commit: &commit}, + }, + }, + want: "7bc067e-fe0e849a", + }, + { + name: "rebuild of same commit", + build: openapi.ImageBuild{ + Id: openapi.PtrString("api-server-7bc067e829eb9380539878b72d8b64ac017b487a-a2a97d0d"), + SourceRevision: openapi.BuildSourceRevision{ + GitRepoRevision: &openapi.GitRepoRevision{Commit: &commit}, + }, + }, + want: "7bc067e-a2a97d0d", + }, + { + name: "volume build", + build: openapi.ImageBuild{ + Id: openapi.PtrString("worker-volume-source-11223344"), + SourceRevision: openapi.BuildSourceRevision{ + VolumeSourceRevision: &openapi.BuildSourceRevisionVolumeSourceRevision{}, + }, + }, + want: "volume-11223344", + }, + } + + for _, tt := range builds { + t.Run(tt.name, func(t *testing.T) { + if got := buildReference(tt.build); got != tt.want { + t.Fatalf("buildReference() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildListShowsCopyableBuildReferences(t *testing.T) { + const stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/api/v1/organizations/org-1/projects/proj-1/stacks/"+stackID+"/builds" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"items":[ + {"id":"api-server-a214e9a114b01a17c7c4ad576543810eb3a4f421-3e794668","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"branch":"feat/alpha-observability-p0","commit":"a214e9a114b01a17c7c4ad576543810eb3a4f421"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Success"}}, + {"id":"api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e849a","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"branch":"feat/alpha-observability-p0","commit":"7bc067e829eb9380539878b72d8b64ac017b487a"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Success"}} + ]}`)) + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + }, output.FormatTable, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newBuildListCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + + if err := cmd.Execute(); err != nil { + t.Fatalf("build list: %v", err) + } + + for _, want := range []string{"BUILD", "a214e9a-3e794668", "7bc067e-fe0e849a"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("build list output omitted %q:\n%s", want, stdout.String()) + } + } +} + +func TestBuildInfoAcceptsDisplayedBuildReference(t *testing.T) { + const ( + stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + buildID = "api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e849a" + ) + buildJSON := `{"id":"` + buildID + `","stack_id":"` + stackID + `","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"branch":"feat/alpha-observability-p0","commit":"7bc067e829eb9380539878b72d8b64ac017b487a"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Success"}}` + var gotInfoPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds": + _, _ = w.Write([]byte(`{"items":[` + buildJSON + `]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/" + buildID: + gotInfoPath = r.URL.Path + _, _ = w.Write([]byte(buildJSON)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + }, output.FormatJSON, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newBuildInfoCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"7bc067e-fe0e849a"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("build info by displayed reference: %v", err) + } + if !strings.HasSuffix(gotInfoPath, "/builds/"+buildID) { + t.Fatalf("build info path = %q, want full build ID", gotInfoPath) + } + var got openapi.ImageBuild + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("build info output is not JSON: %v\n%s", err, stdout.String()) + } + if got.GetId() != buildID { + t.Fatalf("build info ID = %q, want %q", got.GetId(), buildID) + } +} + +func TestBuildInfoTableShowsReferenceAndFullID(t *testing.T) { + const ( + stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + buildID = "api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e849a" + ) + buildJSON := `{"id":"` + buildID + `","stack_id":"` + stackID + `","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"branch":"feat/alpha-observability-p0","commit":"7bc067e829eb9380539878b72d8b64ac017b487a"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Success"}}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds": + _, _ = w.Write([]byte(`{"items":[` + buildJSON + `]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/" + buildID: + _, _ = w.Write([]byte(buildJSON)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + }, output.FormatTable, slog.LevelError) + var stdout bytes.Buffer + ctx.Formatter.Writer = &stdout + cmd := newBuildInfoCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"7bc067e-fe0e849a"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("build info by displayed reference: %v", err) + } + for _, want := range []string{ + "Build: 7bc067e-fe0e849a", + "ID: " + buildID, + } { + if !strings.Contains(stdout.String(), want) { + t.Errorf("build info output omitted %q:\n%s", want, stdout.String()) + } + } +} + func TestBuildDurationConditionFallback(t *testing.T) { base := time.Date(2026, 8, 5, 20, 38, 0, 0, time.UTC) done := base.Add(time.Minute + 41*time.Second) @@ -236,6 +423,106 @@ func TestBuildLogsJSONWritesDecodedNDJSONEvent(t *testing.T) { } } +func TestBuildLogsPrunedErrorUsesCopyableReferenceWithoutClusterInternals(t *testing.T) { + const ( + stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + buildID = "api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e8490" + buildRef = "7bc067e-fe0e8490" + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"` + buildID + `","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"commit":"7bc067e829eb9380539878b72d8b64ac017b487a"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Success"}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/" + buildID + "/logs": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"reason":"no logs available for build ` + buildID + `: no build pod found for job api-server-c4dfdf78-build: the build has not started yet or its logs have been pruned"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + }, output.FormatTable, slog.LevelError) + cmd := newBuildLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"-f", buildRef}) + + err := cmd.Execute() + if err == nil { + t.Fatal("build logs error = nil, want pruned-logs error") + } + got := clierrors.UserMessage(err) + want := `Logs for build "7bc067e-fe0e8490" are no longer available; they were pruned after the build completed.` + if got != want { + t.Fatalf("user message = %q, want %q", got, want) + } + for _, leaked := range []string{buildID, "build pod", "api-server-c4dfdf78-build"} { + if strings.Contains(got, leaked) { + t.Errorf("user message leaks cluster detail %q: %s", leaked, got) + } + } + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) || cliErr.Code != "NOT_FOUND" || cliErr.ExitCode != clierrors.ExitNotFound { + t.Errorf("error = %#v, want NOT_FOUND with exit code %d", err, clierrors.ExitNotFound) + } +} + +func TestBuildLogsUnavailableForPendingBuildDoesNotClaimPruning(t *testing.T) { + const ( + stackID = "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4" + buildID = "api-server-7bc067e829eb9380539878b72d8b64ac017b487a-fe0e8490" + buildRef = "7bc067e-fe0e8490" + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds": + _, _ = w.Write([]byte(`{"items":[{"id":"` + buildID + `","stack_resource_id":"resource-1","stack_resource_name":"api-server","source_revision":{"git_repo_revision":{"commit":"7bc067e829eb9380539878b72d8b64ac017b487a"}},"build_context":{},"image_repo":"example.invalid/api","status":{"state":"Pending"}}]}`)) + case "/api/v1/organizations/org-1/projects/proj-1/stacks/" + stackID + "/builds/" + buildID + "/logs": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"reason":"no logs available for build ` + buildID + `: no build pod found for job api-server-c4dfdf78-build: the build has not started yet or its logs have been pruned"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + ctx := cmdutil.NewCommandContext(&config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + CurrentStack: stackID, + }, output.FormatTable, slog.LevelError) + cmd := newBuildLogsCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"-f", buildRef}) + + err := cmd.Execute() + if err == nil { + t.Fatal("build logs error = nil, want unavailable-logs error") + } + got := clierrors.UserMessage(err) + want := `Logs for build "7bc067e-fe0e8490" are not available yet; the build has not started.` + if got != want { + t.Fatalf("user message = %q, want %q", got, want) + } + if strings.Contains(strings.ToLower(got), "pruned") { + t.Fatalf("pending-build message incorrectly claims pruning: %s", got) + } +} + // Build-stream errors follow the same root-only JSON error contract as // runtime logs; prose from the callback would corrupt the document. func TestBuildLogsJSONServerErrorIsSingleRootDocument(t *testing.T) { diff --git a/cmd/stackdome/helpers.go b/cmd/stackdome/helpers.go index 977c457..05b30d4 100644 --- a/cmd/stackdome/helpers.go +++ b/cmd/stackdome/helpers.go @@ -7,6 +7,7 @@ import ( "github.com/Stackdome/stackdome-cli/internal/cmdutil" clierrors "github.com/Stackdome/stackdome-cli/internal/errors" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" ) @@ -121,15 +122,49 @@ func resolveIDPrefix(kind, arg string, ids []string) (string, error) { } func resolveBuildID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (string, error) { - builds, err := ctx.Client.ListBuilds(cmd.Context(), stackID) + build, err := resolveBuild(ctx, cmd, stackID, arg) if err != nil { return "", err } + return build.GetId(), nil +} + +func resolveBuild(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (openapi.ImageBuild, error) { + builds, err := ctx.Client.ListBuilds(cmd.Context(), stackID) + if err != nil { + return openapi.ImageBuild{}, err + } ids := make([]string, 0, len(builds)) + buildsByID := make(map[string]openapi.ImageBuild, len(builds)) + refMatches := make([]openapi.ImageBuild, 0, 1) for _, b := range builds { - ids = append(ids, b.GetId()) + id := b.GetId() + if id == arg { + return b, nil + } + ids = append(ids, id) + buildsByID[id] = b + if buildReference(b) == arg { + refMatches = append(refMatches, b) + } + } + switch len(refMatches) { + case 1: + return refMatches[0], nil + case 0: + id, resolveErr := resolveIDPrefix("Build", arg, ids) + if resolveErr != nil { + return openapi.ImageBuild{}, resolveErr + } + return buildsByID[id], nil + default: + matchingIDs := make([]string, len(refMatches)) + for i := range refMatches { + matchingIDs[i] = refMatches[i].GetId() + } + return openapi.ImageBuild{}, clierrors.ValidationError( + "Ambiguous Build reference \"" + arg + "\": matches " + strings.Join(matchingIDs, ", ")) } - return resolveIDPrefix("Build", arg, ids) } func resolveReleaseID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (string, error) { diff --git a/internal/client/client.go b/internal/client/client.go index 75a2af2..11981e4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -304,7 +304,14 @@ func WrapError(httpResp *http.Response, err error, message string) error { } func wrapHTTPResponseError(httpResp *http.Response, message string) error { - return WrapError(httpResp, errors.New(message), message) + responseErr := errors.New(message) + if httpResp != nil && httpResp.Body != nil { + body, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20)) + if err == nil && len(bytes.TrimSpace(body)) > 0 { + responseErr = errors.New(string(body)) + } + } + return WrapError(httpResp, responseErr, message) } type bodyer interface { diff --git a/internal/client/logs_test.go b/internal/client/logs_test.go new file mode 100644 index 0000000..ee057d6 --- /dev/null +++ b/internal/client/logs_test.go @@ -0,0 +1,39 @@ +package client + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + clierrors "github.com/Stackdome/stackdome-cli/internal/errors" +) + +func TestStreamBuildLogsPreservesServerReason(t *testing.T) { + const reason = "no logs available for build build-1: build build-1 no longer exists in the cluster" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"reason":"` + reason + `"}`)) + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("sdm_test", ""), WithOrgAndProject("org-1", "proj-1")) + + _, err := c.StreamBuildLogs(context.Background(), "stack-1", "build-1", LogOptions{Tail: 200}) + if err == nil { + t.Fatal("StreamBuildLogs() error = nil, want not found error") + } + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("error = %T (%v), want CLIError", err, err) + } + if !strings.Contains(cliErr.Detail, reason) { + t.Fatalf("error detail = %q, want server reason %q", cliErr.Detail, reason) + } + if got := clierrors.UserMessage(err); strings.Contains(got, reason) { + t.Fatalf("user message exposes internal server reason: %q", got) + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..a5db6c3 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,16 @@ +package errors + +import "testing" + +func TestUserMessageDoesNotExposeInternalDetail(t *testing.T) { + err := &CLIError{ + Message: "Resource not found", + Detail: "internal namespace and workload diagnostics", + Code: "NOT_FOUND", + ExitCode: ExitNotFound, + } + + if got, want := UserMessage(err), "Resource not found"; got != want { + t.Fatalf("UserMessage() = %q, want %q", got, want) + } +}