diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191bcc3f0..5ba8f6d8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,21 @@ jobs: - name: Check code style run: ./tools/lint.sh -c + install_sh_tests: + name: install.sh tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - name: Install bash 4+ (macOS) + if: runner.os == 'macOS' + run: brew install bash + - name: Run install.sh tests + run: bash tools/test/run.sh + build_backend_tests: name: ut-build-backend runs-on: ubuntu-latest diff --git a/tools/.rat-excludes b/tools/.rat-excludes index bea5f93f8..11e47ca79 100644 --- a/tools/.rat-excludes +++ b/tools/.rat-excludes @@ -4,6 +4,10 @@ docs .git .gitignore .gitmodules +\.bats-cache +.*\.bats$ +.*\.bash$ +\.gitkeep .*\.lock$ .venv/* .idea/* @@ -16,4 +20,6 @@ PULL_REQUEST_TEMPLATE.md .pytest_cache/* .ruff_cache/* .*\.egg-info/* -licenses/* \ No newline at end of file +licenses/* +skills/* +.*\.yaml$ \ No newline at end of file diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 000000000..c71e3fa31 --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,1886 @@ +#!/usr/bin/env bash +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +# -E (errtrace) propagates the ERR trap into functions and command +# substitutions so the failure banner fires from anywhere in the script, +# not only at the top level. +set -Eeuo pipefail + +BOLD='\033[1m' +ACCENT='\033[38;2;255;77;77m' # coral-bright +INFO='\033[38;2;136;146;176m' # text-secondary +SUCCESS='\033[38;2;0;229;204m' # cyan-bright +WARN='\033[38;2;255;176;32m' # amber +ERROR='\033[38;2;230;57;70m' # coral-mid +MUTED='\033[38;2;90;100;128m' # text-muted +NC='\033[0m' # No Color + +TMPFILES=() +cleanup_tmpfiles() { + local f + for f in "${TMPFILES[@]:-}"; do + rm -rf "$f" 2>/dev/null || true + done +} +trap cleanup_tmpfiles EXIT +# Some interactive read combinations (notably `read -e` under stdin redirection) +# can swallow SIGINT, leaving the user pressing Ctrl+C with no effect. Install +# an explicit INT trap so Ctrl+C always lands. +trap 'die_cancelled' INT + +mktempfile() { + local f + f="$(mktemp)" + TMPFILES+=("$f") + echo "$f" +} + +DOWNLOADER="" +detect_downloader() { + if command -v curl &> /dev/null; then + DOWNLOADER="curl" + return 0 + fi + if command -v wget &> /dev/null; then + DOWNLOADER="wget" + return 0 + fi + ui_error "Missing downloader (curl or wget required)" + exit 1 +} + +download_file() { + local url="$1" + local output="$2" + if [[ -z "$DOWNLOADER" ]]; then + detect_downloader + fi + if [[ "$DOWNLOADER" == "curl" ]]; then + curl -fL --progress-bar --proto '=https' --tlsv1.2 --retry 3 --max-time 900 --retry-delay 1 --retry-connrefused -o "$output" "$url" + return + fi + wget -q --show-progress --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=900 -O "$output" "$url" +} + +GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}" +GUM="" +GUM_STATUS="skipped" +GUM_REASON="" +# Persistent cache for the auto-downloaded gum binary so reruns don't +# re-download it. Honors XDG_CACHE_HOME, falls back to ~/.cache. +GUM_CACHE_ROOT="${FLINK_AGENTS_GUM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/flink-agents/gum}" + +is_non_interactive_shell() { + if [[ "${NO_PROMPT:-0}" == "1" ]]; then + return 0 + fi + if [[ ! -t 0 || ! -t 1 ]]; then + return 0 + fi + return 1 +} + +gum_is_tty() { + if [[ -n "${NO_COLOR:-}" ]]; then + return 1 + fi + if [[ "${TERM:-dumb}" == "dumb" ]]; then + return 1 + fi + if [[ -t 2 || -t 1 ]]; then + return 0 + fi + if [[ -r /dev/tty && -w /dev/tty ]]; then + return 0 + fi + return 1 +} + +gum_detect_os() { + case "$(uname -s 2>/dev/null || true)" in + Darwin) echo "Darwin" ;; + Linux) echo "Linux" ;; + *) echo "unsupported" ;; + esac +} + +gum_detect_arch() { + case "$(uname -m 2>/dev/null || true)" in + x86_64|amd64) echo "x86_64" ;; + arm64|aarch64) echo "arm64" ;; + i386|i686) echo "i386" ;; + armv7l|armv7) echo "armv7" ;; + armv6l|armv6) echo "armv6" ;; + *) echo "unknown" ;; + esac +} + +verify_sha256sum_file() { + local checksums="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum --ignore-missing -c "$checksums" >/dev/null 2>&1 + return $? + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 --ignore-missing -c "$checksums" >/dev/null 2>&1 + return $? + fi + return 1 +} + +bootstrap_gum_temp() { + GUM="" + GUM_STATUS="skipped" + GUM_REASON="" + + if is_non_interactive_shell; then + GUM_REASON="non-interactive shell (auto-disabled)" + return 1 + fi + + if ! gum_is_tty; then + GUM_REASON="terminal does not support gum UI" + return 1 + fi + + if command -v gum >/dev/null 2>&1; then + GUM="gum" + GUM_STATUS="found" + GUM_REASON="already installed" + return 0 + fi + + local cache_dir="${GUM_CACHE_ROOT}/${GUM_VERSION}" + local cached_gum="${cache_dir}/gum" + if [[ -x "$cached_gum" ]]; then + GUM="$cached_gum" + GUM_STATUS="cached" + GUM_REASON="reused from ${cache_dir}" + return 0 + fi + + if ! command -v tar >/dev/null 2>&1; then + GUM_REASON="tar not found" + return 1 + fi + + local os arch asset base gum_tmpdir extracted_path + os="$(gum_detect_os)" + arch="$(gum_detect_arch)" + if [[ "$os" == "unsupported" || "$arch" == "unknown" ]]; then + GUM_REASON="unsupported os/arch ($os/$arch)" + return 1 + fi + + asset="gum_${GUM_VERSION}_${os}_${arch}.tar.gz" + base="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}" + + gum_tmpdir="$(mktemp -d)" + TMPFILES+=("$gum_tmpdir") + + ui_info "Installing gum v${GUM_VERSION}, please wait..." + + if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then + GUM_REASON="download failed" + return 1 + fi + + if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt"; then + GUM_REASON="checksum unavailable or failed" + return 1 + fi + + if ! (cd "$gum_tmpdir" && verify_sha256sum_file "checksums.txt"); then + GUM_REASON="checksum unavailable or failed" + return 1 + fi + + if ! tar -xzf "$gum_tmpdir/$asset" -C "$gum_tmpdir" >/dev/null 2>&1; then + GUM_REASON="extract failed" + return 1 + fi + + extracted_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head -n1 || true)" + if [[ -z "$extracted_path" ]]; then + GUM_REASON="gum binary missing after extract" + return 1 + fi + + chmod +x "$extracted_path" >/dev/null 2>&1 || true + if [[ ! -x "$extracted_path" ]]; then + GUM_REASON="gum binary is not executable" + return 1 + fi + + # Promote into the persistent cache so subsequent runs skip the download. + if mkdir -p "$cache_dir" 2>/dev/null && mv "$extracted_path" "$cached_gum" 2>/dev/null; then + GUM="$cached_gum" + GUM_REASON="cached at ${cache_dir}" + else + # Cache write failed (read-only HOME etc.) — fall back to using the + # extracted binary directly. It'll get cleaned up at EXIT, costing a + # re-download next run, but the current run still works. + GUM="$extracted_path" + GUM_REASON="temp, verified (cache unavailable)" + fi + GUM_STATUS="installed" + return 0 +} + +print_gum_status() { + case "$GUM_STATUS" in + found) + ui_success "gum available (${GUM_REASON})" + ;; + cached) + ui_success "gum loaded from cache (v${GUM_VERSION})" + ;; + installed) + ui_success "gum bootstrapped (${GUM_REASON}, v${GUM_VERSION})" + ;; + *) + if [[ -n "$GUM_REASON" && "$GUM_REASON" != "non-interactive shell (auto-disabled)" ]]; then + ui_info "gum skipped (${GUM_REASON})" + fi + ;; + esac +} + +print_installer_banner() { + if [[ -n "$GUM" ]]; then + local title + title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink Agents Installer")" + "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding "1 2" "$title" + echo "" + return + fi + + echo -e "${ACCENT}${BOLD}" + echo " Apache Flink Agents Installer" + echo "" +} + +detect_os_or_die() { + OS="unknown" + if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + elif [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; then + OS="linux" + fi + + if [[ "$OS" == "unknown" ]]; then + ui_error "Unsupported operating system" + echo "This installer supports macOS and Linux (including WSL)." + exit 1 + fi + + ui_success "Detected: $OS" +} + +ui_info() { + local msg="$*" + if [[ -n "$GUM" ]]; then + "$GUM" log --level info "$msg" + else + echo -e "${MUTED}·${NC} ${msg}" + fi +} + +ui_warn() { + local msg="$*" + if [[ -n "$GUM" ]]; then + "$GUM" log --level warn "$msg" + else + echo -e "${WARN}!${NC} ${msg}" + fi +} + +ui_success() { + local msg="$*" + if [[ -n "$GUM" ]]; then + local mark + mark="$("$GUM" style --foreground "#00e5cc" --bold "✓")" + echo "${mark} ${msg}" + else + echo -e "${SUCCESS}✓${NC} ${msg}" + fi +} + +ui_error() { + local msg="$*" + if [[ -n "$GUM" ]]; then + "$GUM" log --level error "$msg" + else + echo -e "${ERROR}x${NC} ${msg}" + fi +} + +die() { + ui_error "$*" + exit 1 +} + +die_cancelled() { + # Write to stderr so the message survives `$(...)` command substitution + # — otherwise the cancellation note would be silently captured by the + # caller's variable and the user would see nothing on Ctrl+C. + ui_info "Cancelled by user" >&2 + exit 130 +} + +# Fires on any command that exits non-zero under set -e — the cases that +# would otherwise dump a tail of pip / curl / tar noise and silently exit. +# `die`/`die_cancelled` use plain `exit` (not a non-zero command), so they +# do NOT trip this trap; they print their own friendly message and exit +# straight away. That keeps the banner reserved for genuinely unexpected +# failures. +on_error() { + local rc=$1 + local line=$2 + local cmd=$3 + # Suppress ourselves if we re-enter (e.g. echo failing under set -e + # inside the handler itself). + trap - ERR + { + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if (( INSTALL_STAGE_CURRENT > 0 )); then + printf '%b Installation failed at stage %d/%d (%s).\n' \ + "${ERROR}✗${NC}" \ + "$INSTALL_STAGE_CURRENT" "$INSTALL_STAGE_TOTAL" \ + "$INSTALL_STAGE_TITLE" + else + printf '%b Installation failed.\n' "${ERROR}✗${NC}" + fi + echo "" + echo " Command: ${cmd}" + echo " Source: install.sh:${line}" + echo " Exit code: ${rc}" + echo "" + echo " Re-run with --verbose for full output, or report at:" + echo " https://github.com/apache/flink-agents/issues" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + } >&2 + exit "$rc" +} +trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR + +INSTALL_STAGE_TOTAL=5 +INSTALL_STAGE_CURRENT=0 +INSTALL_STAGE_TITLE="" + +ui_section() { + local title="$1" + if [[ -n "$GUM" ]]; then + "$GUM" style --bold --foreground "#ff4d4d" --padding "1 0" "$title" + else + echo "" + echo -e "${ACCENT}${BOLD}${title}${NC}" + fi +} + +ui_stage() { + local title="$1" + INSTALL_STAGE_CURRENT=$((INSTALL_STAGE_CURRENT + 1)) + # Remember the stage title so on_error can include it in the + # failure banner ("Installation failed at stage 3/5 (Installing + # Apache Flink)"). + INSTALL_STAGE_TITLE="$title" + ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}" +} + +UI_KV_KEY_WIDTH=24 + +ui_kv() { + local key="$1" + local value="$2" + local labeled="${key}:" + if [[ -n "$GUM" ]]; then + local key_part value_part + key_part="$("$GUM" style --foreground "#5a6480" --width "$UI_KV_KEY_WIDTH" "$labeled")" + value_part="$("$GUM" style --bold "$value")" + "$GUM" join --horizontal "$key_part" "$value_part" + else + printf "${MUTED}%-${UI_KV_KEY_WIDTH}s${NC} %s\n" "$labeled" "$value" + fi +} + +ui_celebrate() { + local msg="$1" + if [[ -n "$GUM" ]]; then + "$GUM" style --bold --foreground "#00e5cc" "$msg" + else + echo -e "${SUCCESS}${BOLD}${msg}${NC}" + fi +} + +mark_explicit() { + local var="$1" + if [[ -n "${!var:-}" ]]; then + printf -v "${var}_EXPLICIT" '%s' '1' + else + printf -v "${var}_EXPLICIT" '%s' '0' + fi +} + +mark_explicit FLINK_VERSION +mark_explicit FLINK_AGENTS_VERSION +mark_explicit INSTALL_DIR +mark_explicit VENV_DIR + +FLINK_VERSION="${FLINK_VERSION:-2.2.0}" +FLINK_AGENTS_VERSION="${FLINK_AGENTS_VERSION:-0.2.1}" +FLINK_SCALA_VERSION="${FLINK_SCALA_VERSION:-2.12}" +FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}" +# Flink Agents JARs live next to Flink on the ASF mirror network. Override +# this to point at archive.apache.org if you need a non-current release. +FLINK_AGENTS_BASE_URL="${FLINK_AGENTS_BASE_URL:-https://dlcdn.apache.org/flink}" +# Direct (non-mirrored) ASF download host. We hit it for the SHA512 sidecar +# because the mirror network does not redistribute checksum files. +FLINK_AGENTS_CHECKSUM_BASE_URL="${FLINK_AGENTS_CHECKSUM_BASE_URL:-https://downloads.apache.org/flink}" + +FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3") +FLINK_RECOMMENDED_VERSION="2.2.0" + +# Mirrors https://flink.apache.org/downloads/#apache-flink-agents +# (latest first). Note: 0.1.x only ships JARs for Flink 1.20, while 0.2.x +# ships JARs for Flink 1.20 / 2.0 / 2.1 / 2.2 — see the download page for +# the exact compatibility matrix. +FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0") +FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1" + +INSTALL_FLINK="${INSTALL_FLINK:-Ask}" +ENABLE_PYFLINK="${ENABLE_PYFLINK:-Ask}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}" +VENV_DIR="${VENV_DIR:-.flink-agents-env}" +PYTHON_BIN="${PYTHON_BIN:-}" +NO_PROMPT="${NO_PROMPT:-0}" +VERBOSE="${FLINK_AGENTS_VERBOSE:-0}" +DRY_RUN="${FLINK_AGENTS_DRY_RUN:-0}" +VERIFY_INSTALL="${FLINK_AGENTS_VERIFY_INSTALL:-0}" +HELP=0 +PYFLINK_ACTUALLY_ENABLED=0 + +print_usage() { + cat </install.sh | bash -s -- [options] + +Options: + --non-interactive Non-interactive mode (accept all defaults) + --install-flink Download and install Apache Flink + --enable-pyflink Enable PyFlink and install Python packages + --verbose Print debug output (set -x) + --dry-run Print install plan without making changes + --verify Run post-install verification checks + --python Path to a Python3 interpreter (overrides PATH lookup) + --flink-version Apache Flink version (e.g. 2.2.0); overrides the interactive picker + --flink-agents-version + Flink Agents version (default: ${FLINK_AGENTS_RECOMMENDED_VERSION}); overrides the picker + --help, -h Show this help + +Environment variables: + FLINK_VERSION Flink version + FLINK_AGENTS_VERSION Flink Agents version to install + FLINK_SCALA_VERSION Scala version suffix (default: 2.12) + FLINK_BASE_URL Mirror base URL for Flink (default: https://dlcdn.apache.org/flink) + FLINK_AGENTS_BASE_URL Mirror base URL for Flink Agents JARs (default: https://dlcdn.apache.org/flink) + FLINK_AGENTS_CHECKSUM_BASE_URL Direct ASF URL for SHA512 sidecars (default: https://downloads.apache.org/flink) + INSTALL_FLINK Ask|Yes|No (default: Ask) + ENABLE_PYFLINK Ask|Yes|No (default: Ask) + INSTALL_DIR Flink install directory (default: \$HOME/.local/flink) + VENV_DIR Python venv directory (default: .flink-agents-env) + PYTHON_BIN Path to Python3 interpreter (default: auto-detect python3 on PATH) + NO_PROMPT 1 to disable all prompts + FLINK_AGENTS_VERBOSE 1 to enable verbose output + FLINK_AGENTS_DRY_RUN 1 to enable dry-run mode + FLINK_AGENTS_VERIFY_INSTALL 1 to enable post-install verification + +Examples: + bash install.sh --install-flink --enable-pyflink --non-interactive + FLINK_VERSION=2.2.0 bash install.sh --verbose + bash install.sh --dry-run +EOF +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +# Normalize a filesystem path so downstream string interpolation can rely on +# a consistent shape. Empty input is passed through (callers handle "user +# entered nothing"). Otherwise: +# - "~" prefix expands to $HOME +# - relative paths are anchored at $PWD +# - "/./" segments and trailing "/." are folded away +# - trailing slashes are stripped, except for the root "/" +# - runs of '/' collapse to a single '/' +# +# Implementation note: the folding steps use sed instead of bash +# `${var//pattern/replacement}`. Bash 3.2 (macOS /bin/bash) does not +# strip the backslash from an escaped slash in the replacement, so +# `${p//\/.\//\/}` leaves a literal `\/` in the result (concretely: +# normalize_path "./" under PWD=/tmp/x produced /tmp/x\ on macOS). +# sed sidesteps the parameter-expansion quirk entirely. +normalize_path() { + local p="$1" + if [[ -z "$p" ]]; then + printf '' + return 0 + fi + p="${p/#\~/$HOME}" + if [[ "$p" != /* ]]; then + p="$PWD/$p" + fi + p="$(printf '%s' "$p" | sed -E 's|/+|/|g; s|/\./|/|g; s|/\.$||; s|/+$||')" + [[ -z "$p" ]] && p='/' + printf '%s' "$p" +} + +is_valid_tgz() { + local archive="$1" + [[ -f "$archive" ]] || return 1 + tar -tzf "$archive" >/dev/null 2>&1 +} + +is_promptable() { + if [[ "$NO_PROMPT" == "1" ]]; then + return 1 + fi + if [[ -r /dev/tty && -w /dev/tty ]]; then + return 0 + fi + return 1 +} + +choose_install_method_interactive() { + local prompt="$1" + + if ! is_promptable; then + return 1 + fi + + if [[ -n "$GUM" ]] && gum_is_tty; then + local selection _rc=0 + selection="$("$GUM" choose \ + --header "$prompt" \ + --cursor-prefix "❯ " \ + "Yes" "No" < /dev/tty)" || _rc=$? + (( _rc == 0 )) || die_cancelled + [[ "$selection" == "Yes" ]] + return + fi + + local answer="" + printf '%s [y/n]: ' "$prompt" > /dev/tty + read -r answer < /dev/tty || die_cancelled + + [[ "$answer" =~ ^[Yy]$ ]] +} + +prompt_flink_version_interactive() { + if ! is_promptable; then + return 1 + fi + + local labels=() + local v + for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do + if [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]]; then + labels+=("$v (recommended)") + else + labels+=("$v") + fi + done + + local selection="" + if [[ -n "$GUM" ]] && gum_is_tty; then + local _rc=0 + selection="$("$GUM" choose \ + --header "Select Flink version" \ + --cursor-prefix "❯ " \ + "${labels[@]}" < /dev/tty)" || _rc=$? + (( _rc == 0 )) || die_cancelled + selection="${selection%% *}" + else + printf 'Select Flink version:\n' > /dev/tty + local i=1 + for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do + local suffix="" + [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]] && suffix=" (recommended)" + printf ' %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty + i=$((i+1)) + done + local answer="" + printf 'Enter choice [1-%d, default %s]: ' \ + "${#FLINK_SUPPORTED_VERSIONS[@]}" "$FLINK_RECOMMENDED_VERSION" > /dev/tty + read -r answer < /dev/tty || die_cancelled + if [[ "$answer" =~ ^[0-9]+$ ]] \ + && (( answer >= 1 && answer <= ${#FLINK_SUPPORTED_VERSIONS[@]} )); then + selection="${FLINK_SUPPORTED_VERSIONS[$((answer-1))]}" + fi + fi + + if [[ -z "$selection" ]]; then + selection="$FLINK_RECOMMENDED_VERSION" + fi + FLINK_VERSION="$selection" + return 0 +} + +prompt_flink_agents_version_interactive() { + if ! is_promptable; then + return 1 + fi + + local labels=() + local v + for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do + if [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]]; then + labels+=("$v (recommended)") + else + labels+=("$v") + fi + done + + local selection="" + if [[ -n "$GUM" ]] && gum_is_tty; then + local _rc=0 + selection="$("$GUM" choose \ + --header "Select Flink Agents version" \ + --cursor-prefix "❯ " \ + "${labels[@]}" < /dev/tty)" || _rc=$? + (( _rc == 0 )) || die_cancelled + selection="${selection%% *}" + else + printf 'Select Flink Agents version:\n' > /dev/tty + local i=1 + for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do + local suffix="" + [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]] && suffix=" (recommended)" + printf ' %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty + i=$((i+1)) + done + local answer="" + printf 'Enter choice [1-%d, default %s]: ' \ + "${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]}" "$FLINK_AGENTS_RECOMMENDED_VERSION" > /dev/tty + read -r answer < /dev/tty || die_cancelled + if [[ "$answer" =~ ^[0-9]+$ ]] \ + && (( answer >= 1 && answer <= ${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]} )); then + selection="${FLINK_AGENTS_SUPPORTED_VERSIONS[$((answer-1))]}" + fi + fi + + if [[ -z "$selection" ]]; then + selection="$FLINK_AGENTS_RECOMMENDED_VERSION" + fi + FLINK_AGENTS_VERSION="$selection" + return 0 +} + +# Populate FLINK_VERSION from an existing FLINK_HOME. Tries to parse +# `lib/flink-dist-.jar` first because it's instant; falls back to +# Extract the major.minor portion of a Flink version string. +# 2.2.0 -> 2.2 +# 2.2.0-SNAPSHOT -> 2.2 +# 2.2-SNAPSHOT -> 2.2 (local source builds, no patch number) +# 2.1-rc1 -> 2.1 +# Returns empty string on no match. Note: replaces the older +# `${FLINK_VERSION%.*}` trick, which silently produced "2" for "2.2-SNAPSHOT". +flink_major_minor() { + printf '%s' "$1" | sed -E -n 's/^([0-9]+\.[0-9]+).*/\1/p' +} + +# True (rc=0) when the version string carries a pre-release suffix +# (-SNAPSHOT, -rc1, -dev, -beta-2, ...). PyPI only carries finished +# release wheels for apache-flink, so we need to know when to fall +# back from `==exact` to `~=X.Y.0` (compatible release). +is_snapshot_version() { + local v="${1:-}" + [[ -n "$v" && "$v" == *-* ]] +} + +# `bin/flink --version`, which is authoritative but spins up a JVM and can +# take 3-10s on cold start. Returns 0 on success. +detect_flink_version_from_home() { + [[ -n "${FLINK_HOME:-}" && -d "$FLINK_HOME" ]] || return 1 + + # A Flink version on the wire is roughly: + # .(.)?(-)? + # Suffix examples: SNAPSHOT, rc1, beta-2, 20251115. Anchor it loose + # enough to handle local source builds (flink-dist-2.2-SNAPSHOT.jar). + local ver_re='[0-9]+\.[0-9]+(\.[0-9]+)?(-[A-Za-z0-9._]+)?' + local version="" + + # Fast path: filename inspection. Avoids JVM startup entirely. + local jar + for jar in "$FLINK_HOME/lib"/flink-dist-[0-9]*.jar; do + [[ -f "$jar" ]] || continue + version="$(basename "$jar" | sed -E -n "s/^flink-dist-(${ver_re})\.jar$/\1/p")" + [[ -n "$version" ]] && break + done + + # Slow path: ask the Flink CLI. JVM startup means a few seconds of + # silence, so signal what's happening. + if [[ -z "$version" && -x "$FLINK_HOME/bin/flink" ]]; then + ui_info "Detecting Flink version (running '${FLINK_HOME}/bin/flink --version', this may take a few seconds)..." + local out + out="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)" + version="$(printf '%s\n' "$out" | sed -E -n "s/.*Version:[[:space:]]*(${ver_re}).*/\1/p" | head -n1)" + fi + + if [[ -z "$version" ]]; then + return 1 + fi + + FLINK_VERSION="$version" + return 0 +} + +plan_flink() { + case "$INSTALL_FLINK" in + Yes|No) + ;; + Ask) + if choose_install_method_interactive "Do you want this script to download and install Apache Flink?"; then + INSTALL_FLINK=Yes + else + INSTALL_FLINK=No + fi + ;; + *) + die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use: Ask|Yes|No" + ;; + esac + + if [[ "$INSTALL_FLINK" == "No" ]]; then + if [[ -n "${FLINK_HOME:-}" ]]; then + FLINK_HOME="$(normalize_path "$FLINK_HOME")" + fi + if is_promptable; then + while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d "${FLINK_HOME}/lib" ]]; do + if [[ -n "${FLINK_HOME:-}" ]]; then + if [[ ! -d "${FLINK_HOME}" ]]; then + ui_warn "Path does not exist: ${FLINK_HOME}" + else + ui_warn "Not a valid Flink home (missing 'lib' directory): ${FLINK_HOME}" + fi + fi + FLINK_HOME="$(prompt_path_input "Enter the path to your existing FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")" + done + export FLINK_HOME + fi + [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set." + [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME" + [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib directory): $FLINK_HOME" + ui_success "FLINK_HOME accepted: $FLINK_HOME" + if detect_flink_version_from_home; then + ui_success "Detected Flink version: $FLINK_VERSION" + else + ui_warn "Could not auto-detect Flink version from $FLINK_HOME; assuming ${FLINK_VERSION}." + ui_warn "If JAR copy fails, set FLINK_VERSION explicitly (e.g. FLINK_VERSION=2.1.1 bash install.sh)." + fi + FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")" + return + fi + + if [[ "$FLINK_VERSION_EXPLICIT" -eq 0 ]]; then + prompt_flink_version_interactive || true + fi + + if [[ "$INSTALL_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then + INSTALL_DIR="$(prompt_path_choice_interactive \ + "Choose Flink install directory" \ + "$INSTALL_DIR" \ + "/path/to/flink-install-dir")" + fi + + INSTALL_DIR="$(normalize_path "$INSTALL_DIR")" + FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}" + FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")" +} + +plan_flink_agents() { + if [[ "$FLINK_AGENTS_VERSION_EXPLICIT" -eq 0 ]]; then + prompt_flink_agents_version_interactive || true + fi +} + +# Classify a candidate VENV_DIR path. Echoes one of: +# new — path doesn't exist; caller should mkdir + python -m venv +# empty — empty directory; safe to use as venv root +# venv — already a real Python venv (pyvenv.cfg marker present) +# nonempty — directory contains foreign files; caller MUST re-prompt +# file — path is a regular file or invalid; caller MUST re-prompt +# Exit code: 0 for the safe-to-use cases, 1 for the must-re-prompt cases. +# +# We only treat pyvenv.cfg as the venv marker — `bin/activate` alone is +# not enough since arbitrary projects sometimes ship a file by that name. +validate_venv_dir() { + local p="$1" + if [[ -z "$p" ]]; then + printf 'file' + return 1 + fi + if [[ ! -e "$p" ]]; then + printf 'new' + return 0 + fi + if [[ ! -d "$p" ]]; then + printf 'file' + return 1 + fi + if [[ -f "$p/pyvenv.cfg" ]]; then + printf 'venv' + return 0 + fi + # Directory exists. Empty iff no entries including dotfiles. + local entries + entries="$(find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)" + if [[ -z "$entries" ]]; then + printf 'empty' + return 0 + fi + printf 'nonempty' + return 1 +} + +# Drive the "Choose Python venv directory" picker, then loop until the +# user picks a path that's either non-existent, empty, or an actual +# Python venv. Sets the global VENV_DIR. Caller must already have +# ensured we're in an interactive shell (is_promptable). +prompt_and_validate_venv_dir() { + local seed_default="$1" + VENV_DIR="$(prompt_path_choice_interactive \ + "Choose Python venv directory" \ + "$seed_default" \ + "/path/to/venv")" + VENV_DIR="$(normalize_path "$VENV_DIR")" + local kind + while true; do + kind="$(validate_venv_dir "$VENV_DIR")" || true + case "$kind" in + new|empty|venv) return 0 ;; + nonempty) + ui_warn "${VENV_DIR} already exists and is not a Python venv. Pick a different path, or remove its contents and try again." + ;; + file|*) + ui_warn "${VENV_DIR} is not a directory. Pick a different path." + ;; + esac + VENV_DIR="$(prompt_path_input \ + "Enter Python venv directory" \ + "/path/to/venv")" + VENV_DIR="$(normalize_path "$VENV_DIR")" + done +} + +plan_pyflink() { + case "$ENABLE_PYFLINK" in + Yes|No) + ;; + Ask) + if choose_install_method_interactive "Create a Python venv with PyFlink and flink-agents? (Only needed for Python API users; Java users can select No)"; then + ENABLE_PYFLINK=Yes + else + ENABLE_PYFLINK=No + fi + ;; + *) + die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use: Ask|Yes|No" + ;; + esac + + if [[ "$ENABLE_PYFLINK" == "No" ]]; then + return + fi + + PYFLINK_ACTUALLY_ENABLED=1 + + # Validate VENV_DIR before doing anything Python-related — if the + # user picked a bad path we want to bail before we make them resolve + # a Python interpreter, and certainly before stage 3 downloads Flink. + if [[ "$VENV_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then + prompt_and_validate_venv_dir "$VENV_DIR" + else + VENV_DIR="$(normalize_path "$VENV_DIR")" + # No prompt available (--non-interactive / NO_PROMPT / explicit + # VENV_DIR). Refuse to scribble into a foreign directory; tell + # the caller exactly what's wrong instead of silently mixing the + # venv into their codebase. + local kind + kind="$(validate_venv_dir "$VENV_DIR")" || true + case "$kind" in + new|empty|venv) ;; + nonempty) + die "VENV_DIR=${VENV_DIR} already exists and is not a Python venv. Set VENV_DIR to a new or empty path, or to an existing venv." + ;; + file|*) + die "VENV_DIR=${VENV_DIR} is not a directory." + ;; + esac + fi + + resolve_python +} + +# Echo one of: "confirm" | "edit" | "cancel" +confirm_plan_action_interactive() { + if [[ -n "$GUM" ]] && gum_is_tty; then + local selection _rc=0 + selection="$("$GUM" choose \ + --header "Proceed with installation?" \ + --cursor-prefix "❯ " \ + "Proceed" "Edit a setting" "Cancel" < /dev/tty)" || _rc=$? + (( _rc == 0 )) || die_cancelled + case "$selection" in + Proceed) echo confirm ;; + "Edit a setting") echo edit ;; + *) echo cancel ;; + esac + return + fi + + printf 'Proceed with installation?\n' > /dev/tty + printf ' 1) Proceed\n' > /dev/tty + printf ' 2) Edit a setting\n' > /dev/tty + printf ' 3) Cancel\n' > /dev/tty + local answer="" + printf 'Enter choice [1-3, default 1]: ' > /dev/tty + read -r answer < /dev/tty || die_cancelled + case "$answer" in + 2) echo edit ;; + 3) echo cancel ;; + *) echo confirm ;; + esac +} + +# Prompt the user to pick a Flink home interactively. Loops until a valid +# path (exists and has a lib/ subdir) is provided. Mirrors the loop in +# plan_flink so the two stay in sync. +edit_prompt_flink_home() { + FLINK_HOME="" + while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d "${FLINK_HOME}/lib" ]]; do + if [[ -n "${FLINK_HOME:-}" ]]; then + if [[ ! -d "${FLINK_HOME}" ]]; then + ui_warn "Path does not exist: ${FLINK_HOME}" + else + ui_warn "Not a valid Flink home (missing 'lib' directory): ${FLINK_HOME}" + fi + fi + FLINK_HOME="$(prompt_path_input "Enter the path to your existing FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")" + done + export FLINK_HOME +} + +# Show a menu of currently-applicable plan fields and re-prompt for the +# selected one. After the edit, recomputes FLINK_HOME / FLINK_MAJOR_MINOR +# so the next show_install_plan reflects the change. +# Quote a value for safe re-sourcing by the parent shell via single-quoted +# assignment. Any embedded single quotes are escaped by closing/reopening +# the quoted string. +edit_plan_quote() { + local v="$1" + printf "'%s'" "${v//\'/\'\\\'\'}" +} + +# Write the subset of plan variables we may have modified to a sourceable +# state file. Called from inside the edit subshell on a successful action. +edit_plan_dump_state() { + local out="$1" + { + printf 'INSTALL_FLINK=%s\n' "$(edit_plan_quote "$INSTALL_FLINK")" + printf 'FLINK_VERSION=%s\n' "$(edit_plan_quote "$FLINK_VERSION")" + printf 'INSTALL_DIR=%s\n' "$(edit_plan_quote "$INSTALL_DIR")" + printf 'FLINK_HOME=%s\n' "$(edit_plan_quote "${FLINK_HOME:-}")" + printf 'FLINK_MAJOR_MINOR=%s\n' "$(edit_plan_quote "${FLINK_MAJOR_MINOR:-}")" + printf 'ENABLE_PYFLINK=%s\n' "$(edit_plan_quote "$ENABLE_PYFLINK")" + printf 'PYFLINK_ACTUALLY_ENABLED=%s\n' "$(edit_plan_quote "$PYFLINK_ACTUALLY_ENABLED")" + printf 'VENV_DIR=%s\n' "$(edit_plan_quote "$VENV_DIR")" + printf 'PYTHON_BIN=%s\n' "$(edit_plan_quote "${PYTHON_BIN:-}")" + printf 'FLINK_AGENTS_VERSION=%s\n' "$(edit_plan_quote "$FLINK_AGENTS_VERSION")" + } > "$out" +} + +# Show a menu of currently-applicable plan fields and re-prompt for the +# selected one. The entire body runs inside a `()` subshell so that an +# ESC anywhere — top-level menu or any nested sub-prompt — only terminates +# this menu (subshell exits 130, caller treats it as "back"). Successful +# edits dump their state to a file that the caller sources back. +# Ctrl+C is delivered to the whole process group; the parent's INT trap +# fires die_cancelled and exits the installer before we ever inspect $rc. +edit_plan_interactive() { + local state_file + state_file="$(mktempfile)" + + # `set -e` is active, so we MUST catch any non-zero exit from the + # subshell with `|| rc=$?` — otherwise the script would terminate the + # moment a sub-prompt's ESC bubbles `exit 130` up through the subshell, + # and the parent would never get to interpret it as "back". + local rc=0 + ( + local labels=() + local actions=() + + labels+=("Flink Agents version: $FLINK_AGENTS_VERSION") + actions+=("flink_agents_version") + + labels+=("Install Flink: $INSTALL_FLINK") + actions+=("install_flink") + + if [[ "$INSTALL_FLINK" == "Yes" ]]; then + labels+=("Flink version: $FLINK_VERSION") + actions+=("flink_version") + labels+=("Install directory: $INSTALL_DIR") + actions+=("install_dir") + else + labels+=("FLINK_HOME: ${FLINK_HOME:-}") + actions+=("flink_home") + fi + + labels+=("Enable PyFlink: $ENABLE_PYFLINK") + actions+=("enable_pyflink") + + if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then + labels+=("Venv directory: $VENV_DIR") + actions+=("venv_dir") + fi + + local picked_index=-1 + if [[ -n "$GUM" ]] && gum_is_tty; then + local _rc=0 + local selected="" + selected="$("$GUM" choose \ + --header "Edit a setting (↑/↓ navigate · Enter select · Esc to go back)" \ + --cursor-prefix "❯ " \ + "${labels[@]}" < /dev/tty)" || _rc=$? + # ESC inside this menu — exit the subshell so the caller treats + # it as "back to confirm". The exit code we use here (130) is + # what `gum` returns on Esc; we just propagate it. + if (( _rc != 0 )); then + exit "$_rc" + fi + local i=0 + for l in "${labels[@]}"; do + if [[ "$l" == "$selected" ]]; then + picked_index=$i + break + fi + i=$((i+1)) + done + else + printf 'Edit a setting (blank or "b" to go back):\n' > /dev/tty + local i=1 + for l in "${labels[@]}"; do + printf ' %d) %s\n' "$i" "$l" > /dev/tty + i=$((i+1)) + done + local answer="" + printf 'Enter choice [1-%d]: ' "${#labels[@]}" > /dev/tty + read -r answer < /dev/tty || die_cancelled + case "$answer" in + ""|b|B|back|0) exit 130 ;; + esac + if [[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= ${#labels[@]} )); then + picked_index=$((answer - 1)) + fi + fi + + if (( picked_index < 0 )); then + # No valid selection — same as back. + exit 130 + fi + + local action="${actions[$picked_index]}" + case "$action" in + flink_agents_version) + prompt_flink_agents_version_interactive || true + ;; + install_flink) + if choose_install_method_interactive "Install Flink?"; then + if [[ "$INSTALL_FLINK" != "Yes" ]]; then + INSTALL_FLINK=Yes + # Coming from No → Yes: caller had no version/dir, so + # walk them through both immediately instead of + # forcing two more menu trips. + prompt_flink_version_interactive || true + INSTALL_DIR="$(prompt_path_choice_interactive \ + "Choose Flink install directory" \ + "$INSTALL_DIR" \ + "/path/to/flink-install-dir")" + INSTALL_DIR="$(normalize_path "$INSTALL_DIR")" + fi + FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}" + else + INSTALL_FLINK=No + edit_prompt_flink_home + detect_flink_version_from_home || ui_warn "Could not auto-detect Flink version; keeping ${FLINK_VERSION}" + fi + FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")" + ;; + flink_version) + prompt_flink_version_interactive || true + FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}" + FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")" + ;; + install_dir) + INSTALL_DIR="$(prompt_path_choice_interactive \ + "Choose Flink install directory" \ + "$INSTALL_DIR" \ + "/path/to/flink-install-dir")" + INSTALL_DIR="$(normalize_path "$INSTALL_DIR")" + FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}" + ;; + flink_home) + edit_prompt_flink_home + ;; + enable_pyflink) + if choose_install_method_interactive "Enable PyFlink?"; then + if [[ "$ENABLE_PYFLINK" != "Yes" ]]; then + ENABLE_PYFLINK=Yes + PYFLINK_ACTUALLY_ENABLED=1 + resolve_python + prompt_and_validate_venv_dir "$VENV_DIR" + fi + else + ENABLE_PYFLINK=No + fi + ;; + venv_dir) + prompt_and_validate_venv_dir "$VENV_DIR" + ;; + esac + + edit_plan_dump_state "$state_file" + ) || rc=$? + + if (( rc != 0 )); then + # Any non-zero exit from the subshell means we never reached the + # state dump — treat it as "back to confirm". (Ctrl+C would have + # killed the parent before this point via the INT trap.) + return 0 + fi + + # shellcheck disable=SC1090 + source "$state_file" +} + +confirm_install_plan() { + if [[ "$NO_PROMPT" == "1" ]] || ! is_promptable; then + return 0 + fi + while true; do + local action + # Capture stdout AND honor the child's exit code. The child runs in a + # command-substitution subshell, which does NOT inherit the parent's + # `trap die_cancelled INT`. When Ctrl+C lands inside `gum choose`, + # the subshell exits 130 but its stdout is empty — without this `||`, + # the case-statement falls through to the default arm and we'd loop + # forever re-prompting instead of exiting. + action="$(confirm_plan_action_interactive)" || exit 130 + case "$action" in + confirm) return 0 ;; + edit) + edit_plan_interactive + # Re-display the updated plan so the user sees their change + # in context before re-prompting. + show_install_plan + ;; + cancel) + ui_info "Installation cancelled by user." + exit 0 + ;; + esac + done +} + +install_flink_if_needed() { + if [[ "$INSTALL_FLINK" == "No" ]]; then + ui_info "Skipping Flink download/install (using existing FLINK_HOME)." + ui_success "FLINK_HOME resolved: $FLINK_HOME" + return + fi + + local ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz" + local ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}" + + detect_downloader + require_cmd tar + + if ! mkdir -p "$INSTALL_DIR"; then + die "Failed to create INSTALL_DIR=$INSTALL_DIR. Please run with proper permissions or set INSTALL_DIR to a writable path." + fi + if [[ -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]] && ! is_valid_tgz "${INSTALL_DIR}/${ARCHIVE_NAME}"; then + ui_warn "Existing archive is corrupted; re-downloading: ${INSTALL_DIR}/${ARCHIVE_NAME}" + rm -f "${INSTALL_DIR}/${ARCHIVE_NAME}" + fi + + if [[ ! -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]]; then + ui_info "Downloading ${ARCHIVE_URL}" + download_file "$ARCHIVE_URL" "${INSTALL_DIR}/${ARCHIVE_NAME}" + else + ui_info "Reusing existing archive: ${INSTALL_DIR}/${ARCHIVE_NAME}" + fi + + if ! is_valid_tgz "${INSTALL_DIR}/${ARCHIVE_NAME}"; then + die "Downloaded archive is invalid or truncated: ${INSTALL_DIR}/${ARCHIVE_NAME}" + fi + + if [[ -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]] && [[ ! -d "${INSTALL_DIR}/flink-${FLINK_VERSION}/lib" ]]; then + ui_warn "Existing Flink home is incomplete; re-extracting: ${INSTALL_DIR}/flink-${FLINK_VERSION}" + rm -rf "${INSTALL_DIR}/flink-${FLINK_VERSION}" + fi + + if [[ ! -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]]; then + ui_info "Extracting Flink to ${INSTALL_DIR}" + tar -xzf "${INSTALL_DIR}/${ARCHIVE_NAME}" -C "$INSTALL_DIR" + else + ui_info "Reusing existing Flink home: ${INSTALL_DIR}/flink-${FLINK_VERSION}" + fi + + export FLINK_HOME + ui_success "FLINK_HOME resolved: $FLINK_HOME" +} + +prompt_path_input() { + local header="$1" + # Placeholder kept for backward compatibility with the previous signature; + # readline does not surface placeholders the way `gum input` did, so we + # weave the hint into the prompt label instead. + local placeholder="${2:-}" + local input="" + printf '%s\n' "$header" > /dev/tty + if [[ -n "$placeholder" ]]; then + printf ' (example: %s)\n' "$placeholder" > /dev/tty + fi + IFS= read -e -r -p " path> " input < /dev/tty || die_cancelled + printf '%s' "$(normalize_path "$input")" +} + +prompt_path_choice_interactive() { + local header="$1" + local default_path="$2" + local placeholder="$3" + + if ! is_promptable; then + printf '%s' "$default_path" + return 0 + fi + + local default_label="Default: ${default_path}" + local custom_label="Custom..." + local selection="" + + if [[ -n "$GUM" ]] && gum_is_tty; then + local _rc=0 + selection="$("$GUM" choose \ + --header "$header" \ + --cursor-prefix "❯ " \ + "$default_label" "$custom_label" < /dev/tty)" || _rc=$? + (( _rc == 0 )) || die_cancelled + else + printf '%s\n' "$header" > /dev/tty + printf ' 1) %s\n' "$default_label" > /dev/tty + printf ' 2) %s\n' "$custom_label" > /dev/tty + local answer="" + printf 'Enter choice [1-2, default 1]: ' > /dev/tty + read -r answer < /dev/tty || die_cancelled + case "$answer" in + 2) selection="$custom_label" ;; + *) selection="$default_label" ;; + esac + fi + + if [[ "$selection" != "$custom_label" ]]; then + printf '%s' "$(normalize_path "$default_path")" + return 0 + fi + + local input="" + printf '%s\n' "$header" > /dev/tty + IFS= read -e -r -p " path> " input < /dev/tty || die_cancelled + + if [[ -z "$input" ]]; then + ui_warn "Empty path; falling back to default: $default_path" + printf '%s' "$(normalize_path "$default_path")" + return 0 + fi + + printf '%s' "$(normalize_path "$input")" +} + +copy_pyflink_jar() { + local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar" + [[ -f "$pyflink_jar" ]] || die "Missing required PyFlink jar: $pyflink_jar" + + ui_info "Copying PyFlink jar into Flink lib" + cp "$pyflink_jar" "$FLINK_HOME/lib/" +} + +create_venv() { + local venv_err + venv_err="$(mktempfile)" + if "$PYTHON_BIN" -m venv "$VENV_DIR" 2>"$venv_err"; then + return 0 + fi + + ui_error "Failed to create virtual environment at $VENV_DIR" + if [[ -s "$venv_err" ]]; then + sed 's/^/ /' "$venv_err" >&2 || true + fi + die "Virtual environment creation failed" +} + +# Run `python -m pip install ` with reduced terminal noise. +# Strategy depends on environment: +# VERBOSE=1 : full output, pass-through +# gum available + tty : `gum spin --show-error` (spinner; pip output +# is hidden unless pip exits non-zero) +# plain tty (no gum) : single rolling status line via \r\033[K; +# full output goes to a temp log and is shown +# only on failure +# non-tty (CI / piped) : pip --quiet (errors still surface) +pip_install_quiet() { + local pkgs=("$@") + + if [[ "$VERBOSE" == "1" ]]; then + python -m pip install "${pkgs[@]}" + return + fi + + if [[ -n "$GUM" ]] && gum_is_tty; then + "$GUM" spin --show-error \ + --title "pip install ${pkgs[*]}" \ + -- python -m pip install "${pkgs[@]}" + return + fi + + if [[ ! -t 1 ]]; then + python -m pip install -q "${pkgs[@]}" + return + fi + + local log + log="$(mktempfile)" + local cols max + cols="$(tput cols 2>/dev/null || echo 80)" + local prefix=" · " + max=$((cols - ${#prefix} - 4)) + (( max < 20 )) && max=20 + + local rc=0 + { + python -m pip install "${pkgs[@]}" 2>&1 \ + | while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + printf '\r\033[K%s%s' "$prefix" "${line:0:$max}" + done + } || rc=$? + + # Clear the rolling status line. + printf '\r\033[K' + + if (( rc != 0 )); then + ui_error "pip install failed; full output:" + sed 's/^/ /' "$log" >&2 + return $rc + fi +} + +setup_python_env() { + if [[ ! -d "$VENV_DIR" ]]; then + ui_info "Creating virtual environment: $VENV_DIR" + create_venv + else + ui_info "Reusing existing virtual environment: $VENV_DIR" + fi + + source "$VENV_DIR/bin/activate" + + export PIP_PROGRESS_BAR=off + export PIP_NO_COLOR=1 + export PIP_NO_INPUT=1 + + # PyPI only ships finished release wheels for apache-flink, so a + # source-built FLINK_VERSION like "2.1-SNAPSHOT" / "2.0.0-rc1" has + # no matching distribution. Fall back to the compatible-release + # operator (~= X.Y.0 ↔ >=X.Y.0, " (single line). Best-effort: a +# missing sidecar or missing sha512 tool warns but does not fail (mirrors +# may not always carry the checksum on time). +verify_flink_agents_jar_sha512() { + local jar="$1" + local sha_url="$2" + local sha_file + sha_file="$(mktempfile)" + + if ! download_file "$sha_url" "$sha_file" >/dev/null 2>&1; then + ui_warn "SHA512 sidecar unavailable (${sha_url}); skipping verification" + return 0 + fi + + local expected actual + expected="$(awk 'NR==1 {print $1}' "$sha_file" 2>/dev/null || true)" + if [[ -z "$expected" ]]; then + ui_warn "SHA512 sidecar is empty; skipping verification" + return 0 + fi + # SHA-512 hex is exactly 128 lowercase/upper hex chars. If the sidecar + # didn't look like a real ASF .sha512 file, warn instead of failing. + if [[ ! "$expected" =~ ^[A-Fa-f0-9]{128}$ ]]; then + ui_warn "SHA512 sidecar at ${sha_url} is not a valid hash; skipping verification" + return 0 + fi + + if command -v sha512sum >/dev/null 2>&1; then + actual="$(sha512sum "$jar" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 512 "$jar" | awk '{print $1}')" + else + ui_warn "Neither sha512sum nor shasum available; skipping SHA512 verification" + return 0 + fi + + # Use `tr` for case-folding instead of `${var,,}` — the latter is + # bash 4+ only, and macOS still ships /bin/bash 3.2. + local expected_lc actual_lc + expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" + actual_lc="$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" + if [[ "$expected_lc" != "$actual_lc" ]]; then + die "SHA512 mismatch for $(basename "$jar") (expected ${expected:0:16}…, got ${actual:0:16}…)" + fi + ui_success "SHA512 verified" +} + +# Download flink-agents-dist-flink--.jar +# directly from the ASF mirror into FLINK_HOME/lib. This replaces the older +# wheel-extraction path, so Java-only users no longer need Python at all. +install_flink_agents_jar() { + [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set" + [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib): $FLINK_HOME" + + local relpath jar_name target url sha_url + relpath="$(flink_agents_jar_relpath)" + jar_name="$(basename "$relpath")" + target="$FLINK_HOME/lib/$jar_name" + url="${FLINK_AGENTS_BASE_URL}/${relpath}" + sha_url="${FLINK_AGENTS_CHECKSUM_BASE_URL}/${relpath}.sha512" + + detect_downloader + + if [[ -f "$target" ]]; then + ui_info "Reusing existing JAR: ${target}" + verify_flink_agents_jar_sha512 "$target" "$sha_url" || true + return 0 + fi + + ui_info "Downloading ${url}" + if ! download_file "$url" "$target"; then + rm -f "$target" 2>/dev/null || true + die "Failed to download flink-agents JAR from ${url}" + fi + + if [[ ! -s "$target" ]]; then + rm -f "$target" 2>/dev/null || true + die "Downloaded an empty file: ${target}" + fi + + verify_flink_agents_jar_sha512 "$target" "$sha_url" + ui_success "Installed: ${jar_name} → ${FLINK_HOME}/lib" +} + +check_java() { + if ! command -v java &>/dev/null; then + ui_warn "Java not found on PATH" + if [[ "$OS" == "macos" ]]; then + ui_info "Install Java: brew install openjdk@17" + elif [[ "$OS" == "linux" ]]; then + ui_info "Install Java: sudo apt install openjdk-17-jdk (Debian/Ubuntu)" + ui_info " sudo yum install java-17-openjdk-devel (RHEL/CentOS)" + fi + ui_info "Flink requires Java 11+ to run" + return 1 + fi + + local java_version_output + java_version_output="$(java -version 2>&1 | head -n1)" + local java_major="" + java_major="$(echo "$java_version_output" | sed -E -n 's/.*version "?([0-9]+).*/\1/p')" + + if [[ -z "$java_major" ]]; then + ui_warn "Could not parse Java version from: $java_version_output" + return 1 + fi + + if [[ "$java_major" -lt 11 ]]; then + ui_error "Java $java_major detected, but Flink requires Java 11+" + ui_info "Please upgrade your Java installation" + return 1 + fi + + ui_success "Java $java_major found" + + if [[ -z "${JAVA_HOME:-}" ]]; then + ui_info "JAVA_HOME is not set (Flink will try to detect it automatically)" + fi + return 0 +} + +validate_python_bin() { + local bin="$1" + [[ -n "$bin" ]] || return 1 + command -v "$bin" >/dev/null 2>&1 || return 1 + + local py_version_output + py_version_output="$("$bin" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)" + [[ -n "$py_version_output" ]] || return 1 + + local py_major py_minor + py_major="${py_version_output%%.*}" + py_minor="${py_version_output##*.}" + + if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]] || [[ "$py_minor" -ge 12 ]]; then + return 1 + fi + return 0 +} + +resolve_python() { + if [[ -n "$PYTHON_BIN" ]]; then + if validate_python_bin "$PYTHON_BIN"; then + ui_success "Using Python: $PYTHON_BIN" + return 0 + fi + die "PYTHON_BIN is invalid or unsupported (need >=3.10 and <3.12): $PYTHON_BIN" + fi + + if validate_python_bin python3; then + PYTHON_BIN="python3" + local v + v="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)" + ui_success "Python $v found on PATH" + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + ui_warn "python3 on PATH is incompatible (Flink Agents requires Python >=3.10 and <3.12)" + else + ui_warn "python3 not found on PATH" + fi + + if ! is_promptable; then + die "No compatible Python found. Set PYTHON_BIN or pass --python ." + fi + + local input + input="$(prompt_path_input "Enter path to a Python interpreter" "/path/to/python3")" + if [[ -z "$input" ]]; then + die "No Python interpreter provided." + fi + if ! validate_python_bin "$input"; then + die "Provided Python is invalid or unsupported (need >=3.10 and <3.12): $input" + fi + PYTHON_BIN="$input" + ui_success "Using Python: $PYTHON_BIN" + return 0 +} + +show_install_plan() { + ui_section "Environment (read-only)" + ui_kv "OS" "$OS" + local java_summary="not found" + if command -v java >/dev/null 2>&1; then + local jv + jv="$(java -version 2>&1 | head -n1 | sed -E 's/^[^ ]+ version "?([^"]+)"?.*/\1/')" + java_summary="${jv:-detected}" + fi + ui_kv "Java" "$java_summary" + if [[ -n "${JAVA_HOME:-}" ]]; then + ui_kv "JAVA_HOME" "$JAVA_HOME" + else + ui_kv "JAVA_HOME" "" + fi + local python_summary="" + if [[ -n "$PYTHON_BIN" ]] && command -v "$PYTHON_BIN" >/dev/null 2>&1; then + local pv + pv="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' 2>/dev/null || true)" + python_summary="${PYTHON_BIN}${pv:+ ($pv)}" + elif command -v python3 >/dev/null 2>&1; then + local pv + pv="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' 2>/dev/null || true)" + python_summary="python3${pv:+ ($pv)}" + fi + ui_kv "Python" "$python_summary" + + ui_section "Installation plan" + ui_kv "Flink Agents version" "$FLINK_AGENTS_VERSION" + ui_kv "Install Flink" "$INSTALL_FLINK" + if [[ "$INSTALL_FLINK" == "Yes" ]]; then + ui_kv "Flink version" "$FLINK_VERSION" + ui_kv "Install directory" "$INSTALL_DIR" + else + if [[ -n "${FLINK_HOME:-}" ]]; then + ui_kv "FLINK_HOME" "$FLINK_HOME (v$FLINK_VERSION)" + fi + fi + ui_kv "Enable PyFlink" "$ENABLE_PYFLINK" + if [[ "$ENABLE_PYFLINK" == "Yes" ]] || [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then + ui_kv "Venv directory" "$VENV_DIR" + fi + if [[ "$DRY_RUN" == "1" ]]; then + ui_kv "Dry run" "yes" + fi + if [[ "$VERIFY_INSTALL" == "1" ]]; then + ui_kv "Verify" "yes" + fi +} + +verify_installation() { + if [[ "${VERIFY_INSTALL}" != "1" ]]; then + return 0 + fi + + ui_stage "Verifying installation" + + if [[ -x "$FLINK_HOME/bin/flink" ]]; then + local flink_ver + flink_ver="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)" + if [[ -n "$flink_ver" ]]; then + ui_success "Flink binary: $flink_ver" + else + ui_warn "Flink binary exists but --version failed" + fi + else + ui_warn "Flink binary not found at $FLINK_HOME/bin/flink" + fi + + local expected_jar="$FLINK_HOME/lib/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${FLINK_AGENTS_VERSION}.jar" + if [[ -f "$expected_jar" ]]; then + ui_success "flink-agents JAR found: $(basename "$expected_jar")" + else + ui_error "Expected flink-agents JAR missing: $expected_jar" + return 1 + fi + + if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then + if python -c "import flink_agents; print('flink-agents', flink_agents.__version__)" 2>/dev/null; then + ui_success "flink-agents Python package verified" + else + ui_error "flink-agents Python package import failed" + return 1 + fi + + if [[ -f "$FLINK_HOME/lib/flink-python-${FLINK_VERSION}.jar" ]]; then + ui_success "flink-python JAR found in FLINK_HOME/lib" + else + ui_warn "flink-python-${FLINK_VERSION}.jar not found in $FLINK_HOME/lib" + fi + fi + + ui_success "Verification complete" +} + +show_footer_links() { + local docs_url="https://nightlies.apache.org/flink/flink-agents-docs-latest/" + local issues_url="https://github.com/apache/flink-agents/issues" + echo "" + ui_info "Documentation: ${docs_url}" + ui_info "Report issues: ${issues_url}" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) + NO_PROMPT=1 + shift + ;; + --install-flink) + INSTALL_FLINK=Yes + shift + ;; + --enable-pyflink|--enable-pyFlink) + ENABLE_PYFLINK=Yes + shift + ;; + --verbose) + VERBOSE=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --verify) + VERIFY_INSTALL=1 + shift + ;; + --python) + if [[ $# -lt 2 ]]; then + die "--python requires a path argument" + fi + PYTHON_BIN="$2" + shift 2 + ;; + --python=*) + PYTHON_BIN="${1#*=}" + shift + ;; + --flink-agents-version) + if [[ $# -lt 2 ]]; then + die "--flink-agents-version requires a version argument" + fi + FLINK_AGENTS_VERSION="$2" + FLINK_AGENTS_VERSION_EXPLICIT=1 + shift 2 + ;; + --flink-agents-version=*) + FLINK_AGENTS_VERSION="${1#*=}" + FLINK_AGENTS_VERSION_EXPLICIT=1 + shift + ;; + --flink-version) + if [[ $# -lt 2 ]]; then + die "--flink-version requires a version argument" + fi + FLINK_VERSION="$2" + FLINK_VERSION_EXPLICIT=1 + shift 2 + ;; + --flink-version=*) + FLINK_VERSION="${1#*=}" + FLINK_VERSION_EXPLICIT=1 + shift + ;; + --help|-h) + HELP=1 + shift + ;; + *) + ui_warn "Unknown option: $1" + shift + ;; + esac + done +} + +configure_verbose() { + if [[ "$VERBOSE" != "1" ]]; then + return 0 + fi + if [[ "$NPM_LOGLEVEL" == "error" ]]; then + NPM_LOGLEVEL="notice" + fi + NPM_SILENT_FLAG="" + set -x +} + +main() { + if [[ "$HELP" == "1" ]]; then + print_usage + return 0 + fi + + bootstrap_gum_temp || true + print_installer_banner + print_gum_status + detect_os_or_die + check_java || die "Java environment check failed. Please install Java 11 or newer." + + ui_stage "Planning Flink installation" + plan_flink + plan_flink_agents + + ui_stage "Planning Python environment" + plan_pyflink + + show_install_plan + + if [[ "$DRY_RUN" == "1" ]]; then + ui_success "Dry run complete (no changes made)" + return 0 + fi + + confirm_install_plan + + ui_stage "Installing Apache Flink" + install_flink_if_needed + + ui_stage "Installing Flink Agents" + install_flink_agents_jar + if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then + copy_pyflink_jar + setup_python_env + else + ui_info "Skipping Python venv (ENABLE_PYFLINK=No)." + fi + + ui_stage "Finalizing" + + verify_installation + + echo "" + ui_celebrate "Apache Flink Agents installation finished!" + echo "" + ui_section "Next steps" + ui_success "1) Point FLINK_HOME at this install (Flink CLI and clients read it):" + ui_info " export FLINK_HOME=${FLINK_HOME}" + if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then + ui_success "2) Activate the Python venv (PyFlink + flink-agents are installed there):" + ui_info " source ${VENV_DIR}/bin/activate" + ui_success "3) To make both permanent, append the two lines above to your shell rc" + ui_info " (~/.zshrc or ~/.bashrc)." + else + ui_success "2) To make it permanent, append the line above to your shell rc" + ui_info " (~/.zshrc or ~/.bashrc)." + fi + + show_footer_links +} + +if [[ "${FLINK_AGENTS_INSTALL_SH_NO_RUN:-0}" != "1" ]]; then + parse_args "$@" + configure_verbose + main +fi + diff --git a/tools/test/.gitignore b/tools/test/.gitignore new file mode 100644 index 000000000..74b450371 --- /dev/null +++ b/tools/test/.gitignore @@ -0,0 +1 @@ +.bats-cache/ diff --git a/tools/test/helpers/load.bash b/tools/test/helpers/load.bash new file mode 100644 index 000000000..308d450da --- /dev/null +++ b/tools/test/helpers/load.bash @@ -0,0 +1,50 @@ +# Helpers used by every bats file. Loaded via `load 'helpers/load'`. + +# Sources install.sh with the no-run hook so main() is skipped. +load_install_sh() { + export FLINK_AGENTS_INSTALL_SH_NO_RUN=1 + # shellcheck disable=SC1090 + source "${BATS_TEST_DIRNAME}/../../install.sh" + # Note: install.sh sets `set -euo pipefail`, which matches the + # strict mode bats itself enables. Leave it on so bats's ERR trap + # can detect failed assertions. Use `run ` (which captures + # $status in a subshell) for tests that exercise failure paths. +} + +# Resets every module-level variable install.sh defines, so tests don't +# leak state into one another. +reset_install_sh_state() { + TMPFILES=() + INSTALL_STAGE_CURRENT=0 + GUM="" + GUM_STATUS="skipped" + GUM_REASON="" + DOWNLOADER="" + HELP=0 + DRY_RUN=0 + NO_PROMPT=0 + VERBOSE=0 + VERIFY_INSTALL=0 + INSTALL_FLINK="Ask" + ENABLE_PYFLINK="Ask" + PYTHON_BIN="" + PYFLINK_ACTUALLY_ENABLED=0 + FLINK_VERSION="2.2.0" + FLINK_AGENTS_VERSION="0.2.1" + FLINK_SCALA_VERSION="2.12" + FLINK_BASE_URL="https://dlcdn.apache.org/flink" + FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3") + FLINK_RECOMMENDED_VERSION="2.2.0" + INSTALL_DIR="$HOME/.local/flink" + VENV_DIR=".flink-agents-env" + GUM_VERSION="0.17.0" + # Default the bootstrap cache to a per-test directory so we never touch + # the developer's real $HOME/.cache when running locally. + GUM_CACHE_ROOT="${BATS_TEST_TMPDIR:-/tmp}/gum-cache" + FLINK_VERSION_EXPLICIT=0 + FLINK_AGENTS_VERSION_EXPLICIT=0 + INSTALL_DIR_EXPLICIT=0 + VENV_DIR_EXPLICIT=0 + FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0") + FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1" +} diff --git a/tools/test/helpers/shim.bash b/tools/test/helpers/shim.bash new file mode 100644 index 000000000..1d60a5b10 --- /dev/null +++ b/tools/test/helpers/shim.bash @@ -0,0 +1,88 @@ +# PATH-shim helpers for integration tests. Loaded via `load 'helpers/shim'`. +# +# After `shim_setup` runs, $BATS_TEST_TMPDIR/bin is prepended to PATH and +# every shimmed binary appends one tab-separated line per invocation to +# $BATS_TEST_TMPDIR/calls/.log. +# +# `shim_bin_missing` additionally registers a name in SHIM_MISSING_NAMES; +# the `command` function override below intercepts `command -v ` for +# those names and returns 1, so install.sh's `command -v curl &>/dev/null` +# checks see them as unavailable. + +shim_setup() { + SHIM_DIR="$BATS_TEST_TMPDIR/bin" + SHIM_CALLS="$BATS_TEST_TMPDIR/calls" + SHIM_MISSING_NAMES=() + mkdir -p "$SHIM_DIR" "$SHIM_CALLS" + export PATH="$SHIM_DIR:$PATH" +} + +# Override `command` as a shell function. When SHIM_MISSING_NAMES is empty +# (i.e. shim_setup hasn't been called or nothing has been marked missing), +# this is fully transparent — every call falls through to `builtin command`. +command() { + if [[ "$1" == "-v" ]]; then + local q="$2" + local m + for m in "${SHIM_MISSING_NAMES[@]:-}"; do + if [[ "$q" == "$m" ]]; then + return 1 + fi + done + fi + builtin command "$@" +} + +# Replace `name` with a stub that records argv and exits with `exit_code` (default 0). +shim_bin() { + local name="$1" exit_code="${2:-0}" + cat >"$SHIM_DIR/$name" <> "$SHIM_CALLS/$name.log" +exit $exit_code +EOF + chmod +x "$SHIM_DIR/$name" +} + +# Replace `name` with a stub that records argv then runs an arbitrary shell body. +# Note: `body` must not contain a line that is exactly `EOF`, or it will +# terminate the heredoc prematurely. +shim_bin_script() { + local name="$1" body="$2" + cat >"$SHIM_DIR/$name" <> "$SHIM_CALLS/$name.log" +$body +EOF + chmod +x "$SHIM_DIR/$name" +} + +# Make `name` resolve to "missing" for both `command -v` checks and actual +# invocation: register it in SHIM_MISSING_NAMES (the `command` override +# returns 1 for these) and drop an exit-127 stub on PATH (so direct +# invocation doesn't accidentally hit the real system binary). +shim_bin_missing() { + local name="$1" + SHIM_MISSING_NAMES+=("$name") + cat >"$SHIM_DIR/$name" <<'EOF' +#!/usr/bin/env bash +exit 127 +EOF + chmod +x "$SHIM_DIR/$name" +} + +# Print all recorded calls for `name`, one per line, tab-separated argv. +shim_calls() { + local name="$1" + cat "$SHIM_CALLS/$name.log" 2>/dev/null || true +} + +# Print the number of times `name` was invoked. +shim_call_count() { + local name="$1" + if [[ ! -f "$SHIM_CALLS/$name.log" ]]; then + echo 0 + return + fi + wc -l < "$SHIM_CALLS/$name.log" | tr -d ' ' +} diff --git a/tools/test/integration/.gitkeep b/tools/test/integration/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tools/test/integration/bootstrap_gum_temp.bats b/tools/test/integration/bootstrap_gum_temp.bats new file mode 100644 index 000000000..c35ab9ffd --- /dev/null +++ b/tools/test/integration/bootstrap_gum_temp.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup + # Force interactive-shell + tty path so bootstrap doesn't auto-skip. + NO_PROMPT=0 + # gum_is_tty falls back to checking /dev/tty readability; bats provides one. + # If the test env happens not to, override via TERM/NO_COLOR being unset. + unset NO_COLOR + TERM=xterm + # Make sure `gum` isn't already on PATH so we exercise the install branch. + shim_bin_missing gum + # Provide tar so the early "tar not found" branch is skipped. + shim_bin tar + # Real uname is fine here — we just need a supported os/arch. + + # Override is_non_interactive_shell to report "interactive" by default so + # tests that exercise the download path are not short-circuited by stdin/ + # stdout not being ttys in the bats process. + is_non_interactive_shell() { return 1; } + + # Override gum_is_tty to report "has tty" by default so the second guard + # in bootstrap_gum_temp does not short-circuit the download path either. + gum_is_tty() { return 0; } +} + +@test "bootstrap_gum_temp: non-interactive shell auto-skips with no downloads" { + # Restore the real check and set NO_PROMPT to force non-interactive. + is_non_interactive_shell() { + [[ "${NO_PROMPT:-0}" == "1" ]] + } + NO_PROMPT=1 + run bootstrap_gum_temp + [ "$status" -ne 0 ] + [ "$GUM" = "" ] + [ "$GUM_STATUS" = "skipped" ] + [ "$(shim_call_count curl)" = "0" ] + [ "$(shim_call_count wget)" = "0" ] +} + +@test "bootstrap_gum_temp: download failure sets reason='download failed'" { + DOWNLOADER=curl + shim_bin curl 22 + bootstrap_gum_temp || true + [ "$GUM" = "" ] + [ "$GUM_STATUS" = "skipped" ] + [ "$GUM_REASON" = "download failed" ] +} + +@test "bootstrap_gum_temp: checksum failure sets reason='checksum unavailable or failed'" { + DOWNLOADER=curl + # curl always succeeds (both asset and checksums.txt downloads succeed) + shim_bin curl + # sha256sum / shasum both fail + shim_bin sha256sum 1 + shim_bin shasum 1 + bootstrap_gum_temp || true + [ "$GUM_REASON" = "checksum unavailable or failed" ] +} + +@test "bootstrap_gum_temp: extract failure sets reason='extract failed'" { + DOWNLOADER=curl + shim_bin curl + shim_bin sha256sum + shim_bin shasum + shim_bin tar 1 + bootstrap_gum_temp || true + [ "$GUM_REASON" = "extract failed" ] +} + +@test "bootstrap_gum_temp: missing gum binary after extract sets reason" { + DOWNLOADER=curl + shim_bin curl + shim_bin sha256sum + shim_bin shasum + # tar 'succeeds' but produces nothing + shim_bin tar + bootstrap_gum_temp || true + [ "$GUM_REASON" = "gum binary missing after extract" ] +} + +@test "bootstrap_gum_temp: gum already on PATH is reported as found" { + # Clear the missing-names list so command -v gum sees the shim on PATH. + SHIM_MISSING_NAMES=() + shim_bin gum + bootstrap_gum_temp + [ "$GUM" = "gum" ] + [ "$GUM_STATUS" = "found" ] +} + +@test "bootstrap_gum_temp: successful download+extract sets GUM_STATUS=installed" { + DOWNLOADER=curl + # curl writes a dummy archive to the -o path + shim_bin_script curl ' +out=""; prev="" +for a in "$@"; do + [[ "$prev" == "-o" ]] && out="$a" + prev="$a" +done +[[ -n "$out" ]] && printf "fake" > "$out" +' + shim_bin sha256sum + shim_bin shasum + # tar extracts a fake gum binary into the temp dir + shim_bin_script tar ' +if [[ "$1" == "-xzf" ]]; then + dest="$4" + mkdir -p "$dest" + printf "#!/bin/bash\n" > "$dest/gum" + chmod +x "$dest/gum" +fi +' + bootstrap_gum_temp + [ "$GUM_STATUS" = "installed" ] + [ -n "$GUM" ] + [ -x "$GUM" ] +} + +@test "bootstrap_gum_temp: cached binary on disk is reused without downloading" { + DOWNLOADER=curl + # Pre-seed the persistent cache with an executable gum. + mkdir -p "${GUM_CACHE_ROOT}/${GUM_VERSION}" + printf '#!/bin/bash\n' > "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" + chmod +x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" + # Any downloader call would be a regression — fail loudly if it happens. + shim_bin curl 22 + shim_bin wget 22 + + bootstrap_gum_temp + [ "$GUM_STATUS" = "cached" ] + [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ] + [ -x "$GUM" ] + [ "$(shim_call_count curl)" = "0" ] + [ "$(shim_call_count wget)" = "0" ] +} + +@test "bootstrap_gum_temp: successful install promotes binary into the cache" { + DOWNLOADER=curl + shim_bin_script curl ' +out=""; prev="" +for a in "$@"; do + [[ "$prev" == "-o" ]] && out="$a" + prev="$a" +done +[[ -n "$out" ]] && printf "fake" > "$out" +' + shim_bin sha256sum + shim_bin shasum + shim_bin_script tar ' +if [[ "$1" == "-xzf" ]]; then + dest="$4" + mkdir -p "$dest" + printf "#!/bin/bash\n" > "$dest/gum" + chmod +x "$dest/gum" +fi +' + bootstrap_gum_temp + [ "$GUM_STATUS" = "installed" ] + [ -x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ] + [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ] +} diff --git a/tools/test/integration/download_file.bats b/tools/test/integration/download_file.bats new file mode 100644 index 000000000..2b02d0759 --- /dev/null +++ b/tools/test/integration/download_file.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup +} + +@test "download_file: curl is invoked with the exact expected argv" { + DOWNLOADER=curl + shim_bin curl + + download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out" + + [ "$(shim_call_count curl)" = "1" ] + local got + got="$(shim_calls curl)" + local expected + expected=$'-fL\t--progress-bar\t--proto\t=https\t--tlsv1.2\t--retry\t3\t--max-time\t900\t--retry-delay\t1\t--retry-connrefused\t-o\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz' + [ "$got" = "$expected" ] +} + +@test "download_file: curl argv contains no stray '--' token (PR #599 regression guard)" { + DOWNLOADER=curl + shim_bin curl + + download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out" + + local got + got="$(shim_calls curl)" + # A bare '--' token would appear as tab-bracketed or at line edges. + # Use POSIX [ ] with string-prefix stripping: if the pattern is absent, + # stripping it leaves got unchanged (strings are equal → [ ] succeeds). + # bash 3.2 does not trigger set -e for [[ ]] failures, so [ ] is required. + [ "${got#*$'\t--\t'}" = "$got" ] + [ "${got%$'\t--'}" = "$got" ] + [ "${got#'--'}" = "$got" ] +} + +@test "download_file: curl failure propagates" { + DOWNLOADER=curl + shim_bin curl 22 + + run download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out" + [ "$status" -ne 0 ] +} + +@test "download_file: wget is invoked with the exact expected argv" { + DOWNLOADER=wget + shim_bin wget + + download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out" + + [ "$(shim_call_count wget)" = "1" ] + local got + got="$(shim_calls wget)" + local expected + expected=$'-q\t--show-progress\t--https-only\t--secure-protocol=TLSv1_2\t--tries=3\t--timeout=900\t-O\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz' + [ "$got" = "$expected" ] +} + +@test "detect_downloader: picks curl when available" { + DOWNLOADER="" + shim_bin curl + shim_bin wget + detect_downloader + [ "$DOWNLOADER" = "curl" ] +} + +@test "detect_downloader: falls back to wget when curl is missing" { + DOWNLOADER="" + shim_bin_missing curl + shim_bin wget + detect_downloader + [ "$DOWNLOADER" = "wget" ] +} + +@test "detect_downloader: dies when neither curl nor wget is available" { + DOWNLOADER="" + shim_bin_missing curl + shim_bin_missing wget + run detect_downloader + [ "$status" -ne 0 ] + # Use POSIX [ ] for set -e compatibility on bash 3.2 (see also Test 2). + [ "${output#*Missing downloader}" != "$output" ] +} diff --git a/tools/test/integration/dry_run.bats b/tools/test/integration/dry_run.bats new file mode 100644 index 000000000..f16982140 --- /dev/null +++ b/tools/test/integration/dry_run.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + shim_setup + # Fake FLINK_HOME so plan_flink (with INSTALL_FLINK=No) passes validation. + export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home" + mkdir -p "$FLINK_HOME/lib" + # Fake `java` so check_java passes. + shim_bin_script java " +case \"\$1\" in + -version) echo 'openjdk version \"17.0.2\"' >&2 ;; +esac +" +} + +@test "--dry-run --non-interactive prints plan and makes no external calls" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"Installation plan"*) ;; *) false ;; esac + case "$output" in *"Dry run complete"*) ;; *) false ;; esac + # No downloader should have been invoked. + [ "$(shim_call_count curl)" = "0" ] + [ "$(shim_call_count wget)" = "0" ] +} + +@test "--dry-run --install-flink --non-interactive shows Install Flink: Yes" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"Install Flink"*) ;; *) false ;; esac + case "$output" in *"Yes"*) ;; *) false ;; esac + case "$output" in *"Dry run complete"*) ;; *) false ;; esac +} + +@test "INSTALL_DIR=. does not produce a double-slash FLINK_HOME (review feedback guard)" { + cd "$BATS_TEST_TMPDIR" + run env INSTALL_DIR="." bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink --non-interactive + [ "$status" -eq 0 ] + case "$output" in *".//flink-"*) false ;; *) ;; esac +} + +@test "INSTALL_DIR with trailing slash does not produce double-slash (review #7b)" { + run env INSTALL_DIR="/tmp/flink-test/" bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"//"*) false ;; *) ;; esac + case "$output" in *"/tmp/flink-test"*) ;; *) false ;; esac +} + +@test "INSTALL_DIR with consecutive slashes is collapsed (review #7b)" { + run env INSTALL_DIR="/tmp//flink-test" bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"//flink-"*) false ;; *) ;; esac +} diff --git a/tools/test/integration/dry_run_extra.bats b/tools/test/integration/dry_run_extra.bats new file mode 100644 index 000000000..1e7efac05 --- /dev/null +++ b/tools/test/integration/dry_run_extra.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + shim_setup + export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home" + mkdir -p "$FLINK_HOME/lib" "$FLINK_HOME/bin" + # Seed a fake flink-dist jar so detect_flink_version_from_home succeeds + # and the plan reflects the on-disk version (review feedback #2 + #3). + : > "$FLINK_HOME/lib/flink-dist-2.1.1.jar" + shim_bin_script java " +case \"\$1\" in + -version) echo 'openjdk version \"17.0.2\"' >&2 ;; +esac +" +} + +@test "dry-run: plan shows Flink Agents version (review #1 — JARs are implicit)" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac +} + +@test "dry-run: existing FLINK_HOME — plan shows detected version, not default (review #2)" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"v2.1.1"*) ;; *) false ;; esac +} + +@test "dry-run: INSTALL_FLINK=No suppresses Install directory line (review #3)" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"Install directory"*) false ;; *) ;; esac +} + +@test "dry-run: Environment section is shown separately from Plan (review #5)" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --non-interactive + [ "$status" -eq 0 ] + case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac + case "$output" in *"Installation plan"*) ;; *) false ;; esac +} diff --git a/tools/test/integration/err_trap.bats b/tools/test/integration/err_trap.bats new file mode 100644 index 000000000..bc553c53f --- /dev/null +++ b/tools/test/integration/err_trap.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats + +# When a command fails under `set -e` (i.e. NOT through die()/die_cancelled), +# the ERR trap should print a banner that names the stage, the command, +# the install.sh line, and the exit code. die() must keep its existing +# single-line message and not trip the banner. + +@test "on_error: set -e failure triggers a stage-aware banner" { + # Use a real failure mode: pretend a Flink download 404'd. The script + # walks through plan_flink with INSTALL_FLINK=Yes (default INSTALL_DIR, + # but we override it to /tmp) and then dies on curl in stage 3. + local tmp="$BATS_TEST_TMPDIR/demo" + mkdir -p "$tmp" + run env INSTALL_DIR="$tmp" FLINK_BASE_URL="https://dlcdn.apache.org/flink" \ + FLINK_VERSION=99.99.0 \ + bash "${BATS_TEST_DIRNAME}/../../install.sh" \ + --install-flink --non-interactive + [ "$status" -ne 0 ] + # Banner must name the stage by title. + case "$output" in *"Installation failed at stage 3/5"*) ;; *) false ;; esac + case "$output" in *"Installing Apache Flink"*) ;; *) false ;; esac + # Banner must include source line + exit code keywords. + case "$output" in *"Source:"*"install.sh:"*) ;; *) false ;; esac + case "$output" in *"Exit code:"*) ;; *) false ;; esac + # Banner must not point at the trap line itself, which would be a + # regression — we want the line where the failing command lives. + case "$output" in *"install.sh:0"*) false ;; *) ;; esac +} + +@test "die(): single-line message, NO banner duplication" { + # die() uses `exit`, which doesn't trigger ERR. Reach it by giving + # plan_flink a non-existent FLINK_HOME under --non-interactive. + run env FLINK_HOME="" \ + bash "${BATS_TEST_DIRNAME}/../../install.sh" --non-interactive + [ "$status" -ne 0 ] + case "$output" in *"FLINK_HOME is not set"*) ;; *) false ;; esac + # Banner box characters must NOT appear in a die() path. + case "$output" in *"Installation failed at stage"*) false ;; *) ;; esac +} diff --git a/tools/test/integration/help.bats b/tools/test/integration/help.bats new file mode 100644 index 000000000..d77335e37 --- /dev/null +++ b/tools/test/integration/help.bats @@ -0,0 +1,16 @@ +#!/usr/bin/env bats + +@test "--help prints usage and exits 0" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" --help + [ "$status" -eq 0 ] + case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac + case "$output" in *"Options:"*) ;; *) false ;; esac + case "$output" in *"--install-flink"*) ;; *) false ;; esac + case "$output" in *"--dry-run"*) ;; *) false ;; esac +} + +@test "-h prints usage and exits 0" { + run bash "${BATS_TEST_DIRNAME}/../../install.sh" -h + [ "$status" -eq 0 ] + case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac +} diff --git a/tools/test/integration/install_flink_agents_jar.bats b/tools/test/integration/install_flink_agents_jar.bats new file mode 100644 index 000000000..146967cc9 --- /dev/null +++ b/tools/test/integration/install_flink_agents_jar.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup + FLINK_HOME="$BATS_TEST_TMPDIR/flink-home" + mkdir -p "$FLINK_HOME/lib" + FLINK_VERSION="2.2.0" + FLINK_MAJOR_MINOR="2.2" + FLINK_AGENTS_VERSION="0.2.1" + FLINK_AGENTS_BASE_URL="https://mirror.test/flink" + FLINK_AGENTS_CHECKSUM_BASE_URL="https://downloads.test/flink" +} + +# A curl shim that writes whatever was requested at -o into a fake file +# whose body is "fake-jar-" — enough to satisfy the "non-empty" +# check, and lets us verify which URLs were requested. +configure_curl_shim() { + shim_bin_script curl ' +out=""; url=""; prev="" +for a in "$@"; do + [[ "$prev" == "-o" ]] && out="$a" + case "$a" in -*|"") ;; *) url="$a" ;; esac + prev="$a" +done +[[ -n "$out" ]] && printf "fake-content-%s" "$(basename "$out")" > "$out" +' +} + +@test "flink_agents_jar_relpath: builds expected ASF mirror path" { + FLINK_MAJOR_MINOR="2.1" + FLINK_AGENTS_VERSION="0.2.0" + run flink_agents_jar_relpath + [ "$status" -eq 0 ] + [ "$output" = "flink-agents-0.2.0/flink-agents-dist-flink-2.1-0.2.0.jar" ] +} + +@test "install_flink_agents_jar: downloads from mirror, verifies, lands in FLINK_HOME/lib" { + DOWNLOADER=curl + configure_curl_shim + + run install_flink_agents_jar + [ "$status" -eq 0 ] + [ -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ] + + # Two curl calls expected: jar from mirror, sha512 sidecar from downloads.apache.org. + case "$(shim_calls curl)" in + *"https://mirror.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar"*) ;; + *) false ;; + esac + case "$(shim_calls curl)" in + *"https://downloads.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar.sha512"*) ;; + *) false ;; + esac +} + +@test "install_flink_agents_jar: reuses existing JAR, skips re-download" { + DOWNLOADER=curl + configure_curl_shim + # Pre-seed the target JAR. + : > "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" + + run install_flink_agents_jar + [ "$status" -eq 0 ] + # Only the sha512 sidecar should have been fetched, never the JAR itself. + case "$(shim_calls curl)" in + *"flink-agents-dist-flink-2.2-0.2.1.jar"*"flink-agents-dist-flink-2.2-0.2.1.jar"*) false ;; + *) ;; + esac +} + +@test "install_flink_agents_jar: empty downloaded file is a hard error" { + DOWNLOADER=curl + # Shim that "succeeds" but writes nothing. + shim_bin_script curl ' +out=""; prev="" +for a in "$@"; do + [[ "$prev" == "-o" ]] && out="$a" + prev="$a" +done +[[ -n "$out" ]] && : > "$out" +' + run install_flink_agents_jar + [ "$status" -ne 0 ] + case "$output" in *"empty file"*) ;; *) false ;; esac + [ ! -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ] +} + +@test "install_flink_agents_jar: download failure surfaces a clean error" { + DOWNLOADER=curl + shim_bin curl 22 # curl exit 22 = HTTP error + run install_flink_agents_jar + [ "$status" -ne 0 ] + case "$output" in *"Failed to download flink-agents JAR"*) ;; *) false ;; esac +} diff --git a/tools/test/integration/install_flink_if_needed.bats b/tools/test/integration/install_flink_if_needed.bats new file mode 100644 index 000000000..7bbdceeee --- /dev/null +++ b/tools/test/integration/install_flink_if_needed.bats @@ -0,0 +1,132 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup + INSTALL_DIR="$BATS_TEST_TMPDIR/install" + FLINK_VERSION="2.2.0" + FLINK_SCALA_VERSION="2.12" + FLINK_BASE_URL="https://example.test/flink" +} + +# A tar shim that succeeds for both `-tzf` (validity check) and `-xzf` (extract). +# On extract it materializes a minimal Flink home with a lib/ dir. +configure_tar_shim() { + shim_bin_script tar " +case \"\$1\" in + -tzf) exit 0 ;; + -xzf) + # arg order: -xzf -C (\$4 = dest) + mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\" + ;; + *) exit 0 ;; +esac +" +} + +@test "install_flink_if_needed: INSTALL_FLINK=No uses pre-existing FLINK_HOME" { + INSTALL_FLINK=No + FLINK_HOME="$BATS_TEST_TMPDIR/preexisting" + mkdir -p "$FLINK_HOME/lib" + run install_flink_if_needed + [ "$status" -eq 0 ] + [ "$(shim_call_count curl)" = "0" ] + [ "$(shim_call_count wget)" = "0" ] +} + +@test "install_flink_if_needed: fresh install downloads then extracts" { + INSTALL_FLINK=Yes + DOWNLOADER=curl + FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION" + # curl shim writes a placeholder archive at the requested -o path. + shim_bin_script curl " +# argv pattern: ... -o +out=\"\" +prev=\"\" +for a in \"\$@\"; do + if [[ \"\$prev\" == \"-o\" ]]; then out=\"\$a\"; fi + prev=\"\$a\" +done +[[ -n \"\$out\" ]] && printf 'fake tgz' > \"\$out\" +" + configure_tar_shim + + run install_flink_if_needed + [ "$status" -eq 0 ] + [ "$(shim_call_count curl)" = "1" ] + case "$(shim_calls curl)" in + *"https://example.test/flink/flink-2.2.0/flink-2.2.0-bin-scala_2.12.tgz"*) ;; + *) false ;; + esac + [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ] +} + +@test "install_flink_if_needed: existing valid archive is reused (no download)" { + INSTALL_FLINK=Yes + DOWNLOADER=curl + mkdir -p "$INSTALL_DIR" + # Pre-seed a "valid" archive — tar shim will report it as valid via -tzf. + printf 'existing' > "$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz" + shim_bin curl + configure_tar_shim + FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION" + + run install_flink_if_needed + [ "$status" -eq 0 ] + [ "$(shim_call_count curl)" = "0" ] +} + +@test "install_flink_if_needed: corrupt existing archive triggers re-download" { + INSTALL_FLINK=Yes + DOWNLOADER=curl + mkdir -p "$INSTALL_DIR" + local archive="$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz" + printf 'existing' > "$archive" + # tar reports the existing archive as INVALID, then valid on the re-download. + local state="$BATS_TEST_TMPDIR/tar_state" + : > "$state" + shim_bin_script tar " +case \"\$1\" in + -tzf) + if [[ ! -s '$state' ]]; then + echo bad > '$state' + exit 1 + fi + exit 0 + ;; + -xzf) + mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\" + ;; +esac +" + shim_bin_script curl " +out=\"\"; prev=\"\" +for a in \"\$@\"; do + [[ \"\$prev\" == \"-o\" ]] && out=\"\$a\" + prev=\"\$a\" +done +[[ -n \"\$out\" ]] && printf 'freshly downloaded' > \"\$out\" +" + FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION" + + run install_flink_if_needed + [ "$status" -eq 0 ] + [ "$(shim_call_count curl)" = "1" ] +} + +@test "install_flink_if_needed: incomplete extracted dir triggers re-extract" { + INSTALL_FLINK=Yes + DOWNLOADER=curl + mkdir -p "$INSTALL_DIR/flink-${FLINK_VERSION}" # missing lib/ + printf 'archive' > "$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz" + shim_bin curl + configure_tar_shim + FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION" + + run install_flink_if_needed + [ "$status" -eq 0 ] + [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ] +} diff --git a/tools/test/integration/venv_dir_validation.bats b/tools/test/integration/venv_dir_validation.bats new file mode 100644 index 000000000..55fc23155 --- /dev/null +++ b/tools/test/integration/venv_dir_validation.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats + +# End-to-end behavior of VENV_DIR validation. In non-interactive mode +# (the only mode the integration runner can drive), passing a foreign +# non-empty directory as VENV_DIR must abort BEFORE downloading Flink. + +setup() { + load '../helpers/load' + load '../helpers/shim' + shim_setup + # Fake an existing Flink so plan_flink (INSTALL_FLINK=No) passes. + export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home" + mkdir -p "$FLINK_HOME/lib" + : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar" + # Fake java so check_java passes. + shim_bin_script java " +case \"\$1\" in + -version) echo 'openjdk version \"17.0.2\"' >&2 ;; +esac +" +} + +@test "VENV_DIR=non-empty foreign dir + --non-interactive → die before download" { + local foreign="$BATS_TEST_TMPDIR/foreign" + mkdir -p "$foreign" + : > "$foreign/unrelated.txt" + + run env VENV_DIR="$foreign" PYTHON_BIN=/no/such/python3 \ + bash "${BATS_TEST_DIRNAME}/../../install.sh" \ + --non-interactive --enable-pyflink + + [ "$status" -ne 0 ] + case "$output" in + *"already exists and is not a Python venv"*) ;; + *) false ;; + esac + # Critical: must NOT have invoked any downloader before failing. + [ "$(shim_call_count curl)" = "0" ] + [ "$(shim_call_count wget)" = "0" ] +} + +@test "VENV_DIR=existing real venv (has pyvenv.cfg) is accepted in non-interactive mode" { + local venv="$BATS_TEST_TMPDIR/real-venv" + mkdir -p "$venv/bin" + : > "$venv/pyvenv.cfg" + : > "$venv/bin/activate" + + # We don't actually want it to *install* — just to get past the + # plan_pyflink validation. Use --dry-run so plan succeeds and we + # bail before stage 3. + run env VENV_DIR="$venv" PYTHON_BIN=/no/such/python3 \ + bash "${BATS_TEST_DIRNAME}/../../install.sh" \ + --non-interactive --enable-pyflink --dry-run + # Either the dry-run printout completes (status 0) or fails for an + # unrelated reason (e.g. PYTHON_BIN missing in non-interactive resolve_python). + # The thing we explicitly want NOT to see: the VENV_DIR rejection. + case "$output" in + *"is not a Python venv"*) false ;; + *) ;; + esac +} diff --git a/tools/test/run.sh b/tools/test/run.sh new file mode 100755 index 000000000..c5d303d73 --- /dev/null +++ b/tools/test/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +# Bash 4+ is required: bash 3.2 (macOS default) does not trigger `set -e` +# on `[[ ]]` failures or fire the ERR trap on them, which means many +# substring assertions in this suite would silently pass on bash 3.2. +# Force a clean failure here rather than mislead developers. +if [ -z "${BASH_VERSION:-}" ] || [ "${BASH_VERSION%%.*}" -lt 4 ]; then + echo "ERROR: bash >= 4 required (detected: ${BASH_VERSION:-unknown})." >&2 + echo "macOS ships bash 3.2 at /bin/bash; install bash 4+ via Homebrew:" >&2 + echo " brew install bash" >&2 + echo "Then run with the new bash, e.g.:" >&2 + echo " /opt/homebrew/bin/bash $0" >&2 + exit 1 +fi + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CACHE="$HERE/.bats-cache" + +clone_pinned() { + local name="$1" url="$2" tag="$3" + if [[ ! -d "$CACHE/$name" ]]; then + echo "Fetching $name@$tag" >&2 + git clone --quiet --depth 1 --branch "$tag" "$url" "$CACHE/$name" + fi +} + +mkdir -p "$CACHE" +clone_pinned bats-core https://github.com/bats-core/bats-core.git v1.11.0 +clone_pinned bats-support https://github.com/bats-core/bats-support.git v0.3.0 +clone_pinned bats-assert https://github.com/bats-core/bats-assert.git v2.1.0 + +export BATS_LIB_PATH="$CACHE" + +exec "$CACHE/bats-core/bin/bats" --recursive "$HERE/unit" "$HERE/integration" diff --git a/tools/test/unit/detect_flink_version_from_home.bats b/tools/test/unit/detect_flink_version_from_home.bats new file mode 100644 index 000000000..546bb51ad --- /dev/null +++ b/tools/test/unit/detect_flink_version_from_home.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state + FLINK_HOME="$BATS_TEST_TMPDIR/flink-home" + mkdir -p "$FLINK_HOME/bin" "$FLINK_HOME/lib" +} + +@test "detect_flink_version_from_home: prefers bin/flink --version when runnable" { + cat > "$FLINK_HOME/bin/flink" <<'EOF' +#!/usr/bin/env bash +echo "Version: 2.1.1, Commit ID: abc" +EOF + chmod +x "$FLINK_HOME/bin/flink" + FLINK_VERSION="" + run detect_flink_version_from_home + [ "$status" -eq 0 ] + [ "$FLINK_VERSION" = "2.1.1" ] || { + # `run` executes in subshell — re-run inline to read the assignment. + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.1.1" ] + } +} + +@test "detect_flink_version_from_home: falls back to lib/flink-dist-*.jar" { + : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.0.1" ] +} + +@test "detect_flink_version_from_home: returns non-zero when no source available" { + FLINK_VERSION="" + run detect_flink_version_from_home + [ "$status" -ne 0 ] +} + +@test "detect_flink_version_from_home: ignores stderr noise from flink --version" { + cat > "$FLINK_HOME/bin/flink" <<'EOF' +#!/usr/bin/env bash +echo "WARNING: some noise" >&2 +echo "Version: 1.20.3, Commit ID: deadbeef" +EOF + chmod +x "$FLINK_HOME/bin/flink" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "1.20.3" ] +} + +@test "detect_flink_version_from_home: jar fallback picks first match deterministically" { + : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar" + : > "$FLINK_HOME/lib/flink-dist-other-1.0.jar" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.2.0" ] +} + +@test "detect_flink_version_from_home: announces progress before invoking bin/flink" { + # No dist jar in lib/ -> forces the slow path that runs bin/flink --version. + cat > "$FLINK_HOME/bin/flink" <<'EOF' +#!/usr/bin/env bash +echo "Version: 2.1.1, Commit ID: abc" +EOF + chmod +x "$FLINK_HOME/bin/flink" + FLINK_VERSION="" + run detect_flink_version_from_home + [ "$status" -eq 0 ] + case "$output" in *"Detecting Flink version"*) ;; *) false ;; esac +} + +@test "detect_flink_version_from_home: fast jar path does NOT announce progress" { + : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar" + FLINK_VERSION="" + run detect_flink_version_from_home + [ "$status" -eq 0 ] + case "$output" in *"Detecting Flink version"*) false ;; *) ;; esac +} + +@test "detect_flink_version_from_home: accepts X.Y-SNAPSHOT from local source builds" { + : > "$FLINK_HOME/lib/flink-dist-2.2-SNAPSHOT.jar" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.2-SNAPSHOT" ] +} + +@test "detect_flink_version_from_home: accepts X.Y.Z-SNAPSHOT" { + : > "$FLINK_HOME/lib/flink-dist-2.1.0-SNAPSHOT.jar" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.1.0-SNAPSHOT" ] +} + +@test "detect_flink_version_from_home: accepts -rc suffix from CLI output" { + cat > "$FLINK_HOME/bin/flink" <<'EOF' +#!/usr/bin/env bash +echo "Version: 2.0.0-rc1, Commit ID: abc" +EOF + chmod +x "$FLINK_HOME/bin/flink" + FLINK_VERSION="" + detect_flink_version_from_home + [ "$FLINK_VERSION" = "2.0.0-rc1" ] +} + +@test "flink_major_minor: derives X.Y from various version strings" { + [ "$(flink_major_minor "2.2.0")" = "2.2" ] + [ "$(flink_major_minor "2.2.0-SNAPSHOT")" = "2.2" ] + [ "$(flink_major_minor "2.2-SNAPSHOT")" = "2.2" ] + [ "$(flink_major_minor "1.20.3")" = "1.20" ] + [ "$(flink_major_minor "2.1-rc1")" = "2.1" ] + [ "$(flink_major_minor "garbage")" = "" ] +} diff --git a/tools/test/unit/edit_plan_back.bats b/tools/test/unit/edit_plan_back.bats new file mode 100644 index 000000000..40c4796bb --- /dev/null +++ b/tools/test/unit/edit_plan_back.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +# When a sub-prompt's ESC propagates `exit 130` out of edit_plan_interactive's +# subshell, the surrounding `set -e` MUST NOT kill the installer. The wrapper +# should swallow the non-zero exit and return 0 ("back to confirm"). + +@test "edit_plan_interactive: subshell exit 130 is treated as back, not as installer kill" { + # Replace edit_plan_interactive with a stand-in that runs a subshell + # which exits 130 the same way the real ESC path does, and uses the + # exact `|| rc=$?` pattern we ship. + fake_edit() { + local rc=0 + ( + exit 130 + ) || rc=$? + if (( rc != 0 )); then + return 0 + fi + return 99 # we should never get here + } + + # If `set -e` were still propagating the 130, the test process would + # die here. Reaching the assertion means the pattern works. + run fake_edit + [ "$status" -eq 0 ] +} + +@test "edit_plan_interactive: subshell exit 1 (gum ESC) also becomes back" { + fake_edit() { + local rc=0 + ( exit 1 ) || rc=$? + if (( rc != 0 )); then + return 0 + fi + return 99 + } + run fake_edit + [ "$status" -eq 0 ] +} + +@test "edit_plan_interactive: subshell exit 0 sources state and propagates changes" { + local state_file="$BATS_TEST_TMPDIR/state" + cat > "$state_file" < "$WORK/empty.tgz" + run is_valid_tgz "$WORK/empty.tgz" + [ "$status" -ne 0 ] +} + +@test "is_valid_tgz: random bytes are invalid" { + printf 'not a tarball at all\n' > "$WORK/junk.tgz" + run is_valid_tgz "$WORK/junk.tgz" + [ "$status" -ne 0 ] +} + +@test "is_valid_tgz: a real tgz is valid" { + mkdir -p "$WORK/src" + echo hello > "$WORK/src/file.txt" + tar -czf "$WORK/good.tgz" -C "$WORK/src" . + run is_valid_tgz "$WORK/good.tgz" + [ "$status" -eq 0 ] +} + +@test "is_valid_tgz: truncated tgz is invalid" { + mkdir -p "$WORK/src" + head -c 4096 /dev/urandom > "$WORK/src/blob" + tar -czf "$WORK/full.tgz" -C "$WORK/src" . + # truncate to first 32 bytes (gzip header only) + head -c 32 "$WORK/full.tgz" > "$WORK/trunc.tgz" + run is_valid_tgz "$WORK/trunc.tgz" + [ "$status" -ne 0 ] +} diff --git a/tools/test/unit/mark_explicit.bats b/tools/test/unit/mark_explicit.bats new file mode 100644 index 000000000..68a1111f3 --- /dev/null +++ b/tools/test/unit/mark_explicit.bats @@ -0,0 +1,31 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +@test "mark_explicit: unset variable yields _EXPLICIT=0" { + unset MY_VAR MY_VAR_EXPLICIT + mark_explicit MY_VAR + [ "$MY_VAR_EXPLICIT" = "0" ] +} + +@test "mark_explicit: empty variable yields _EXPLICIT=0" { + MY_VAR="" + mark_explicit MY_VAR + [ "$MY_VAR_EXPLICIT" = "0" ] +} + +@test "mark_explicit: non-empty variable yields _EXPLICIT=1" { + MY_VAR="something" + mark_explicit MY_VAR + [ "$MY_VAR_EXPLICIT" = "1" ] +} + +@test "mark_explicit: zero-string non-empty is _EXPLICIT=1" { + MY_VAR="0" + mark_explicit MY_VAR + [ "$MY_VAR_EXPLICIT" = "1" ] +} diff --git a/tools/test/unit/normalize_path.bats b/tools/test/unit/normalize_path.bats new file mode 100644 index 000000000..96ecd578f --- /dev/null +++ b/tools/test/unit/normalize_path.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +@test "normalize_path: empty input yields empty string" { + run normalize_path "" + [ "$status" -eq 0 ] + [ "$output" = "" ] +} + +@test "normalize_path: tilde expands to \$HOME" { + HOME="/home/u" run normalize_path "~/foo" + [ "$status" -eq 0 ] + [ "$output" = "/home/u/foo" ] +} + +@test "normalize_path: relative path resolves against PWD" { + cd "$BATS_TEST_TMPDIR" + run normalize_path "bar" + [ "$status" -eq 0 ] + [ "$output" = "$BATS_TEST_TMPDIR/bar" ] +} + +@test "normalize_path: dot relative is collapsed to current dir" { + cd "$BATS_TEST_TMPDIR" + run normalize_path "." + [ "$status" -eq 0 ] + [ "$output" = "$BATS_TEST_TMPDIR" ] +} + +@test "normalize_path: trailing slash is stripped" { + run normalize_path "/x/y/" + [ "$status" -eq 0 ] + [ "$output" = "/x/y" ] +} + +@test "normalize_path: multiple trailing slashes are stripped" { + run normalize_path "/x/y///" + [ "$status" -eq 0 ] + [ "$output" = "/x/y" ] +} + +@test "normalize_path: consecutive slashes are collapsed" { + run normalize_path "/x//y///z" + [ "$status" -eq 0 ] + [ "$output" = "/x/y/z" ] +} + +@test "normalize_path: root '/' is preserved" { + run normalize_path "/" + [ "$status" -eq 0 ] + [ "$output" = "/" ] +} + +@test "normalize_path: combined tilde + dot + trailing slash" { + HOME=/h + run normalize_path "~/a/./b/" + [ "$status" -eq 0 ] + [ "$output" = "/h/a/b" ] +} diff --git a/tools/test/unit/parse_args.bats b/tools/test/unit/parse_args.bats new file mode 100644 index 000000000..76084472f --- /dev/null +++ b/tools/test/unit/parse_args.bats @@ -0,0 +1,82 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +@test "parse_args: --non-interactive sets NO_PROMPT=1" { + parse_args --non-interactive + [ "$NO_PROMPT" = "1" ] +} + +@test "parse_args: --install-flink sets INSTALL_FLINK=Yes" { + parse_args --install-flink + [ "$INSTALL_FLINK" = "Yes" ] +} + +@test "parse_args: --enable-pyflink sets ENABLE_PYFLINK=Yes" { + parse_args --enable-pyflink + [ "$ENABLE_PYFLINK" = "Yes" ] +} + +@test "parse_args: --enable-pyFlink alias is honored" { + parse_args --enable-pyFlink + [ "$ENABLE_PYFLINK" = "Yes" ] +} + +@test "parse_args: --verbose sets VERBOSE=1" { + parse_args --verbose + [ "$VERBOSE" = "1" ] +} + +@test "parse_args: --dry-run sets DRY_RUN=1" { + parse_args --dry-run + [ "$DRY_RUN" = "1" ] +} + +@test "parse_args: --verify sets VERIFY_INSTALL=1" { + parse_args --verify + [ "$VERIFY_INSTALL" = "1" ] +} + +@test "parse_args: --python sets PYTHON_BIN" { + parse_args --python /opt/py/bin/python3 + [ "$PYTHON_BIN" = "/opt/py/bin/python3" ] +} + +@test "parse_args: --python= sets PYTHON_BIN" { + parse_args --python=/usr/local/bin/python3.11 + [ "$PYTHON_BIN" = "/usr/local/bin/python3.11" ] +} + +@test "parse_args: --python without arg dies" { + run parse_args --python + [ "$status" -ne 0 ] + [[ "$output" == *"--python requires a path argument"* ]] +} + +@test "parse_args: --help sets HELP=1" { + parse_args --help + [ "$HELP" = "1" ] +} + +@test "parse_args: -h sets HELP=1" { + parse_args -h + [ "$HELP" = "1" ] +} + +@test "parse_args: unknown flag warns but does not die" { + run parse_args --no-such-flag + [ "$status" -eq 0 ] + [[ "$output" == *"Unknown option: --no-such-flag"* ]] +} + +@test "parse_args: combined flags all apply" { + parse_args --non-interactive --install-flink --enable-pyflink --verify + [ "$NO_PROMPT" = "1" ] + [ "$INSTALL_FLINK" = "Yes" ] + [ "$ENABLE_PYFLINK" = "Yes" ] + [ "$VERIFY_INSTALL" = "1" ] +} diff --git a/tools/test/unit/platform_detect.bats b/tools/test/unit/platform_detect.bats new file mode 100644 index 000000000..c13400805 --- /dev/null +++ b/tools/test/unit/platform_detect.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup +} + +# Helper: install a fake `uname` that emits a chosen string for -s and -m. +fake_uname() { + local sysname="$1" machine="$2" + shim_bin_script uname " +case \"\$1\" in + -s) echo '$sysname' ;; + -m) echo '$machine' ;; + *) echo 'fake' ;; +esac +" +} + +@test "gum_detect_os: Darwin" { + fake_uname Darwin x86_64 + [ "$(gum_detect_os)" = "Darwin" ] +} + +@test "gum_detect_os: Linux" { + fake_uname Linux x86_64 + [ "$(gum_detect_os)" = "Linux" ] +} + +@test "gum_detect_os: unknown kernel" { + fake_uname FreeBSD x86_64 + [ "$(gum_detect_os)" = "unsupported" ] +} + +@test "gum_detect_arch: x86_64 stays x86_64" { + fake_uname Linux x86_64 + [ "$(gum_detect_arch)" = "x86_64" ] +} + +@test "gum_detect_arch: amd64 maps to x86_64" { + fake_uname Linux amd64 + [ "$(gum_detect_arch)" = "x86_64" ] +} + +@test "gum_detect_arch: arm64 stays arm64" { + fake_uname Darwin arm64 + [ "$(gum_detect_arch)" = "arm64" ] +} + +@test "gum_detect_arch: aarch64 maps to arm64" { + fake_uname Linux aarch64 + [ "$(gum_detect_arch)" = "arm64" ] +} + +@test "gum_detect_arch: i686 maps to i386" { + fake_uname Linux i686 + [ "$(gum_detect_arch)" = "i386" ] +} + +@test "gum_detect_arch: armv7l maps to armv7" { + fake_uname Linux armv7l + [ "$(gum_detect_arch)" = "armv7" ] +} + +@test "gum_detect_arch: unknown machine is unknown" { + fake_uname Linux riscv64 + [ "$(gum_detect_arch)" = "unknown" ] +} + +@test "detect_os_or_die: macOS via OSTYPE" { + OSTYPE="darwin23" + detect_os_or_die + [ "$OS" = "macos" ] +} + +@test "detect_os_or_die: linux via OSTYPE" { + OSTYPE="linux-gnu" + detect_os_or_die + [ "$OS" = "linux" ] +} + +@test "detect_os_or_die: WSL via WSL_DISTRO_NAME" { + OSTYPE="something-unknown" + WSL_DISTRO_NAME="Ubuntu" + detect_os_or_die + [ "$OS" = "linux" ] +} + +@test "detect_os_or_die: unsupported OSTYPE dies" { + OSTYPE="cygwin" + unset WSL_DISTRO_NAME + run detect_os_or_die + [ "$status" -ne 0 ] + [[ "$output" == *"Unsupported operating system"* ]] +} diff --git a/tools/test/unit/shim_self_test.bats b/tools/test/unit/shim_self_test.bats new file mode 100644 index 000000000..04def76a7 --- /dev/null +++ b/tools/test/unit/shim_self_test.bats @@ -0,0 +1,50 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup +} + +@test "shim_bin records argv for a single call" { + shim_bin fake_tool + fake_tool --foo bar baz + [ "$(shim_call_count fake_tool)" = "1" ] + run shim_calls fake_tool + [ "$output" = "--foo bar baz" ] +} + +@test "shim_bin can simulate non-zero exit" { + shim_bin fake_tool 7 + run fake_tool + [ "$status" -eq 7 ] +} + +@test "shim_bin_script can write output files" { + shim_bin_script fake_tar 'touch "$2/marker"' + mkdir -p "$BATS_TEST_TMPDIR/out" + fake_tar -x "$BATS_TEST_TMPDIR/out" + [ -f "$BATS_TEST_TMPDIR/out/marker" ] +} + +@test "PATH shim is preferred over real binary" { + shim_bin curl + run command -v curl + [[ "$output" == "$BATS_TEST_TMPDIR/bin/curl" ]] +} + +@test "shim_bin_missing makes command -v report missing" { + shim_bin_missing tool_x + run command -v tool_x + [ "$status" -ne 0 ] +} + +@test "shim_bin_missing makes direct invocation exit 127" { + shim_bin_missing tool_y + run -127 tool_y --some arg + [ "$status" -eq 127 ] +} diff --git a/tools/test/unit/show_install_plan.bats b/tools/test/unit/show_install_plan.bats new file mode 100644 index 000000000..0a304a2f7 --- /dev/null +++ b/tools/test/unit/show_install_plan.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state + OS="linux" +} + +@test "show_install_plan: Environment section always present" { + INSTALL_FLINK=Yes + INSTALL_DIR="/opt/flink" + FLINK_HOME="/opt/flink/flink-2.2.0" + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac + case "$output" in *"OS:"*) ;; *) false ;; esac + case "$output" in *"Java:"*) ;; *) false ;; esac + case "$output" in *"JAVA_HOME:"*) ;; *) false ;; esac + case "$output" in *"Python:"*) ;; *) false ;; esac +} + +@test "show_install_plan: INSTALL_FLINK=Yes shows version + install directory, not FLINK_HOME explicit" { + INSTALL_FLINK=Yes + INSTALL_DIR="/opt/flink" + FLINK_VERSION="2.2.0" + FLINK_HOME="/opt/flink/flink-2.2.0" + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *"Flink version"*) ;; *) false ;; esac + case "$output" in *"Install directory"*) ;; *) false ;; esac +} + +@test "show_install_plan: INSTALL_FLINK=No hides Install directory, shows FLINK_HOME with version" { + INSTALL_FLINK=No + INSTALL_DIR="/should/not/show" + FLINK_VERSION="2.1.1" + FLINK_HOME="/usr/local/flink" + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *"FLINK_HOME"*) ;; *) false ;; esac + case "$output" in *"v2.1.1"*) ;; *) false ;; esac + case "$output" in *"Install directory"*) false ;; *) ;; esac + case "$output" in *"/should/not/show"*) false ;; *) ;; esac +} + +@test "show_install_plan: JAVA_HOME unset shows hint" { + INSTALL_FLINK=Yes + INSTALL_DIR="/opt/flink" + FLINK_HOME="/opt/flink/flink-2.2.0" + unset JAVA_HOME + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *""*) ;; *) false ;; esac +} + +@test "show_install_plan: Flink Agents version line is present" { + INSTALL_FLINK=Yes + INSTALL_DIR="/opt/flink" + FLINK_HOME="/opt/flink/flink-2.2.0" + FLINK_AGENTS_VERSION="0.2.1" + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac +} + +@test "show_install_plan: no redundant 'Install flink-agents JARs' row" { + INSTALL_FLINK=Yes + INSTALL_DIR="/opt/flink" + FLINK_HOME="/opt/flink/flink-2.2.0" + run show_install_plan + [ "$status" -eq 0 ] + case "$output" in *"Install flink-agents JARs"*) false ;; *) ;; esac +} diff --git a/tools/test/unit/smoke.bats b/tools/test/unit/smoke.bats new file mode 100644 index 000000000..08ced7ee2 --- /dev/null +++ b/tools/test/unit/smoke.bats @@ -0,0 +1,18 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +@test "install.sh sources without executing main" { + [ "$(type -t parse_args)" = "function" ] + [ "$(type -t download_file)" = "function" ] +} + +@test "reset_install_sh_state restores defaults" { + NO_PROMPT=1 + reset_install_sh_state + [ "$NO_PROMPT" = "0" ] +} diff --git a/tools/test/unit/ui_helpers.bats b/tools/test/unit/ui_helpers.bats new file mode 100644 index 000000000..61ad31d00 --- /dev/null +++ b/tools/test/unit/ui_helpers.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state + # Force the non-gum fallback branch. + GUM="" +} + +@test "ui_info: prints the message in fallback branch" { + run ui_info "hello world" + [ "$status" -eq 0 ] + [[ "$output" == *"hello world"* ]] +} + +@test "ui_warn: prints the message in fallback branch" { + run ui_warn "be careful" + [ "$status" -eq 0 ] + [[ "$output" == *"be careful"* ]] +} + +@test "ui_success: prints the message in fallback branch" { + run ui_success "all good" + [ "$status" -eq 0 ] + [[ "$output" == *"all good"* ]] +} + +@test "ui_error: prints the message in fallback branch" { + run ui_error "uh oh" + [ "$status" -eq 0 ] + [[ "$output" == *"uh oh"* ]] +} + +@test "ui_kv: prints key and value" { + run ui_kv "Flink version" "2.2.0" + [ "$status" -eq 0 ] + [[ "$output" == *"Flink version"* ]] + [[ "$output" == *"2.2.0"* ]] +} + +@test "ui_stage: increments stage counter and prints title" { + INSTALL_STAGE_CURRENT=0 + run ui_stage "First thing" + [ "$status" -eq 0 ] + [[ "$output" == *"First thing"* ]] + [[ "$output" == *"[1/${INSTALL_STAGE_TOTAL}]"* ]] +} + +@test "die: prints message and exits non-zero" { + run die "fatal boom" + [ "$status" -ne 0 ] + [[ "$output" == *"fatal boom"* ]] +} diff --git a/tools/test/unit/validate_python_bin.bats b/tools/test/unit/validate_python_bin.bats new file mode 100644 index 000000000..87998bd18 --- /dev/null +++ b/tools/test/unit/validate_python_bin.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats + +setup() { + load '../helpers/load' + load '../helpers/shim' + load_install_sh + reset_install_sh_state + shim_setup +} + +# Helper: write a fake python that reports a chosen version. +fake_python() { + local ver="$1" + shim_bin_script fake_py " +case \"\$*\" in + *'sys.version_info.major'*) + echo '$ver' + ;; +esac +" +} + +@test "validate_python_bin: empty path is rejected" { + run validate_python_bin "" + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: missing binary is rejected" { + run validate_python_bin /no/such/bin + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: Python 3.10 is accepted" { + fake_python "3.10" + run validate_python_bin fake_py + [ "$status" -eq 0 ] +} + +@test "validate_python_bin: Python 3.11 is accepted" { + fake_python "3.11" + run validate_python_bin fake_py + [ "$status" -eq 0 ] +} + +@test "validate_python_bin: Python 3.9 is rejected" { + fake_python "3.9" + run validate_python_bin fake_py + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: Python 3.12 is rejected" { + fake_python "3.12" + run validate_python_bin fake_py + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: Python 3.13 is rejected" { + fake_python "3.13" + run validate_python_bin fake_py + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: Python 2.7 is rejected" { + fake_python "2.7" + run validate_python_bin fake_py + [ "$status" -ne 0 ] +} + +@test "validate_python_bin: binary that prints nothing is rejected" { + shim_bin_script fake_py 'exit 0' + run validate_python_bin fake_py + [ "$status" -ne 0 ] +} diff --git a/tools/test/unit/validate_venv_dir.bats b/tools/test/unit/validate_venv_dir.bats new file mode 100644 index 000000000..b41dfd157 --- /dev/null +++ b/tools/test/unit/validate_venv_dir.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats + +# validate_venv_dir classifies a candidate VENV_DIR path so the caller +# can either accept it (new / empty / real venv) or re-prompt the user +# (non-empty foreign directory or file). +# +# Echoes one of: new | empty | venv | nonempty | file +# Exit code 0 for new/empty/venv (caller proceeds), 1 for nonempty/file +# (caller re-prompts). + +setup() { + load '../helpers/load' + load_install_sh + reset_install_sh_state +} + +@test "validate_venv_dir: path that does not exist → 'new'" { + local p="$BATS_TEST_TMPDIR/will-be-created" + run validate_venv_dir "$p" + [ "$status" -eq 0 ] + [ "$output" = "new" ] +} + +@test "validate_venv_dir: empty directory → 'empty'" { + local p="$BATS_TEST_TMPDIR/empty" + mkdir -p "$p" + run validate_venv_dir "$p" + [ "$status" -eq 0 ] + [ "$output" = "empty" ] +} + +@test "validate_venv_dir: directory with pyvenv.cfg → 'venv'" { + local p="$BATS_TEST_TMPDIR/avenv" + mkdir -p "$p/bin" + : > "$p/pyvenv.cfg" + : > "$p/bin/activate" + run validate_venv_dir "$p" + [ "$status" -eq 0 ] + [ "$output" = "venv" ] +} + +@test "validate_venv_dir: non-empty foreign directory → 'nonempty' (rc=1)" { + local p="$BATS_TEST_TMPDIR/foreign" + mkdir -p "$p" + : > "$p/some-unrelated-file" + : > "$p/README.md" + run validate_venv_dir "$p" + [ "$status" -eq 1 ] + [ "$output" = "nonempty" ] +} + +@test "validate_venv_dir: path is a regular file → 'file' (rc=1)" { + local p="$BATS_TEST_TMPDIR/just-a-file" + : > "$p" + run validate_venv_dir "$p" + [ "$status" -eq 1 ] + [ "$output" = "file" ] +} + +@test "validate_venv_dir: directory with only a hidden file is still 'nonempty'" { + local p="$BATS_TEST_TMPDIR/dot" + mkdir -p "$p" + : > "$p/.hiddenfile" + run validate_venv_dir "$p" + [ "$status" -eq 1 ] + [ "$output" = "nonempty" ] +} + +@test "validate_venv_dir: bin/activate without pyvenv.cfg is still 'nonempty'" { + # A user could have a random project with a bin/activate file. Treat + # only the strict pyvenv.cfg marker as a real venv. + local p="$BATS_TEST_TMPDIR/halfbaked" + mkdir -p "$p/bin" + : > "$p/bin/activate" + run validate_venv_dir "$p" + [ "$status" -eq 1 ] + [ "$output" = "nonempty" ] +} + +@test "validate_venv_dir: empty argument → 'file' (rc=1) (treated as invalid)" { + run validate_venv_dir "" + [ "$status" -eq 1 ] +}