From bac6e0708241f93f103e180802d4ab8e63f71fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans-J=C3=BCrgen=20Sch=C3=B6nig?= Date: Mon, 22 Jun 2026 15:49:53 +0200 Subject: [PATCH 1/2] installer: fix and harden the curl|sh installer, add serving Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/install.sh never worked against real releases: - it built an unversioned archive name (pg_hardstorage__.tar.gz) but goreleaser emits pg_hardstorage___.tar.gz → 404; - it used `latest` as a literal download path (no such tag) → 404; - it read $1 directly, so `--version ` was taken as the version string verbatim. Resolve `latest` via the GitHub release redirect, build the versioned goreleaser archive name, and parse --version/--bindir/--no-verify properly. Verify downloads by SHA-256 against checksums.txt, and by cosign signature when cosign is present. Re-exec under bash (the canonical `| sh` entry runs dash on Debian) and fall back to ~/.local/bin when there's no TTY to prompt for sudo. Add a Cloudflare Worker (deploy/cloudflare/) that serves the script at get.pghardstorage.org, with a wrangler.toml wired for the Cloudflare Git integration and the custom-domain route. Verified against the live v1.0.0 release: latest resolves to v1.0.0, the built archive name matches checksums.txt, and the tarball + .sig + .pem all exist. --- CHANGELOG.md | 11 ++ deploy/cloudflare/README.md | 62 ++++++ deploy/cloudflare/get-installer-worker.js | 71 +++++++ deploy/cloudflare/wrangler.toml | 23 +++ scripts/README.md | 10 +- scripts/install.sh | 218 ++++++++++++++++++---- 6 files changed, 357 insertions(+), 38 deletions(-) create mode 100644 deploy/cloudflare/README.md create mode 100644 deploy/cloudflare/get-installer-worker.js create mode 100644 deploy/cloudflare/wrangler.toml mode change 100644 => 100755 scripts/install.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e7eb5f..f0ad5786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ keeps reading that version for at least 24 months after a successor lands. ## [Unreleased] +### Installer: fix and harden the curl|sh installer + +The `scripts/install.sh` one-liner now works against real releases: it +builds the versioned goreleaser archive name, resolves `latest` via the +GitHub release redirect, and parses `--version`/`--bindir`/`--no-verify` +flags correctly (previously `latest` and the unversioned archive name +both 404'd, and `--version` was mis-read). Downloads are verified by +SHA-256 against `checksums.txt`, and by cosign signature when cosign is +installed. Added a Cloudflare Worker (`deploy/cloudflare/`) to serve the +script at get.pghardstorage.org. + ### Docs: publish the documentation site to GitHub Pages The docs CI built and validated the site but never published it. A diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md new file mode 100644 index 00000000..02bef876 --- /dev/null +++ b/deploy/cloudflare/README.md @@ -0,0 +1,62 @@ +# Cloudflare Worker — `get.pghardstorage.org` + +Serves the one-line installer so this works: + +```sh +curl -sSL https://get.pghardstorage.org | sh +``` + +The Worker (`get-installer-worker.js`) fetches `scripts/install.sh` from +the repo's `main` branch and returns it as `text/plain`, cached at the +edge. Editing `scripts/install.sh` on `main` updates the served +installer within the cache TTL — no Worker redeploy needed. + +`wrangler.toml` here carries the name (`pghardstorage-get`), entrypoint, +and the `get.pghardstorage.org` custom-domain route, so a single deploy +wires up serving end-to-end. + +## Setup — GitHub integration (recommended) + +Cloudflare's Git integration ("Workers Builds") redeploys the Worker on +every push to `main`. One-time wiring in the Cloudflare dashboard: + +1. **Workers & Pages → Create → Workers tab → Connect to Git** + (a.k.a. "Import a repository"). +2. Authorise GitHub and pick `cybertec-postgresql/pg_hardstorage`. +3. **Build settings:** + - **Root directory:** `deploy/cloudflare` ← where this `wrangler.toml` lives + - **Build command:** *(leave empty — plain JS, nothing to build)* + - **Deploy command:** `npx wrangler deploy` *(default)* + - **Production branch:** `main` +4. **Save and Deploy.** The first deploy reads `wrangler.toml`, publishes + the Worker, and binds `get.pghardstorage.org` automatically + (`custom_domain = true` creates the DNS record + TLS cert). + +Prerequisite: the `pghardstorage.org` zone is on this Cloudflare account. + +## Setup — manual (fallback) + +If you'd rather not connect Git, deploy from this directory with an +authenticated `wrangler` (`npm i -g wrangler && wrangler login`): + +```sh +cd deploy/cloudflare +wrangler deploy # reads wrangler.toml: name, entrypoint, route +``` + +## Verify (either path) + +```sh +curl -sSL https://get.pghardstorage.org | head -20 # should print the script +curl -sSL https://get.pghardstorage.org | sh # should install +``` + +## Notes + +- **Pin to a release instead of `main`:** edit `INSTALL_SCRIPT_URL` in + the Worker to `...//scripts/install.sh` if you want the served + installer frozen to a tagged release. +- **Cache TTL:** `CACHE_TTL_SECONDS` (default 300s) controls how fast an + `install.sh` change propagates. +- This Worker only ever emits the installer script; it rejects non-GET + methods and sends `X-Content-Type-Options: nosniff`. diff --git a/deploy/cloudflare/get-installer-worker.js b/deploy/cloudflare/get-installer-worker.js new file mode 100644 index 00000000..34eb7d3e --- /dev/null +++ b/deploy/cloudflare/get-installer-worker.js @@ -0,0 +1,71 @@ +/** + * Cloudflare Worker for https://get.pghardstorage.org + * + * Serves the canonical install script as text/plain so that + * + * curl -sSL https://get.pghardstorage.org | sh + * + * pipes the real installer into the shell. The script body is fetched + * from the repo's main branch (raw.githubusercontent.com) and cached at + * the edge, so updating scripts/install.sh on main updates the served + * installer without redeploying this Worker. + * + * Why a Worker rather than a plain redirect: + * - We can pin the Content-Type to text/plain (some clients choke on + * GitHub raw's charset quirks; `curl | sh` doesn't care, but a + * human opening the URL in a browser gets readable text). + * - We control caching + can swap the upstream (e.g. pin to a tag) + * in one place. + * - No dependence on a 30x redirect surviving every client's flags. + * + * Deploy: see deploy/cloudflare/README.md. + */ + +// Source of truth for the installer. Pin to a tag instead of `main` +// (e.g. .../v1.0.0/scripts/install.sh) if you want the served installer +// frozen to a release rather than tracking main. +const INSTALL_SCRIPT_URL = + "https://raw.githubusercontent.com/cybertec-postgresql/pg_hardstorage/main/scripts/install.sh"; + +// Edge cache lifetime for the fetched script (seconds). Short enough +// that a fix to install.sh propagates quickly, long enough to absorb +// install spikes without hammering the origin. +const CACHE_TTL_SECONDS = 300; + +export default { + async fetch(request) { + // Only GET/HEAD make sense for a script endpoint. + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method Not Allowed\n", { + status: 405, + headers: { Allow: "GET, HEAD", "Content-Type": "text/plain" }, + }); + } + + const upstream = await fetch(INSTALL_SCRIPT_URL, { + cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + }); + + if (!upstream.ok) { + return new Response( + "Installer temporarily unavailable. " + + "See https://github.com/cybertec-postgresql/pg_hardstorage/releases\n", + { status: 502, headers: { "Content-Type": "text/plain" } } + ); + } + + const body = await upstream.text(); + + return new Response(body, { + status: 200, + headers: { + // text/plain so `curl | sh` and a browser both behave. + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}`, + // Defensive headers — this endpoint only ever emits a script. + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + }, + }); + }, +}; diff --git a/deploy/cloudflare/wrangler.toml b/deploy/cloudflare/wrangler.toml new file mode 100644 index 00000000..67697e9f --- /dev/null +++ b/deploy/cloudflare/wrangler.toml @@ -0,0 +1,23 @@ +# Wrangler config for the get.pghardstorage.org installer Worker. +# +# Consumed both by `wrangler deploy` and by Cloudflare's Git +# integration (Workers Builds): point the build at this directory +# (deploy/cloudflare) as the root and Cloudflare reads this file. +# +# The `routes` block binds the Worker to the custom hostname on the +# pghardstorage.org zone, so a deploy also wires up serving at +# https://get.pghardstorage.org (custom_domain = Cloudflare manages the +# DNS record + TLS cert automatically). + +name = "pghardstorage-get" +main = "get-installer-worker.js" + +# Pin the Workers runtime semantics to a known date. Bump deliberately +# after testing if you need newer runtime behaviour. +compatibility_date = "2025-06-22" + +# Serve the Worker at the custom hostname. Requires the +# pghardstorage.org zone to be on this Cloudflare account (it is). +[[routes]] +pattern = "get.pghardstorage.org" +custom_domain = true diff --git a/scripts/README.md b/scripts/README.md index f1a176ae..dd43c27b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,8 +14,12 @@ comment block declares otherwise. ## Key files / subdirs -- `install.sh` — the curlable installer published at the official download - URL; resolves the latest release and drops the binary into `$PREFIX/bin` +- `install.sh` — the curlable installer served at https://get.pghardstorage.org; + resolves the latest release (or `--version `), downloads the matching + goreleaser tarball, verifies its SHA-256 against `checksums.txt` (and the + cosign signature when cosign is installed), then drops the binary into + `$PREFIX/bin`. The serving endpoint is the Cloudflare Worker under + `../deploy/cloudflare/`. - `demo-quickstart.sh` — five-minute end-to-end demo (local repo, backup, restore) used by `../docs/tutorials/getting-started.md` - `devcluster.sh` — local Patroni + pg_hardstorage dev cluster for maintainers @@ -26,6 +30,8 @@ comment block declares otherwise. - `../docs/tutorials/getting-started.md` — narrative wrapping `demo-quickstart.sh` +- `../deploy/cloudflare/` — the Worker that serves `install.sh` at + `get.pghardstorage.org` - `../Makefile` — build / test entry points (not here) - `../docs/how-to/packaging/` — release engineering procedures diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755 index a9a68bbc..3f6d0d3f --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,34 +1,129 @@ #!/usr/bin/env bash -set -euo pipefail - -# pg_hardstorage installer — one-line setup +# +# pg_hardstorage installer — one-line setup. # # Usage: # curl -sSL https://get.pghardstorage.org | sh -# curl -sSL https://get.pghardstorage.org | sh -s -- --version v0.2.0 +# curl -sSL https://get.pghardstorage.org | sh -s -- --version v1.0.0 +# curl -sSL https://get.pghardstorage.org | sh -s -- --bindir ~/.local/bin +# +# Flags (all optional): +# --version install a specific release tag (default: latest) +# --bindir install directory (default: /usr/local/bin, with a +# ~/.local/bin fallback when the former isn't writable) +# --no-verify skip checksum/signature verification (NOT advised) +# --help show this help and exit +# +# What it does: detects OS/arch, resolves the release, downloads the +# matching tarball + checksums.txt, verifies the SHA-256 (and the cosign +# signature when cosign is installed), then installs the binary. # -# Detects OS/arch, downloads the right binary, and offers to install. +# Re-exec note: the canonical invocation is `... | sh`, but this script +# uses bash features ([[ ]], local, arrays). Under a non-bash /bin/sh +# (dash on Debian/Ubuntu) those break, so we re-exec under bash when we +# detect we're not already running it. bash is present on macOS and +# every mainstream Linux; if it's genuinely absent we fail with a clear +# message rather than mis-parsing. + +# --- bash re-exec guard (must stay POSIX sh until we're under bash) --- +if [ -z "${BASH_VERSION:-}" ]; then + if command -v bash >/dev/null 2>&1; then + exec bash "$0" "$@" + else + echo "pg_hardstorage installer needs bash; please install bash and retry." >&2 + exit 1 + fi +fi + +set -euo pipefail REPO="cybertec-postgresql/pg_hardstorage" -DEFAULT_VERSION="latest" + +# Identity the release artefacts are cosign-signed under (keyless / +# Sigstore via GitHub Actions OIDC). Used only when cosign is present. +COSIGN_IDENTITY_REGEXP="https://github.com/${REPO}" +COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com" RED='\033[0;31m' GREEN='\033[0;32m' CYAN='\033[0;36m' +YELLOW='\033[0;33m' BOLD='\033[1m' NC='\033[0m' info() { printf "${CYAN}→${NC} %s\n" "$*"; } ok() { printf " ${GREEN}✓${NC} %s\n" "$*"; } +warn() { printf "${YELLOW}!${NC} %s\n" "$*" >&2; } err() { printf "${RED}✗${NC} %s\n" "$*" >&2; exit 1; } +usage() { + sed -n '3,17p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +# resolve_latest_tag prints the newest release tag name. We follow the +# GitHub "latest release" redirect rather than the API to avoid the +# 60-req/hr unauthenticated API rate limit: /releases/latest +# 302-redirects to /releases/tag/, and we read off the final +# effective URL. Works with both curl and wget. +resolve_latest_tag() { + local latest_url="https://github.com/${REPO}/releases/latest" + local tag="" + if command -v curl >/dev/null 2>&1; then + tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null \ + | sed -n 's@.*/releases/tag/\(.*\)@\1@p' | tr -d '\r')" + elif command -v wget >/dev/null 2>&1; then + tag="$(wget -q -S --max-redirect=5 -O /dev/null "$latest_url" 2>&1 \ + | sed -n 's@.*/releases/tag/\(.*\)@\1@p' | tail -1 | tr -d '\r ')" + fi + printf '%s' "$tag" +} + +# download URL DEST — fetch with curl or wget, hard-fail on error. +download() { + local url="$1" dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dest" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$dest" + else + err "Neither curl nor wget found. Install one and try again." + fi +} + +# sha256_of FILE — print the file's hex SHA-256, portable across the +# coreutils (sha256sum) and BSD/macOS (shasum -a 256) worlds. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + err "No sha256 tool (sha256sum / shasum) found; cannot verify download." + fi +} + main() { - local version="${1:-$DEFAULT_VERSION}" + local version="latest" + local install_dir="${INSTALL_DIR:-/usr/local/bin}" + local verify=1 + + # --- arg parsing (the old script read $1 directly, so `--version X` + # was taken as the version literally; parse flags properly). --- + while [[ $# -gt 0 ]]; do + case "$1" in + --version) version="${2:-}"; shift 2 || err "--version needs a value" ;; + --bindir) install_dir="${2:-}"; shift 2 || err "--bindir needs a value" ;; + --no-verify) verify=0; shift ;; + --help|-h) usage ;; + *) err "Unknown argument: $1 (try --help)" ;; + esac + done local os arch case "$(uname -s)" in Linux) os="linux" ;; - Darwin) os="darwin";; + Darwin) os="darwin" ;; *) err "Unsupported OS: $(uname -s). pg_hardstorage supports Linux and macOS." ;; esac case "$(uname -m)" in @@ -36,23 +131,76 @@ main() { aarch64|arm64) arch="arm64" ;; *) err "Unsupported architecture: $(uname -m). pg_hardstorage supports amd64 and arm64." ;; esac + # macOS ships arm64 only (matches .goreleaser.yaml's darwin/amd64 ignore). + if [[ "$os" == "darwin" && "$arch" == "amd64" ]]; then + err "macOS builds are arm64 only. On an Intel Mac, install via Rosetta or build from source." + fi + + # --- resolve the version tag --- + if [[ "$version" == "latest" ]]; then + info "Resolving the latest release" + version="$(resolve_latest_tag)" + [[ -n "$version" ]] || err "Could not resolve latest release. Pass --version , or see https://github.com/${REPO}/releases" + fi + ok "Release: ${version}" - info "Installing pg_hardstorage for ${os}/${arch}" + # goreleaser names archives ___.tar.gz with + # the version stripped of its leading 'v' (.goreleaser.yaml uses + # {{ .Version }}, which is the tag without the 'v'). + local ver_noV="${version#v}" + local tarball="pg_hardstorage_${ver_noV}_${os}_${arch}.tar.gz" + local base="https://github.com/${REPO}/releases/download/${version}" - local tarball="pg_hardstorage_${os}_${arch}.tar.gz" - local url="https://github.com/${REPO}/releases/download/${version}/${tarball}" + info "Installing pg_hardstorage ${version} for ${os}/${arch}" local tmpdir tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT - info "Downloading ${url}" - if command -v curl &>/dev/null; then - curl -fsSL "$url" -o "$tmpdir/$tarball" || err "Download failed. Check https://github.com/${REPO}/releases" - elif command -v wget &>/dev/null; then - wget -q "$url" -O "$tmpdir/$tarball" || err "Download failed. Check https://github.com/${REPO}/releases" + info "Downloading ${tarball}" + download "${base}/${tarball}" "$tmpdir/$tarball" \ + || err "Download failed. Check https://github.com/${REPO}/releases/tag/${version}" + + # --- verification --- + if [[ "$verify" -eq 1 ]]; then + info "Verifying checksum" + download "${base}/checksums.txt" "$tmpdir/checksums.txt" \ + || err "Could not download checksums.txt — cannot verify. Re-run with --no-verify to override (not advised)." + + local want got + want="$(grep " ${tarball}\$" "$tmpdir/checksums.txt" | awk '{print $1}' | head -1)" + [[ -n "$want" ]] || err "No checksum entry for ${tarball} in checksums.txt." + got="$(sha256_of "$tmpdir/$tarball")" + if [[ "$want" != "$got" ]]; then + err "Checksum mismatch for ${tarball}! expected ${want}, got ${got}. Aborting." + fi + ok "SHA-256 verified" + + # cosign is optional: verify the signature when the tool is present, + # otherwise note it was skipped. The release signs every artefact + # keylessly; the .sig + .pem sit next to the tarball. + if command -v cosign >/dev/null 2>&1; then + info "Verifying cosign signature" + download "${base}/${tarball}.sig" "$tmpdir/$tarball.sig" \ + || err "Could not download ${tarball}.sig for cosign verification." + download "${base}/${tarball}.pem" "$tmpdir/$tarball.pem" \ + || err "Could not download ${tarball}.pem for cosign verification." + if cosign verify-blob \ + --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \ + --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \ + --certificate "$tmpdir/$tarball.pem" \ + --signature "$tmpdir/$tarball.sig" \ + "$tmpdir/$tarball" >/dev/null 2>&1; then + ok "cosign signature verified" + else + err "cosign verification FAILED for ${tarball}. Aborting." + fi + else + warn "cosign not installed — skipping signature check (checksum still verified)." + warn "For supply-chain assurance, install cosign and re-run: https://docs.sigstore.dev/cosign/installation/" + fi else - err "Neither curl nor wget found. Install one and try again." + warn "Verification skipped (--no-verify). You are trusting an unverified download." fi info "Extracting" @@ -60,31 +208,29 @@ main() { local binary="$tmpdir/pg_hardstorage" if [[ ! -f "$binary" ]]; then - binary="$tmpdir/pg_hardstorage_${os}_${arch}/pg_hardstorage" + binary="$tmpdir/pg_hardstorage_${ver_noV}_${os}_${arch}/pg_hardstorage" fi - if [[ ! -f "$binary" ]]; then - err "Could not find pg_hardstorage binary in tarball." - fi - + [[ -f "$binary" ]] || err "Could not find pg_hardstorage binary in tarball." chmod +x "$binary" - local install_dir="/usr/local/bin" - case "${INSTALL_DIR:-}" in - "") ;; - *) install_dir="$INSTALL_DIR" ;; - esac - + # --- install --- if [[ -w "$install_dir" ]]; then cp "$binary" "$install_dir/pg_hardstorage" ok "Installed to $install_dir/pg_hardstorage" + elif [[ ! -t 0 ]]; then + # No TTY (the canonical `curl | sh` case): we can't prompt for a + # sudo decision, so fall back to the user-local bin rather than + # blocking on a read that returns EOF. + mkdir -p "$HOME/.local/bin" + cp "$binary" "$HOME/.local/bin/pg_hardstorage" + ok "Installed to $HOME/.local/bin/pg_hardstorage" + printf " Add to PATH: ${BOLD}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}\n" else printf " Install to ${BOLD}%s${NC} (requires sudo)? [Y/n] " "$install_dir" read -r answer if [[ "$answer" =~ ^[Nn] ]]; then - cp "$binary" "$HOME/.local/bin/pg_hardstorage" 2>/dev/null || { - mkdir -p "$HOME/.local/bin" - cp "$binary" "$HOME/.local/bin/pg_hardstorage" - } + mkdir -p "$HOME/.local/bin" + cp "$binary" "$HOME/.local/bin/pg_hardstorage" ok "Installed to $HOME/.local/bin/pg_hardstorage" printf " Add to PATH: ${BOLD}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}\n" else @@ -95,10 +241,10 @@ main() { printf "\n" printf "${BOLD}Next steps:${NC}\n" - printf " %s version\n" "pg_hardstorage" - printf " %s demo\n" "pg_hardstorage" - printf " %s init --quick\n" "pg_hardstorage" - printf " %s\n" "https://docs.pghardstorage.org" + printf " pg_hardstorage version\n" + printf " pg_hardstorage demo\n" + printf " pg_hardstorage init --quick\n" + printf " https://docs.pghardstorage.org\n" } main "$@" \ No newline at end of file From dbd4a238e2da7c7059828cd6f8f036c52b28b25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans-J=C3=BCrgen=20Sch=C3=B6nig?= Date: Mon, 22 Jun 2026 16:35:47 +0200 Subject: [PATCH 2/2] installer: make install.sh POSIX-sh so curl|sh actually works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bash re-exec guard could never recover bash for the canonical `curl -sSL ... | sh` path: a piped script has no file on disk, so $0 is the shell binary (e.g. /usr/bin/dash) and `exec bash "$0"` becomes `exec bash /usr/bin/dash` — "cannot execute binary file", exit 126. That broke the very invocation the PR set out to fix. Drop the re-exec and convert the script to strict POSIX sh (no [[ ]], no =~, no `set -o pipefail`), so it runs unchanged under dash, busybox ash, and bash. usage() now prints a static heredoc instead of sed-ing $0 (which isn't a file when piped). Verified end-to-end under dash: latest→v1.0.0, versioned archive name, SHA-256 verify, extract, install. --- scripts/install.sh | 111 +++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 53 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 3f6d0d3f..09198fe0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # # pg_hardstorage installer — one-line setup. # @@ -18,24 +18,12 @@ # matching tarball + checksums.txt, verifies the SHA-256 (and the cosign # signature when cosign is installed), then installs the binary. # -# Re-exec note: the canonical invocation is `... | sh`, but this script -# uses bash features ([[ ]], local, arrays). Under a non-bash /bin/sh -# (dash on Debian/Ubuntu) those break, so we re-exec under bash when we -# detect we're not already running it. bash is present on macOS and -# every mainstream Linux; if it's genuinely absent we fail with a clear -# message rather than mis-parsing. - -# --- bash re-exec guard (must stay POSIX sh until we're under bash) --- -if [ -z "${BASH_VERSION:-}" ]; then - if command -v bash >/dev/null 2>&1; then - exec bash "$0" "$@" - else - echo "pg_hardstorage installer needs bash; please install bash and retry." >&2 - exit 1 - fi -fi +# Portability: the canonical invocation is `... | sh`, which runs under +# whatever /bin/sh is (dash on Debian/Ubuntu, not bash). A piped script +# has no file on disk to re-exec, so we stay strictly POSIX sh here +# rather than relying on bash — no [[ ]], no arrays, no `set -o pipefail`. -set -euo pipefail +set -eu REPO="cybertec-postgresql/pg_hardstorage" @@ -56,8 +44,25 @@ ok() { printf " ${GREEN}✓${NC} %s\n" "$*"; } warn() { printf "${YELLOW}!${NC} %s\n" "$*" >&2; } err() { printf "${RED}✗${NC} %s\n" "$*" >&2; exit 1; } +# usage prints the flag help. We emit a static heredoc rather than +# sed-ing this file's header, because under `curl | sh` there is no +# script file on disk to read ($0 is the shell, not a path). usage() { - sed -n '3,17p' "$0" | sed 's/^# \{0,1\}//' + cat <<'EOF' +pg_hardstorage installer — one-line setup. + +Usage: + curl -sSL https://get.pghardstorage.org | sh + curl -sSL https://get.pghardstorage.org | sh -s -- --version v1.0.0 + curl -sSL https://get.pghardstorage.org | sh -s -- --bindir ~/.local/bin + +Flags (all optional): + --version install a specific release tag (default: latest) + --bindir install directory (default: /usr/local/bin, with a + ~/.local/bin fallback when the former isn't writable) + --no-verify skip checksum/signature verification (NOT advised) + --help show this help and exit +EOF exit 0 } @@ -67,8 +72,8 @@ usage() { # 302-redirects to /releases/tag/, and we read off the final # effective URL. Works with both curl and wget. resolve_latest_tag() { - local latest_url="https://github.com/${REPO}/releases/latest" - local tag="" + latest_url="https://github.com/${REPO}/releases/latest" + tag="" if command -v curl >/dev/null 2>&1; then tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null \ | sed -n 's@.*/releases/tag/\(.*\)@\1@p' | tr -d '\r')" @@ -81,7 +86,7 @@ resolve_latest_tag() { # download URL DEST — fetch with curl or wget, hard-fail on error. download() { - local url="$1" dest="$2" + url="$1"; dest="$2" if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o "$dest" elif command -v wget >/dev/null 2>&1; then @@ -104,13 +109,13 @@ sha256_of() { } main() { - local version="latest" - local install_dir="${INSTALL_DIR:-/usr/local/bin}" - local verify=1 + version="latest" + install_dir="${INSTALL_DIR:-/usr/local/bin}" + verify=1 # --- arg parsing (the old script read $1 directly, so `--version X` # was taken as the version literally; parse flags properly). --- - while [[ $# -gt 0 ]]; do + while [ $# -gt 0 ]; do case "$1" in --version) version="${2:-}"; shift 2 || err "--version needs a value" ;; --bindir) install_dir="${2:-}"; shift 2 || err "--bindir needs a value" ;; @@ -120,7 +125,6 @@ main() { esac done - local os arch case "$(uname -s)" in Linux) os="linux" ;; Darwin) os="darwin" ;; @@ -132,28 +136,27 @@ main() { *) err "Unsupported architecture: $(uname -m). pg_hardstorage supports amd64 and arm64." ;; esac # macOS ships arm64 only (matches .goreleaser.yaml's darwin/amd64 ignore). - if [[ "$os" == "darwin" && "$arch" == "amd64" ]]; then + if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then err "macOS builds are arm64 only. On an Intel Mac, install via Rosetta or build from source." fi # --- resolve the version tag --- - if [[ "$version" == "latest" ]]; then + if [ "$version" = "latest" ]; then info "Resolving the latest release" version="$(resolve_latest_tag)" - [[ -n "$version" ]] || err "Could not resolve latest release. Pass --version , or see https://github.com/${REPO}/releases" + [ -n "$version" ] || err "Could not resolve latest release. Pass --version , or see https://github.com/${REPO}/releases" fi ok "Release: ${version}" # goreleaser names archives ___.tar.gz with # the version stripped of its leading 'v' (.goreleaser.yaml uses # {{ .Version }}, which is the tag without the 'v'). - local ver_noV="${version#v}" - local tarball="pg_hardstorage_${ver_noV}_${os}_${arch}.tar.gz" - local base="https://github.com/${REPO}/releases/download/${version}" + ver_noV="${version#v}" + tarball="pg_hardstorage_${ver_noV}_${os}_${arch}.tar.gz" + base="https://github.com/${REPO}/releases/download/${version}" info "Installing pg_hardstorage ${version} for ${os}/${arch}" - local tmpdir tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT @@ -162,16 +165,15 @@ main() { || err "Download failed. Check https://github.com/${REPO}/releases/tag/${version}" # --- verification --- - if [[ "$verify" -eq 1 ]]; then + if [ "$verify" -eq 1 ]; then info "Verifying checksum" download "${base}/checksums.txt" "$tmpdir/checksums.txt" \ || err "Could not download checksums.txt — cannot verify. Re-run with --no-verify to override (not advised)." - local want got want="$(grep " ${tarball}\$" "$tmpdir/checksums.txt" | awk '{print $1}' | head -1)" - [[ -n "$want" ]] || err "No checksum entry for ${tarball} in checksums.txt." + [ -n "$want" ] || err "No checksum entry for ${tarball} in checksums.txt." got="$(sha256_of "$tmpdir/$tarball")" - if [[ "$want" != "$got" ]]; then + if [ "$want" != "$got" ]; then err "Checksum mismatch for ${tarball}! expected ${want}, got ${got}. Aborting." fi ok "SHA-256 verified" @@ -206,18 +208,18 @@ main() { info "Extracting" tar xzf "$tmpdir/$tarball" -C "$tmpdir" - local binary="$tmpdir/pg_hardstorage" - if [[ ! -f "$binary" ]]; then + binary="$tmpdir/pg_hardstorage" + if [ ! -f "$binary" ]; then binary="$tmpdir/pg_hardstorage_${ver_noV}_${os}_${arch}/pg_hardstorage" fi - [[ -f "$binary" ]] || err "Could not find pg_hardstorage binary in tarball." + [ -f "$binary" ] || err "Could not find pg_hardstorage binary in tarball." chmod +x "$binary" # --- install --- - if [[ -w "$install_dir" ]]; then + if [ -w "$install_dir" ]; then cp "$binary" "$install_dir/pg_hardstorage" ok "Installed to $install_dir/pg_hardstorage" - elif [[ ! -t 0 ]]; then + elif [ ! -t 0 ]; then # No TTY (the canonical `curl | sh` case): we can't prompt for a # sudo decision, so fall back to the user-local bin rather than # blocking on a read that returns EOF. @@ -228,15 +230,18 @@ main() { else printf " Install to ${BOLD}%s${NC} (requires sudo)? [Y/n] " "$install_dir" read -r answer - if [[ "$answer" =~ ^[Nn] ]]; then - mkdir -p "$HOME/.local/bin" - cp "$binary" "$HOME/.local/bin/pg_hardstorage" - ok "Installed to $HOME/.local/bin/pg_hardstorage" - printf " Add to PATH: ${BOLD}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}\n" - else - sudo cp "$binary" "$install_dir/pg_hardstorage" - ok "Installed to $install_dir/pg_hardstorage" - fi + case "$answer" in + [Nn]*) + mkdir -p "$HOME/.local/bin" + cp "$binary" "$HOME/.local/bin/pg_hardstorage" + ok "Installed to $HOME/.local/bin/pg_hardstorage" + printf " Add to PATH: ${BOLD}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}\n" + ;; + *) + sudo cp "$binary" "$install_dir/pg_hardstorage" + ok "Installed to $install_dir/pg_hardstorage" + ;; + esac fi printf "\n" @@ -247,4 +252,4 @@ main() { printf " https://docs.pghardstorage.org\n" } -main "$@" \ No newline at end of file +main "$@"