#!/usr/bin/env bash set -euo pipefail BASE_URL="https://raw.githubusercontent.com/abahmed/kwatch" RELEASES_URL="https://api.github.com/repos/abahmed/kwatch/releases/latest" RELEASES_LIST_URL="https://api.github.com/repos/abahmed/kwatch/releases?per_page=30" RELEASE_DOWNLOAD_URL="https://github.com/abahmed/kwatch/releases/download" DEFAULT_NAMESPACE="kwatch" DEFAULT_RELEASE="kwatch" CATALOG_VERSION=1 FEATURE_CATALOG_VERSION=1 PROVIDER_CATALOG_VERSION=1 SUPPORTED_CATALOG_VERSION=1 SUPPORTED_FEATURE_CATALOG_VERSIONS="1 2" SUPPORTED_PROVIDER_CATALOG_VERSION=1 MAX_KUBECTL_ATTEMPTS=3 ROLLOUT_TIMEOUT="${KWATCH_ROLLOUT_TIMEOUT:-5m}" SELECTED_CONTEXT="" INSTALL_STATE="" INSTALL_DEPLOYMENT="" INSTALL_VERSION="" INSTALL_REASON="" LAST_COMMAND_ERROR="" PROVIDER_EDIT_INTENT=false INJECTOR_OPT_OUT_PROFILES=() INJECTOR_OPT_OUT_PATCH_AFTER=false MENU_EXIT=false # Set by a menu action that only looked at the installation, so the session # does not re-assess a workload nothing touched. MENU_READ_ONLY=false # True once the settings flow has written something, so leaving it untouched # costs nothing on the way back. CONFIG_FLOW_CHANGED=false UI_SELECT_HINTS=() UI_SELECT_PENDING_HINTS=() HEADER_HEALTH=unknown SESSION_CACHE_DIR="" FRESH_INSTALL=false MIGRATION_TARGET_VERSION="" FEATURE_CATALOG_SOURCE="unavailable" FEATURE_CATALOG=() PROVIDER_CATALOG_SOURCE="unavailable" CATALOG_SOURCE="unavailable" CATALOG=() PROVIDER_CATALOG=() CONFIG_MOUNT_PATH="/config" # The data volume: "pvc" (the release default, a ReadWriteOnce claim on the # default StorageClass) or "emptydir" (no StorageClass needed; the state is # lost when the Pod moves). KWATCH_STORAGE picks one without a prompt, # KWATCH_STORAGE_CLASS names the StorageClass for the claim and # KWATCH_STORAGE_SIZE sizes the volume. STORAGE_MODE="${KWATCH_STORAGE:-}" STORAGE_CLASS="${KWATCH_STORAGE_CLASS:-}" STORAGE_SIZE="${KWATCH_STORAGE_SIZE:-2Gi}" # The first release that keeps its state in one file on the data volume # instead of in ConfigMaps. Upgrading across it rebuilds the state. VOLUME_STATE_VERSION="v1.0.0-rc.11" # The storage driver's topology label chosen before the apply, recorded on # the Deployment once it exists so upgrades keep the placement. PLANNED_STORAGE_KEY="" # Deployment resources and placement are the operator's, not the release's: the # manifest is re-downloaded on every run, so a hand-edited Deployment would be # reverted by the next upgrade. Recording the choices on the workload itself -- # rather than in a file beside the script -- keeps them with the cluster they # belong to, so any operator running the manager against it sees and keeps the # same settings. Only what was set explicitly is recorded, which leaves the # release free to change the defaults for everything else. DEPLOYMENT_OVERRIDES_ANNOTATION="kwatch.sh/deployment-overrides" # What the key was called before it was named after the object it edits. Read, # never written, so an installation recorded under the old name keeps its # sizing instead of quietly reverting to the release defaults on the next # upgrade. Retired once it is read: writing moves the record to the new name. LEGACY_OVERRIDES_ANNOTATION="kwatch.sh/pod-overrides" DEPLOYMENT_OVERRIDES="" # Where the values in DEPLOYMENT_OVERRIDES came from: the running Deployment, or the # record that survived an uninstall. Worth telling the operator apart, because # the second case restores sizing they may have forgotten setting. DEPLOYMENT_OVERRIDES_SOURCE="" # True when a record was found under the name used before the rename, so the # save that follows knows there is an old copy to retire. LEGACY_OVERRIDES_IN_USE=false # Brand palette taken from the kwatch logo: #00ACD7 (Go cyan) and #326CE5 # (Kubernetes blue), with #E8F0FE as the pale highlight. Rendered as 24-bit # colour where the terminal advertises it, as the nearest xterm-256 entries # otherwise, and as plain ANSI on everything else. UI_COLOR=false if [ "${BASH_VERSINFO[0]:-3}" -ge 4 ]; then UI_ESC_TIMEOUT=0.05 else UI_ESC_TIMEOUT=1 fi if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != dumb ]; then UI_COLOR=true UI_RESET=$'\033[0m' UI_BOLD=$'\033[1m' UI_CYAN=$'\033[36m' UI_GREEN=$'\033[32m' UI_YELLOW=$'\033[33m' UI_RED=$'\033[31m' UI_DIM=$'\033[2m' UI_BLUE=$'\033[34m' UI_MAGENTA=$'\033[35m' UI_CLEAR=$'\033[K' UI_CLEAR_LINE=$'\033[2K\r' UI_HIDE_CURSOR=$'\033[?25l' UI_SHOW_CURSOR=$'\033[?25h' case "${COLORTERM:-}" in truecolor|24bit) UI_BRAND=$'\033[38;2;0;172;215m' UI_BRAND_ALT=$'\033[38;2;50;108;229m' UI_BRAND_PALE=$'\033[38;2;232;240;254m' UI_BRAND_BG=$'\033[48;2;50;108;229m' ;; *) if [ "$(tput colors 2>/dev/null || echo 8)" -ge 256 ]; then UI_BRAND=$'\033[38;5;38m' UI_BRAND_ALT=$'\033[38;5;62m' UI_BRAND_PALE=$'\033[38;5;255m' UI_BRAND_BG=$'\033[48;5;62m' else UI_BRAND="$UI_CYAN" UI_BRAND_ALT="$UI_BLUE" UI_BRAND_PALE="$UI_BOLD" UI_BRAND_BG=$'\033[44m' fi ;; esac else UI_RESET="" UI_BOLD="" UI_CYAN="" UI_GREEN="" UI_YELLOW="" UI_RED="" UI_DIM="" UI_BLUE="" UI_MAGENTA="" UI_CLEAR="" UI_CLEAR_LINE="" UI_HIDE_CURSOR="" UI_SHOW_CURSOR="" UI_BRAND="" UI_BRAND_ALT="" UI_BRAND_PALE="" UI_BRAND_BG="" fi # The tab title is the only place the step in progress is visible while the # window sits in the background, and a cluster name in the tab is what stops a # second window being mistaken for this one. OSC 0 sets the window and tab title on xterm, iTerm2, # Terminal.app, kitty, Alacritty, WezTerm and tmux; OSC 22/23 push the # terminal's own title and pop it back on exit, so the shell's prompt title # returns. Terminals that do not implement them ignore both silently. Skipped # when stderr is not a terminal, and with KWATCH_NO_TITLE for a terminal that # prints the escape instead of acting on it. UI_TITLE=false UI_TITLE_BASE="kwatch" if [ -t 2 ] && [ -z "${KWATCH_NO_TITLE:-}" ]; then case "${TERM:-}" in ""|dumb|linux) ;; *) UI_TITLE=true ;; esac fi ui_set_title() { [ "$UI_TITLE" = true ] || return 0 printf '\033]0;%s\007' "$*" >&2 } # Name the cluster in the tab for the whole session, and the step while one is # running. ui_title_step() { if [ -n "${1:-}" ]; then ui_set_title "$UI_TITLE_BASE — $1" else ui_set_title "$UI_TITLE_BASE" fi } ui_title_push() { [ "$UI_TITLE" = true ] || return 0 printf '\033[22;0t' >&2 } ui_title_pop() { [ "$UI_TITLE" = true ] || return 0 printf '\033[23;0t' >&2 } # Retires an unfinished busy line before anything is printed over it. Clearing # an already-empty line is a no-op, so a flag left set by a subshell costs # nothing. UI_BUSY_ACTIVE=false ui_busy_clear() { [ "$UI_BUSY_ACTIVE" = true ] || return 0 UI_BUSY_ACTIVE=false printf '%s' "$UI_CLEAR_LINE" >&2 return 0 } ui_info() { ui_busy_clear; printf '%s%s%s\n' "$UI_BRAND" "$*" "$UI_RESET" >&2; } ui_success() { ui_busy_clear; printf '%s%s%s\n' "$UI_GREEN" "$*" "$UI_RESET" >&2; } ui_warn() { ui_busy_clear; printf '%s%s%s\n' "$UI_YELLOW" "$*" "$UI_RESET" >&2; } ui_error() { ui_busy_clear; printf '%s%s%s\n' "$UI_RED" "$*" "$UI_RESET" >&2; } back_hint() { printf '%s(↩️ type back)%s' "$UI_DIM" "$UI_RESET"; } ui_heading() { ui_busy_clear printf '\n%s%s%s\n' "$UI_BOLD$UI_BRAND" "$*" "$UI_RESET" >&2 } ui_detail() { ui_busy_clear; printf '%s%s%s\n' "$UI_DIM" "$*" "$UI_RESET" >&2; } # A message printed just before a screen is redrawn is wiped by the redraw -- # the result of the change you just made would flash past. Hold it, and let the # redrawn screen print it at the top. FLOW_NOTICE_KIND="" FLOW_NOTICE_TEXT="" flow_notice() { FLOW_NOTICE_KIND="$1" shift FLOW_NOTICE_TEXT="$*" } show_flow_notice() { [ -n "$FLOW_NOTICE_TEXT" ] || return 0 case "$FLOW_NOTICE_KIND" in success) ui_success "$FLOW_NOTICE_TEXT" ;; warn) ui_warn "$FLOW_NOTICE_TEXT" ;; error) ui_error "$FLOW_NOTICE_TEXT" ;; *) ui_info "$FLOW_NOTICE_TEXT" ;; esac FLOW_NOTICE_KIND="" FLOW_NOTICE_TEXT="" printf '\n' >&2 return 0 } # Selection lists are drawn in a fixed window: kwatch has a settings category # with 89 entries, and a list longer than the terminal cannot be redrawn in # place. The window scrolls with the cursor, and a list that needs scrolling # also accepts typing to narrow it -- the only practical way through 89 items. UI_SELECT_LABELS=() UI_SELECT_MATCHES=() # Indices of the entries matching the current filter, in order. ui_select_filter() { local filter="$1" index label lower UI_SELECT_MATCHES=() lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]') for index in "${!UI_SELECT_LABELS[@]}"; do if [ -z "$lower" ]; then UI_SELECT_MATCHES+=("$index") continue fi label=$(printf '%s' "${UI_SELECT_LABELS[$index]}" | tr '[:upper:]' '[:lower:]') case "$label" in *"$lower"*) UI_SELECT_MATCHES+=("$index") ;; esac done } ui_select_render() { local current="$1" top="$2" visible="$3" filter="$4" columns="${5:-80}" local total row index hint local detail="" total=${#UI_SELECT_MATCHES[@]} # The short-list keymap includes j/k and 1-9; undocumented shortcuts are the # same as no shortcuts. hint=" ↑/↓ move · 1-9 pick · ⏎ choose · esc back" [ "$total" -gt "$visible" ] && hint=" ↑/↓ move · ⏎ choose · type to filter · esc back" # The counter moves up here when the rows carry their own detail line, so the # window keeps the same height either way. if [ "$total" -gt "$visible" ] && [ "${#UI_SELECT_HINTS[@]}" -gt 0 ]; then hint="$hint · $((current + 1)) of $total" fi [ -n "$filter" ] && hint=" filter: ${filter}_" printf '%s%s%s%s%s\n' "$UI_CLEAR_LINE" "$UI_DIM" "$hint" "$UI_RESET" "$UI_CLEAR" >&2 for ((row = 0; row < visible; row++)); do index=$((top + row)) if [ "$index" -ge "$total" ]; then printf '%s%s\n' "$UI_CLEAR_LINE" "$UI_CLEAR" >&2 continue fi if [ "$index" -eq "$current" ]; then printf '%s%s%s ❯ %s%s%s\n' "$UI_CLEAR_LINE" "$UI_BOLD" "$UI_BRAND" \ "${UI_SELECT_LABELS[${UI_SELECT_MATCHES[$index]}]}" "$UI_RESET" "$UI_CLEAR" >&2 else printf '%s %s%s%s%s\n' "$UI_CLEAR_LINE" "$UI_DIM" \ "${UI_SELECT_LABELS[${UI_SELECT_MATCHES[$index]}]}" "$UI_RESET" "$UI_CLEAR" >&2 fi done # A row is one line, so a long description would be truncated into it. Give # the highlighted row's full text the line below the window instead, where # there is room for it. if [ "$total" -gt 0 ] && [ "${#UI_SELECT_HINTS[@]}" -gt 0 ]; then detail="${UI_SELECT_HINTS[${UI_SELECT_MATCHES[$current]}]:-}" fi if [ "$total" -eq 0 ]; then # Filtering to nothing leaves an empty frame; say why rather than looking # broken, and keep the filter on screen so it can be edited back. printf '%s%s no matches — backspace to edit the filter%s%s\n' \ "$UI_CLEAR_LINE" "$UI_YELLOW" "$UI_RESET" "$UI_CLEAR" >&2 elif [ -n "$detail" ]; then printf '%s%s %s%s%s\n' "$UI_CLEAR_LINE" "$UI_DIM" \ "$(ui_truncate "$detail" "$((columns - 4))")" "$UI_RESET" \ "$UI_CLEAR" >&2 elif [ "$total" -gt "$visible" ]; then printf '%s%s %s of %s%s%s\n' "$UI_CLEAR_LINE" "$UI_DIM" \ "$((current + 1))" "$total" "$UI_RESET" "$UI_CLEAR" >&2 else printf '%s%s\n' "$UI_CLEAR_LINE" "$UI_CLEAR" >&2 fi } # Clears the window plus the hint line above it and the counter below. ui_select_erase() { local visible="$1" index total total=$((visible + 2)) printf '\033[%sA' "$total" >&2 for ((index = 0; index < total; index++)); do printf '%s\n' "$UI_CLEAR_LINE" >&2 done printf '\033[%sA' "$total" >&2 } # Prints the chosen entry's 0-based index on stdout; returns 1 when the user # cancels. Falls back to a numbered prompt when there is no terminal to draw on, # so pipes and KWATCH_PLAIN_UI still work. # A list whose rows carry a caption: the row stays one line, and the # highlighted row's full text is printed below the window. Captions are passed # positionally -- labels, then captions -- because ui_select # runs in a command substitution and a global set by the caller could not be # cleared again. ui_select_captioned() { local default_index="$1" count="$2" index shift 2 local -a labels=() for ((index = 0; index < count; index++)); do labels+=("$1") shift done UI_SELECT_PENDING_HINTS=("$@") ui_select "$default_index" "${labels[@]}" } ui_select() { local default_index="$1"; shift local count="$#" current top visible rows key rest answer index filter="" local total columns [ "$count" -gt 0 ] || return 1 UI_SELECT_LABELS=("$@") # Captions arrive through ui_select_captioned, which sets them inside this # same command substitution; a plain ui_select call never sees any. UI_SELECT_HINTS=("${UI_SELECT_PENDING_HINTS[@]-}") if [ "${#UI_SELECT_HINTS[@]}" -ne "$count" ]; then UI_SELECT_HINTS=() fi if ! ui_interactive; then for ((index = 0; index < count; index++)); do printf ' %s) %s\n' "$((index + 1))" "${UI_SELECT_LABELS[$index]}" >&2 done while true; do answer=$(ask "Choice" "$((default_index + 1))") || exit_expected if [[ "$answer" =~ ^[0-9]+$ ]] && [ "$answer" -ge 1 ] && [ "$answer" -le "$count" ]; then printf '%s' "$((answer - 1))" return 0 fi is_back_choice "$answer" && return 1 ui_warn "⚠️ Choose a number from 1 to $count." done fi rows=$(ui_rows) # tput is a fork, and the render runs on every keystroke: measure the terminal # once per list instead of once per redraw. columns=$(ui_columns) visible=$((rows - 8)) [ "$visible" -lt 5 ] && visible=5 [ "$visible" -gt "$count" ] && visible="$count" ui_busy_clear ui_select_filter "" current="$default_index" [ "$current" -ge 0 ] && [ "$current" -lt "$count" ] || current=0 top=0 [ "$current" -ge "$visible" ] && top=$((current - visible + 1)) printf '\n%s' "$UI_HIDE_CURSOR" >&2 ui_select_render "$current" "$top" "$visible" "$filter" "$columns" while true; do IFS= read -rsn1 key || { ui_select_erase "$visible" printf '%s' "$UI_SHOW_CURSOR" >&2 input_ended } total=${#UI_SELECT_MATCHES[@]} case "$key" in $'\x1b') # An arrow arrives as ESC [ A; a bare Escape has nothing after it. bash # 3.2, still the default on macOS, rejects a fractional -t. IFS= read -rsn2 -t "$UI_ESC_TIMEOUT" rest || rest="" case "$rest" in '[A') [ "$total" -gt 0 ] && current=$(((current - 1 + total) % total)) ;; '[B') [ "$total" -gt 0 ] && current=$(((current + 1) % total)) ;; '') ui_select_erase "$visible" printf '%s' "$UI_SHOW_CURSOR" >&2 return 1 ;; *) ;; esac ;; '') [ "$total" -gt 0 ] || continue ui_select_erase "$visible" printf '%s' "$UI_SHOW_CURSOR" >&2 printf '%s' "${UI_SELECT_MATCHES[$current]}" return 0 ;; $'\x7f'|$'\b') if [ -n "$filter" ]; then filter="${filter%?}" ui_select_filter "$filter" current=0 top=0 fi ;; *) if [ "$count" -le "$visible" ]; then # Short list: keep the familiar shortcuts rather than filtering. case "$key" in k|K) [ "$total" -gt 0 ] && current=$(((current - 1 + total) % total)) ;; j|J) [ "$total" -gt 0 ] && current=$(((current + 1) % total)) ;; q|Q|b|B) ui_select_erase "$visible" printf '%s' "$UI_SHOW_CURSOR" >&2 return 1 ;; [1-9]) if [ "$key" -le "$total" ]; then current=$((key - 1)) ui_select_erase "$visible" printf '%s' "$UI_SHOW_CURSOR" >&2 printf '%s' "${UI_SELECT_MATCHES[$current]}" return 0 fi ;; esac else case "$key" in [[:print:]]) filter="$filter$key" ui_select_filter "$filter" current=0 top=0 ;; esac fi ;; esac total=${#UI_SELECT_MATCHES[@]} [ "$current" -ge "$total" ] && current=$((total > 0 ? total - 1 : 0)) [ "$current" -lt "$top" ] && top="$current" [ "$current" -ge $((top + visible)) ] && top=$((current - visible + 1)) [ "$top" -lt 0 ] && top=0 # The render writes the hint, the window and the counter: rewind over all # of them, not just the window. printf '\033[%sA' "$((visible + 2))" >&2 ui_select_render "$current" "$top" "$visible" "$filter" "$columns" done } # Every screen is drawn as a frame: the wordmark, one line that always says # which cluster and workload is being managed and whether it is healthy, then # the screen's own content. Menus clear first so the frame stays at the top; # result screens append and end with a pause, so nothing is wiped before it has # been read. ui_screen_clear() { ui_interactive || return 0 printf '\033[H\033[2J' >&2 } ui_header() { local title="$1" health="${HEADER_HEALTH:-unknown}" dot detail case "$health" in healthy) dot=$(ui_dot ok) ;; absent) dot=$(ui_dot warn) ;; *) dot=$(ui_dot bad) ;; esac printf '\n %s%skwatch%s %s%s%s\n' "$UI_BOLD" "$UI_BRAND" "$UI_RESET" \ "$UI_DIM" "${title:+· $title}" "$UI_RESET" >&2 detail="$(context_label "${SELECTED_CONTEXT:-}") · ns ${NAMESPACE:-kwatch}" [ -n "${INSTALL_VERSION:-}" ] && detail="$detail · ${INSTALL_VERSION}" printf ' %s %s%s%s\n' "$dot" "$UI_DIM" "$detail" "$UI_RESET" >&2 ui_rule } ui_screen() { ui_screen_clear ui_header "$1" } # Result screens would otherwise be erased by the next menu redraw. ui_pause() { ui_interactive || return 0 printf '\n' >&2 ui_detail " Press Enter to return to the menu" IFS= read -r _ || true } # with_loading runs its command in a background subshell, so a step that sets # globals -- assess_installation, the settings gather -- cannot use it and used # to leave a cleared screen with nothing on it while kubectl worked. Print a # line, do the work in this shell, then erase the line. ui_busy() { ui_interactive || return 0 UI_BUSY_ACTIVE=true printf '%s%s⏳ %s…%s' "$UI_CLEAR_LINE" "$UI_DIM" "$*" "$UI_RESET" >&2 } ui_busy_done() { ui_interactive || return 0 UI_BUSY_ACTIVE=false printf '%s' "$UI_CLEAR_LINE" >&2 } ui_rows() { local rows rows=$(tput lines 2>/dev/null || true) [[ "$rows" =~ ^[0-9]+$ ]] || rows="${LINES:-24}" [[ "$rows" =~ ^[0-9]+$ ]] || rows=24 printf '%s' "$rows" } ui_columns() { local columns columns=$(tput cols 2>/dev/null || true) # tput needs a terminal on stdout; fall back to the shell's own idea of the # width before giving up on 80. [[ "$columns" =~ ^[0-9]+$ ]] || columns="${COLUMNS:-80}" [[ "$columns" =~ ^[0-9]+$ ]] || columns=80 printf '%s' "$columns" } # Selection rows are counted in lines, so a row that wraps would break the # window arithmetic. Everything that goes in one is cut to fit first. ui_truncate() { local text="$1" width="$2" if [ "$width" -le 1 ] || [ "${#text}" -le "$width" ]; then printf '%s' "$text" return fi printf '%s…' "${text:0:$((width - 1))}" } ui_rule() { printf ' %s%s%s\n' "$UI_DIM" \ "────────────────────────────────────────────────────" "$UI_RESET" >&2 } # An emoji built from a variation selector (⬆️, ⚙️, 🏷️) renders one column wide # in most terminals while a plain emoji (🚀, 📦) renders two, so labels drift out # of line when they are simply concatenated. Pass the icon separately and pad the # label to a fixed width instead. ui_kv() { local icon="$1" label="$2" value="$3" printf ' %s %s%-11s%s %s%s%s\n' "$icon" "$UI_DIM" "$label" "$UI_RESET" \ "$UI_BRAND_PALE" "$value" "$UI_RESET" >&2 } # Green when healthy, red when not: the state is readable before the words are. ui_dot() { case "$1" in ok) printf '%s●%s' "$UI_GREEN" "$UI_RESET" ;; bad) printf '%s●%s' "$UI_RED" "$UI_RESET" ;; *) printf '%s●%s' "$UI_YELLOW" "$UI_RESET" ;; esac } # The banner is drawn one line at a time so the manager opens with a visible # beat rather than a wall of text. It is skipped whenever the output is not an # interactive terminal, so logs and pipes stay clean. # The kwatch mark drawn from its own SVG: the Kubernetes hexagon, the radar # scope sweeping the upper-right quadrant with a detection blip, the eight # resource dots, and the K letterform at the centre. Revealed a row at a time, # and replaced by a plain heading wherever colour is not available. # A small wordmark rather than a drawn logo: the manager opens on the cluster # and the menu, and the header should not push them down the screen. ui_banner() { if [ "$UI_COLOR" != true ] || [ "${KWATCH_PLAIN_UI:-false}" = true ]; then ui_heading "kwatch interactive manager" return 0 fi printf '\n' >&2 printf ' %s%skwatch%s %s·%s %sinteractive manager%s\n' \ "$UI_BOLD" "$UI_BRAND" "$UI_RESET" "$UI_BRAND_ALT" "$UI_RESET" \ "$UI_BRAND_PALE" "$UI_RESET" >&2 printf ' %s%s%s\n\n' "$UI_DIM" \ "kubectl · operational security checks" "$UI_RESET" >&2 } context_label() { local context="$1" label case "$context" in arn:*:cluster/*) label="cluster/${context##*/}" ;; *) label="$context" ;; esac if [ "${#label}" -gt 56 ]; then printf '%s…%s' "${label:0:24}" "${label: -28}" else printf '%s' "$label" fi } with_loading() { local label="$1"; shift local command_name="${1:-command}" local output_file error_file pid frame=0 index char rc diagnostic local started elapsed timer="" hue local -a spinner_frames=( '⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏' ) local -a spinner_hues=( "$UI_BRAND" "$UI_BRAND_ALT" "$UI_BRAND" "$UI_BRAND_ALT" ) LAST_COMMAND_ERROR="" if [ ! -t 2 ] || [ "${KWATCH_PLAIN_UI:-false}" = true ]; then error_file=$(mktemp) if "$@" 2>"$error_file"; then cat "$error_file" >&2 rm -f "$error_file" return 0 else rc=$? diagnostic=$(cat "$error_file" || true) LAST_COMMAND_ERROR=$(printf '%s\n' "$diagnostic" | sed '/^[[:space:]]*$/d' | tail -20 || true) [ -n "$LAST_COMMAND_ERROR" ] || LAST_COMMAND_ERROR="$label failed (exit status $rc) while running $command_name; no diagnostic was returned." cat "$error_file" >&2 rm -f "$error_file" return "$rc" fi fi output_file=$(mktemp) error_file=$(mktemp) "$@" >"$output_file" 2>"$error_file" & pid=$! trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; \ rm -f "$output_file" "$error_file"; \ printf "%s%s" "$UI_CLEAR_LINE" "$UI_SHOW_CURSOR" >&2; exit 130' INT trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; \ rm -f "$output_file" "$error_file"; \ printf "%s%s" "$UI_CLEAR_LINE" "$UI_SHOW_CURSOR" >&2; exit 143' TERM started=$SECONDS printf '%s' "$UI_HIDE_CURSOR" >&2 ui_title_step "$label" while kill -0 "$pid" 2>/dev/null; do index=$((frame % ${#spinner_frames[@]})) char="${spinner_frames[$index]}" hue="${spinner_hues[$(((frame / 4) % ${#spinner_hues[@]}))]}" elapsed=$((SECONDS - started)) # Only time the waits that are long enough to make someone wonder. if [ "$elapsed" -ge 3 ]; then timer=" ${UI_DIM}${elapsed}s${UI_RESET}" fi printf '%s%s%s %s%s%s%s%s' "$UI_CLEAR_LINE" "$hue" "$char" \ "$UI_CYAN" "$label" "$UI_RESET" "$timer" "$UI_CLEAR" >&2 sleep 0.1 frame=$((frame + 1)) done if wait "$pid"; then rc=0 else rc=$? fi install_signal_traps ui_restore_terminal ui_title_step "" elapsed=$((SECONDS - started)) [ "$elapsed" -ge 3 ] && timer=" ${UI_DIM}(${elapsed}s)${UI_RESET}" || timer="" if [ "$rc" -eq 0 ]; then printf '%s%s✅ %s%s%s%s\n' "$UI_CLEAR_LINE" "$UI_GREEN" "$label" "$UI_RESET" \ "$timer" "$UI_CLEAR" >&2 cat "$output_file" cat "$error_file" >&2 rm -f "$output_file" "$error_file" return 0 fi printf '%s%s❌ %s%s%s%s\n' "$UI_CLEAR_LINE" "$UI_RED" "$label" "$UI_RESET" \ "$timer" "$UI_CLEAR" >&2 LAST_COMMAND_ERROR=$(cat "$error_file" "$output_file" | sed '/^[[:space:]]*$/d' | tail -20 || true) if [ -z "$LAST_COMMAND_ERROR" ]; then LAST_COMMAND_ERROR="$label failed (exit status $rc) while running $command_name; no diagnostic was returned." fi cat "$output_file" cat "$error_file" >&2 rm -f "$output_file" "$error_file" return "$rc" } # The spinner and the selection list hide the cursor. Without these traps, a # Ctrl-C at the wrong moment left the terminal with no cursor until `reset`. ui_restore_terminal() { printf '%s' "$UI_SHOW_CURSOR" >&2; } cleanup_session() { local rc=$? ui_restore_terminal ui_title_pop report_unexpected_exit "$rc" [ -n "$SESSION_CACHE_DIR" ] && rm -rf "$SESSION_CACHE_DIR" 2>/dev/null return 0 } # Only INT is trapped. A TERM handler would catch the signal that input_ended # sends to end the session, and bash defers it until the command substitution # the prompt runs in has returned -- by which time the calling loop has already # asked again, so the session never ended. TERM stays fatal by default; the # cursor is restored before the signal is sent instead. install_signal_traps() { trap 'ui_restore_terminal; ui_title_pop; exit 130' INT trap - TERM } install_signal_traps trap cleanup_session EXIT die() { EXPECTED_EXIT=true; ui_error "Error: $*"; exit 1; } # `set -e` ends the manager wherever a command fails, which used to leave the # user with a bare kubectl line and no idea what was in progress. Reported from # the exit trap rather than from an ERR trap: an ERR trap needs errtrace to see # failures inside functions, and errtrace also carries it into every command # substitution, where a deliberate "cancelled" return looked like a crash. EXPECTED_EXIT=false exit_expected() { EXPECTED_EXIT=true exit "${1:-1}" } report_unexpected_exit() { local rc="$1" [ "$rc" -ne 0 ] || return 0 [ "$EXPECTED_EXIT" != true ] || return 0 ui_error "❌ The manager stopped unexpectedly (exit status $rc)." [ -n "${LAST_COMMAND_ERROR:-}" ] && ui_detail "$(compact_reason "$LAST_COMMAND_ERROR")" ui_info \ "ℹ️ No further changes were made. Re-run kwatch.sh and choose View status to inspect the installation." return 0 } need() { type -P "$1" >/dev/null 2>&1 || die "'$1' is required"; } require_tools() { need kubectl; need curl; } # The manager relies on server-side dry run and on Pod Security admission, both # of which need a reasonably recent cluster. Warn once rather than failing later # in a way that looks like a kwatch problem. check_cluster_version() { local version minor # "Server Version: v1.35.6-eks-bca9cf6" is one stable line across kubectl # versions; the JSON form splits the field across lines and needs a parser. version=$(command kubectl --context "$SELECTED_CONTEXT" version 2>/dev/null | sed -n 's/^Server Version: v\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1 || true) minor="${version#*.}" [ -n "$minor" ] && [[ "$minor" =~ ^[0-9]+$ ]] || return 0 if [ "$minor" -lt 25 ] 2>/dev/null; then ui_warn \ "⚠️ Kubernetes 1.$minor predates built-in Pod Security admission (1.25);" \ "the manager's security verification may not apply." fi } # Every prompt is read inside a command substitution, so `die` would end only # that subshell: the menu loop around it would then re-prompt on a closed stdin # for ever, spinning on end-of-file. Signal the manager itself so the session # ends when input is gone -- a closed stdin or Ctrl-D at any prompt. # End of input is reported by exit status, not by a signal. Signalling the main # shell left the subshell that owns the asking loop running as an orphan, and it # span on the closed stdin for ever; every prompt site propagates this status # instead. The ERR trap is cleared first: this exit is deliberate. input_ended() { printf '\n' >&2 ui_error "Error: input ended before the operation was complete" ui_restore_terminal exit_expected 1 } ask() { local prompt="$1" default="${2:-}" answer ui_busy_clear [ -n "$default" ] && prompt="$prompt [$default]" printf '%s%s%s%s: ' "$UI_BOLD" "$UI_CYAN" "$prompt" "$UI_RESET" >&2 IFS= read -r answer || input_ended printf '%s' "${answer:-$default}" } # `ask` cannot see Escape: in canonical mode the key is just a byte in the line # buffer, so it does nothing until Enter is pressed -- which is why "esc back" # worked in the lists but not at a value prompt. Reading raw keeps Escape live, # at the cost of doing the line editing here. Printable characters, backspace, # Ctrl-U and Enter is all a setting value needs. # # Prints the answer and returns 0; returns 2 for Escape or a typed "back". ask_or_back() { local prompt="$1" default="${2:-}" buffer="" key rest shown if ! ui_interactive; then buffer=$(ask "$prompt $(back_hint)" "$default") || exit_expected is_back_choice "$buffer" && return 2 printf '%s' "$buffer" return 0 fi ui_busy_clear shown="$prompt" [ -n "$default" ] && shown="$prompt [$default]" printf '%s%s%s%s: ' "$UI_BOLD" "$UI_CYAN" "$shown" "$UI_RESET" >&2 while true; do IFS= read -rsn1 key || { printf '\n' >&2; input_ended; } case "$key" in $'\x1b') # An arrow key arrives as ESC [ A; a bare Escape has nothing after it. IFS= read -rsn2 -t "$UI_ESC_TIMEOUT" rest || rest="" if [ -z "$rest" ]; then printf '\n' >&2 return 2 fi ;; '') printf '\n' >&2 [ -n "$buffer" ] || buffer="$default" is_back_choice "$buffer" && return 2 printf '%s' "$buffer" return 0 ;; $'\x7f'|$'\b') if [ -n "$buffer" ]; then buffer="${buffer%?}" printf '\b \b' >&2 fi ;; $'\x03') printf '\n' >&2 ui_restore_terminal ui_title_pop exit 130 ;; $'\x15') while [ -n "$buffer" ]; do buffer="${buffer%?}" printf '\b \b' >&2 done ;; *) # Stray control bytes would corrupt both the echo and the value; every # other byte is passed through, so UTF-8 still arrives intact. case "$key" in [[:cntrl:]]) ;; *) buffer="$buffer$key"; printf '%s' "$key" >&2 ;; esac ;; esac done } ask_secret() { local prompt="$1" answer ui_busy_clear printf '%s%s%s%s: ' "$UI_BOLD" "$UI_CYAN" "$prompt" "$UI_RESET" >&2 IFS= read -r -s answer || input_ended printf '\n' >&2 printf '%s' "$answer" } # ask_or_back for a value that must not appear on screen. Escape leaves, and # each keystroke draws a bullet so there is still feedback that it landed. # Returns 2 for Escape or a typed "back". ask_secret_or_back() { local prompt="$1" buffer="" key rest if ! ui_interactive; then buffer=$(ask_secret "$prompt $(back_hint)") || exit_expected is_back_choice "$buffer" && return 2 printf '%s' "$buffer" return 0 fi ui_busy_clear printf '%s%s%s%s: ' "$UI_BOLD" "$UI_CYAN" "$prompt" "$UI_RESET" >&2 while true; do IFS= read -rsn1 key || { printf '\n' >&2; input_ended; } case "$key" in $'\x1b') IFS= read -rsn2 -t "$UI_ESC_TIMEOUT" rest || rest="" if [ -z "$rest" ]; then printf '\n' >&2 return 2 fi ;; '') printf '\n' >&2 is_back_choice "$buffer" && return 2 printf '%s' "$buffer" return 0 ;; $'\x7f'|$'\b') if [ -n "$buffer" ]; then buffer="${buffer%?}" printf '\b \b' >&2 fi ;; $'\x03') printf '\n' >&2 ui_restore_terminal ui_title_pop exit 130 ;; $'\x15') while [ -n "$buffer" ]; do buffer="${buffer%?}" printf '\b \b' >&2 done ;; *) case "$key" in [[:cntrl:]]) ;; *) buffer="$buffer$key"; printf '•' >&2 ;; esac ;; esac done } # True when the terminal can draw an interactive selection list. ui_interactive() { [ -t 0 ] && [ -t 2 ] && [ "$UI_COLOR" = true ] && [ "${KWATCH_PLAIN_UI:-false}" != true ] } ask_yes_no() { local prompt="$1" default="$2" answer index if ui_interactive; then printf '%s%s%s%s\n' "$UI_BOLD" "$UI_BRAND" "$prompt" "$UI_RESET" >&2 case "$default" in y|Y|yes|Yes|true) index=0 ;; *) index=1 ;; esac # The list is erased on selection, so echo the answer: the question would # otherwise be left on screen with nothing under it. if index=$(ui_select "$index" "✅ Yes" "🚫 No"); then if [ "$index" = 0 ]; then ui_detail " ✅ Yes" printf 'true' else ui_detail " 🚫 No" printf 'false' fi else ui_detail " 🚫 No" printf 'false' fi return 0 fi while true; do answer=$(ask "$prompt" "$default") || exit_expected case "$answer" in y|Y|yes|Yes) printf 'true'; return 0 ;; n|N|no|No) printf 'false'; return 0 ;; *) ui_warn "⚠️ Please answer yes or no." ;; esac done } is_back_choice() { case "$1" in b|B|back|Back|BACK) return 0 ;; *) return 1 ;; esac } ask_yes_no_or_back() { local prompt="$1" default="$2" answer index if ui_interactive; then printf '%s%s%s%s\n' "$UI_BOLD" "$UI_BRAND" "$prompt" "$UI_RESET" >&2 case "$default" in y|Y|yes|Yes|true) index=0 ;; *) index=1 ;; esac # Cancelling the list means the same thing as choosing Back. index=$(ui_select "$index" "✅ Yes" "🚫 No" "↩️ Back") || { ui_detail " ↩️ Back" return 2 } case "$index" in 0) ui_detail " ✅ Yes"; printf 'true'; return 0 ;; 1) ui_detail " 🚫 No"; printf 'false'; return 0 ;; *) ui_detail " ↩️ Back"; return 2 ;; esac fi while true; do answer=$(ask "$prompt $(back_hint)" "$default") || exit_expected is_back_choice "$answer" && return 2 case "$answer" in y|Y|yes|Yes) printf 'true'; return 0 ;; n|N|no|No) printf 'false'; return 0 ;; *) ui_warn "⚠️ Please answer yes, no, or back." ;; esac done } confirm_action() { local prompt="$1" default="${2:-n}" [ "$(ask_yes_no "$prompt" "$default")" = true ] } confirm_change() { local title="$1" details="$2" answer ui_heading "⚠️ Confirmation required" ui_warn "$title" ui_detail "$details" if answer=$(ask_yes_no_or_back "Do you want to continue" "n"); then [ "$answer" = true ] return fi return 1 } confirm_repair() { local action="$1" details="$2" confirm_change "🛠️ The manager can $action." \ "$details No changes will be made unless you answer yes." } valid_name() { [[ "$1" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; } valid_kubernetes_name() { [ "${#1}" -le 40 ] && valid_name "$1"; } valid_release_version() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; } is_transient_kubectl_error() { case "$1" in *[Tt]imeout*|*[Uu]navailable*|*"connection refused"*|*"connection reset"*|*"too many requests"*|*"rate limit"*|*"embedded etcd"*|*"leader changed"*) return 0 ;; esac return 1 } failure_hint() { local diagnostic diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$diagnostic" in *podsecurity*|*restricted*|*hostpath*|*violat*) printf '%s' "A Pod Security policy rejected a volume added by the workload or an admission injector." ;; *admission*|*webhook*) printf '%s' "An admission controller rejected or changed the resource. Review the webhook or policy named in the event." ;; *serviceaccount*not\ found*|*service\ account*not\ found*|*serviceaccount*missing*|*service\ account*missing*|*configuration\ secret*missing*) printf '%s' "The workload references a missing ServiceAccount or configuration Secret. Re-run with permission to create it, then retry." ;; *forbidden*|*unauthorized*|*permission*denied*) printf '%s' "Kubernetes denied this operation. Check your account and RBAC permissions." ;; *immutable*|*field\ is\ immutable*|*invalid\ value*) printf '%s' "Kubernetes rejected a field that cannot be changed in place. Review the resources." ;; *imagepullbackoff*|*errimagepull*|*pull\ access\ denied*) printf '%s' "The cluster could not pull the kwatch image. Check registry access and network policy." ;; *timeout*|*timed\ out*|*connection\ refused*|*unavailable*) printf '%s' "The Kubernetes API or rollout did not respond in time. Check connectivity and nodes." ;; *persistentvolumeclaim*|*unbound*|*storageclass*|*no\ persistent\ volumes*) printf '%s' "The state volume claim did not bind: the cluster has no default StorageClass, or none that provisions ReadWriteOnce volumes." ;; *) printf '%s' "Review the Kubernetes details below; no safe automatic fix was identified." ;; esac } failure_fix() { local diagnostic diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$diagnostic" in *podsecurity*|*restricted*|*hostpath*|*violat*) printf '%s' "Use a workload-only injector opt-out when the manager identifies a supported injector. Do not weaken Pod Security." ;; *admission*|*webhook*) printf '%s' "Ask the cluster administrator to review the named webhook or policy, then retry." ;; *serviceaccount*not\ found*|*service\ account*not\ found*|*serviceaccount*missing*|*service\ account*missing*|*configuration\ secret*missing*) printf '%s' "Grant create/get access for the named ServiceAccount or Secret, or ask the cluster administrator to create it, then retry." ;; *forbidden*|*unauthorized*|*permission*denied*) printf '%s' "Use a Kubernetes identity allowed to create, patch, and delete kwatch resources; the manager will not grant cluster-admin automatically." ;; *immutable*|*field\ is\ immutable*) printf '%s' "Approve Deployment recreation when offered; KwatchConfig and Secrets are preserved." ;; *imagepullbackoff*|*errimagepull*|*pull\ access\ denied*) printf '%s' "Make the kwatch image reachable from the cluster or configure the required imagePullSecret, then retry." ;; *timeout*|*timed\ out*|*connection\ refused*|*unavailable*|*too\ many\ requests*|*rate\ limit*) printf '%s' "Check API/network health; the manager can safely retry this operation after you approve it." ;; *persistentvolumeclaim*|*unbound*|*storageclass*|*no\ persistent\ volumes*) printf '%s' "Mark a default StorageClass, or run the manager with KWATCH_STORAGE_CLASS=, or with KWATCH_STORAGE=emptydir to run without persistent state." ;; *) printf '%s' "Review the details and events, correct the named resource or permission, then run the manager again." ;; esac } failure_is_retryable() { local diagnostic diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$diagnostic" in *timeout*|*timed\ out*|*connection\ refused*|*connection\ reset*|*unavailable*|*too\ many\ requests*|*rate\ limit*|*serviceaccount*not\ found*|*service\ account*not\ found*|*serviceaccount*missing*|*service\ account*missing*|*configuration\ secret*missing*) return 0 ;; esac return 1 } # Known Pod-level opt-outs for admission injectors. The keyword is matched # against the injected volume or container named in a rejection, and against the # names of the mutating webhooks installed in the cluster. injector_keywords() { printf '%s\n' datadog istio linkerd vault kuma opentelemetry } provider_profile_for_keyword() { case "$1" in datadog) printf '%s' "Datadog|labels|admission.datadoghq.com/enabled|false" ;; istio) printf '%s' "Istio|labels|sidecar.istio.io/inject|false" ;; linkerd) printf '%s' "Linkerd|annotations|linkerd.io/inject|disabled" ;; vault) printf '%s' "Vault Agent|annotations|vault.hashicorp.com/agent-inject|false" ;; kuma) printf '%s' "Kuma|annotations|kuma.io/sidecar-injection|disabled" ;; opentelemetry) printf '%s' "OpenTelemetry|annotations|instrumentation.opentelemetry.io/inject-sdk|false" ;; *) return 1 ;; esac } namespace_warning_events() { kubectl -n "$NAMESPACE" get events --field-selector=type=Warning \ -o custom-columns='REASON:.reason,MESSAGE:.message' --no-headers \ 2>/dev/null || true } # Scan every Warning event for an admission rejection instead of trusting the # newest one. A later unrelated warning -- FailedScheduling, FailedMount, a # failing probe on a previous ReplicaSet -- would otherwise hide the rejection # and skip the repair. Ordering is not dependable either: lastTimestamp is unset # on events written through events.k8s.io, which sorts them unpredictably. admission_rejection_event() { namespace_warning_events | grep -Ei 'violates PodSecurity|restricted volume types|is forbidden' | tail -1 || true } latest_warning_event() { namespace_warning_events | tail -1 || true } installed_injector_webhooks() { kubectl get mutatingwebhookconfigurations \ -o 'jsonpath={range .items[*]}{.metadata.name}{" "}{range .webhooks[*]}{.name}{" "}{.clientConfig.service.namespace}{" "}{.clientConfig.service.name}{" "}{end}{"\n"}{end}' \ 2>/dev/null | tr '[:upper:]' '[:lower:]' || true } # Every Pod Security rejection of a kwatch Pod comes from content the manager did # not submit: the shipped workload runs non-root and read-only, drops all # capabilities, and sets RuntimeDefault seccomp. Classify the whole family rather # than hostPath alone, because an injector that adds a privileged container, a # host namespace, or extra capabilities breaks the install in the same way and is # repaired by the same Pod-level opt-out. is_injected_admission_failure() { local diagnostic diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$diagnostic" in *"violates podsecurity"*) return 0 ;; esac case "$diagnostic" in *hostpath*|*privileged*|*"unrestricted capabilities"*|*"host namespaces"*|*hostport*) case "$diagnostic" in *podsecurity*|*restricted*|*baseline*|*violates*) return 0 ;; esac ;; esac return 1 } # Identify the injector from the rejection first, since it names the injected # volume or container and some vendors put their name there. Vendors whose # injected names do not carry it -- an Istio sidecar rejected for NET_ADMIN, a # Linkerd proxy rejected for runAsUser -- are identifiable only from the webhooks # installed in the cluster, and then only when exactly one candidate exists. The # manager never chooses between two possible injectors. injection_opt_out_profile() { local diagnostic webhooks keyword profile chosen="" matches=0 diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') webhooks=$(installed_injector_webhooks) [ -n "$webhooks" ] || return 1 while IFS= read -r keyword; do [ -n "$keyword" ] || continue case "$diagnostic" in *"$keyword"*) ;; *) continue ;; esac case "$webhooks" in *"$keyword"*) ;; *) continue ;; esac profile=$(provider_profile_for_keyword "$keyword") || continue injection_opt_out_collected "$profile" && continue printf '%s' "$profile" return 0 done < <(injector_keywords) # Nothing in the rejection names a vendor. Count only the injectors not # already opted out of, so a second pass can still identify the one that is # left; with more than one candidate the manager does not choose. while IFS= read -r keyword; do [ -n "$keyword" ] || continue case "$webhooks" in *"$keyword"*) ;; *) continue ;; esac profile=$(provider_profile_for_keyword "$keyword") || continue injection_opt_out_collected "$profile" && continue matches=$((matches + 1)) chosen="$profile" done < <(injector_keywords) [ "$matches" -eq 1 ] || return 1 printf '%s' "$chosen" } show_injector_candidates() { local candidates candidates=$(kubectl get mutatingwebhookconfigurations \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null | grep -Ei 'datadog|istio|linkerd|vault|kuma|telemetry|inject|sidecar|mesh' \ | head -6 || true) [ -n "$candidates" ] || return 0 ui_detail "🔌 Possible injection webhooks (read-only):" while IFS= read -r candidate; do [ -n "$candidate" ] && printf ' %s\n' "$candidate" done <<< "$candidates" } # True when the workload already carries this opt-out, so an upgrade of an # installation that was repaired earlier does not ask the same question again. injection_opt_out_present() { local profile="$1" deployment provider target key value current IFS='|' read -r provider target key value <<< "$profile" deployment=$(deployment_name || true) [ -n "$deployment" ] || return 1 current=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.spec.template.metadata.$target}" 2>/dev/null || true) case "$current" in *"\"$key\":\"$value\""*) return 0 ;; esac return 1 } # True when every collected opt-out is already on the workload. injection_opt_outs_present() { local profile [ "${#INJECTOR_OPT_OUT_PROFILES[@]}" -gt 0 ] || return 1 for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do [ -n "$profile" ] || continue injection_opt_out_present "$profile" || return 1 done return 0 } # True when this opt-out was already collected on an earlier pass, which is how # the probe loop recognises that it has stopped making progress. injection_opt_out_collected() { local wanted="$1" profile for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do if [ "$profile" = "$wanted" ]; then return 0 fi done return 1 } # The collected opt-outs as pod-template metadata, every line prefixed with a # newline so an empty set expands to nothing at all. One renderer feeds both the # probe Pod and the release manifest, which is what makes the probe's verdict # worth anything: it answers for the exact metadata that will be applied. opt_out_yaml_lines() { local target="$1" indent="$2" profile provider scope key value for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do [ -n "$profile" ] || continue IFS='|' read -r provider scope key value <<< "$profile" [ "$scope" = "$target" ] || continue printf '\n%s%s: "%s"' "$indent" "$key" "$value" done } # The providers collected so far, named for a single readable prompt. opt_out_provider_list() { local profile provider scope key value out="" for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do [ -n "$profile" ] || continue IFS='|' read -r provider scope key value <<< "$profile" if [ -n "$out" ]; then out="$out, $provider" else out="$provider" fi done printf '%s' "$out" } # True when the Pod template already declares an annotations block, so a second # one is not created next to it. pod_template_has_annotations() { awk ' /^ metadata:$/ { inside = 1; next } inside && /^ spec:$/ { exit } inside && /^ annotations:$/ { found = 1; exit } END { exit (found ? 0 : 1) } ' "$1" } # Write the collected opt-outs into the release manifest's Pod template, so the # workload is created already excluded instead of being created, rejected, and # then patched. Returns non-zero when the manifest is not shaped as expected, # which sends the caller back to patching after the apply. inject_opt_out_into_manifest() { local manifest="$1" labels annotations rewritten profile provider scope key value local has_annotations=false labels=$(opt_out_yaml_lines labels ' ') annotations=$(opt_out_yaml_lines annotations ' ') [ -n "$labels$annotations" ] || return 0 # Both anchors are unique to the Pod template: the Deployment's own metadata # sits at column 0 and its labels at two spaces. grep -q '^ metadata:$' "$manifest" || return 1 grep -q '^ labels:$' "$manifest" || return 1 # rc.3 ships no annotations on the Pod template, but a release that adds one # would get a second `annotations:` key from the insert below -- a duplicate # mapping key, which kubectl rejects. Append to the existing block instead. if pod_template_has_annotations "$manifest"; then has_annotations=true fi rewritten=$(mktemp) || return 1 # Passed through the environment rather than -v: awk interprets backslash # escapes in a -v value, and these are multi-line strings. if ! KWATCH_OPT_OUT_LABELS="$labels" \ KWATCH_OPT_OUT_ANNOTATIONS="$annotations" \ KWATCH_HAS_ANNOTATIONS="$has_annotations" awk ' /^ metadata:$/ && meta == 0 { print meta = 1 if (ENVIRON["KWATCH_OPT_OUT_ANNOTATIONS"] != "" && ENVIRON["KWATCH_HAS_ANNOTATIONS"] != "true") { print " annotations:" printf "%s\n", substr(ENVIRON["KWATCH_OPT_OUT_ANNOTATIONS"], 2) } next } /^ annotations:$/ && annotated == 0 { print annotated = 1 if (ENVIRON["KWATCH_OPT_OUT_ANNOTATIONS"] != "" && ENVIRON["KWATCH_HAS_ANNOTATIONS"] == "true") { printf "%s\n", substr(ENVIRON["KWATCH_OPT_OUT_ANNOTATIONS"], 2) } next } /^ labels:$/ && labelled == 0 { print labelled = 1 if (ENVIRON["KWATCH_OPT_OUT_LABELS"] != "") { printf "%s\n", substr(ENVIRON["KWATCH_OPT_OUT_LABELS"], 2) } next } { print } ' "$manifest" >"$rewritten"; then rm -f "$rewritten" return 1 fi # Trust the result only if every key actually landed. for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do [ -n "$profile" ] || continue IFS='|' read -r provider scope key value <<< "$profile" if ! grep -qF " $key: \"$value\"" "$rewritten"; then rm -f "$rewritten" return 1 fi done mv "$rewritten" "$manifest" || { rm -f "$rewritten"; return 1; } return 0 } # --- Deployment resources and placement ------------------------------------ # # Stored as one annotation on the Deployment, semicolon-separated, so it reads # plainly in `kubectl get deploy -o yaml`: # # kwatch.sh/pod-overrides: requests.cpu=200m;limits.memory=512Mi;\ # nodeSelector=kubernetes.io/os=linux,workload=infra # # Every value is validated before it is stored, and none of the permitted # characters need quoting in YAML, JSON or a shell word. # Quantity checks, deliberately narrower than Kubernetes accepts: these are the # forms an operator writes, and rejecting the rest here gives a better error # than the API server's. valid_cpu_quantity() { case "$1" in ''|*[!0-9.m]*) return 1 ;; esac printf '%s' "$1" | grep -Eq '^([0-9]+m|[0-9]+(\.[0-9]+)?)$' } valid_memory_quantity() { printf '%s' "$1" | grep -Eq '^[0-9]+(\.[0-9]+)?(Ki|Mi|Gi|Ti|K|M|G|T)?$' } # Millicores and bytes, so a request can be compared with its limit before the # cluster is touched. cpu_to_milli() { local value="$1" case "$value" in *m) printf '%s' "${value%m}" ;; *) awk -v v="$value" 'BEGIN { printf "%d", v * 1000 }' ;; esac } memory_to_bytes() { local value="$1" number unit number=$(printf '%s' "$value" | sed -E 's/[A-Za-z]+$//') unit=$(printf '%s' "$value" | sed -E 's/^[0-9.]+//') awk -v n="$number" -v u="$unit" 'BEGIN { m = 1 if (u == "Ki") m = 1024 else if (u == "Mi") m = 1024 * 1024 else if (u == "Gi") m = 1024 * 1024 * 1024 else if (u == "Ti") m = 1024 * 1024 * 1024 * 1024 else if (u == "K") m = 1000 else if (u == "M") m = 1000000 else if (u == "G") m = 1000000000 else if (u == "T") m = 1000000000000 printf "%.0f", n * m }' } # A comma-separated k=v list, using the label syntax Kubernetes enforces. Empty # means "no placement constraint", which is how a nodeSelector is removed. valid_node_selector() { local spec="$1" pair key value [ -n "$spec" ] || return 0 local IFS=',' for pair in $spec; do [ -n "$pair" ] || return 1 case "$pair" in *=*) ;; *) return 1 ;; esac key="${pair%%=*}" value="${pair#*=}" printf '%s' "$key" | grep -Eq '^([A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$' || return 1 [ "${#key}" -le 253 ] || return 1 [ -n "$value" ] || return 1 printf '%s' "$value" | grep -Eq '^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$' || return 1 [ "${#value}" -le 63 ] || return 1 done return 0 } # The nodeSelector as pod-spec YAML, each line prefixed with a newline so an # empty selector expands to nothing. Splitting on commas needs its own scope: # unsetting a local IFS would expose the global one. node_selector_yaml_lines() { local spec="$1" indent="$2" pair [ -n "$spec" ] || return 0 local IFS=',' for pair in $spec; do [ -n "$pair" ] || continue printf '\n%s%s: "%s"' "$indent" "${pair%%=*}" "${pair#*=}" done } # Read the recorded overrides from the workload. A fresh install has none. # # Two stores, each under two names, is four questions -- and asked one at a time # that was five seconds every time the status was drawn, for an installation # that has recorded nothing at all. Both names come back in one request per # store instead: a jsonpath reads two annotations at once, and # `get configmap a b --ignore-not-found` returns whichever exists. deployment_overrides_read() { local deployment pair records DEPLOYMENT_OVERRIDES="" DEPLOYMENT_OVERRIDES_SOURCE="" LEGACY_OVERRIDES_IN_USE=false deployment=$(deployment_name || true) if [ -n "$deployment" ]; then # "|" cannot appear in a recorded value: quantities and label selectors are # alphanumerics with . - _ / and the , ; = this format already uses. pair=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.metadata.annotations.${DEPLOYMENT_OVERRIDES_ANNOTATION//./\\.}}{\"|\"}{.metadata.annotations.${LEGACY_OVERRIDES_ANNOTATION//./\\.}}" \ 2>/dev/null || true) DEPLOYMENT_OVERRIDES="${pair%%|*}" if [ -z "$DEPLOYMENT_OVERRIDES" ]; then DEPLOYMENT_OVERRIDES="${pair#*|}" [ -n "$DEPLOYMENT_OVERRIDES" ] && LEGACY_OVERRIDES_IN_USE=true fi [ -n "$DEPLOYMENT_OVERRIDES" ] && DEPLOYMENT_OVERRIDES_SOURCE=deployment fi # Nothing on the workload: either it was never sized, or this is a reinstall # over a kept configuration. The record answers both the same way. if [ -z "$DEPLOYMENT_OVERRIDES" ]; then records=$(kubectl -n "$NAMESPACE" get configmap \ "$DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME" \ "$LEGACY_OVERRIDES_CONFIGMAP_NAME" --ignore-not-found \ -o "jsonpath={range .items[*]}{.metadata.name}{\"=\"}{.data.overrides}{\"\n\"}{end}" \ 2>/dev/null || true) DEPLOYMENT_OVERRIDES=$(printf '%s\n' "$records" | sed -n "s|^${DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME}=||p" | head -1) if [ -z "$DEPLOYMENT_OVERRIDES" ]; then DEPLOYMENT_OVERRIDES=$(printf '%s\n' "$records" | sed -n "s|^${LEGACY_OVERRIDES_CONFIGMAP_NAME}=||p" | head -1) [ -n "$DEPLOYMENT_OVERRIDES" ] && LEGACY_OVERRIDES_IN_USE=true fi [ -n "$DEPLOYMENT_OVERRIDES" ] && DEPLOYMENT_OVERRIDES_SOURCE=record fi # Both stores are ordinary Kubernetes objects that anyone can edit, and these # values are written straight into a manifest. A bad quantity there would # fail the whole upgrade, so drop what does not validate rather than carrying # it into the apply. DEPLOYMENT_OVERRIDES=$(deployment_overrides_sanitize "$DEPLOYMENT_OVERRIDES") [ -n "$DEPLOYMENT_OVERRIDES" ] || DEPLOYMENT_OVERRIDES_SOURCE="" return 0 } # Keeps the entries that are well formed and drops the rest, reporting anything # discarded so a hand-edit that did not take is visible rather than silent. deployment_overrides_sanitize() { local raw="$1" entry key value kept="" dropped="" [ -n "$raw" ] || return 0 local IFS=';' for entry in $raw; do [ -n "$entry" ] || continue case "$entry" in *=*) ;; *) dropped="$dropped $entry"; continue ;; esac key="${entry%%=*}" value="${entry#*=}" case "$key" in requests.cpu|limits.cpu) if ! valid_cpu_quantity "$value"; then dropped="$dropped $key" continue fi ;; requests.memory|limits.memory) if ! valid_memory_quantity "$value"; then dropped="$dropped $key" continue fi ;; nodeSelector) if ! valid_node_selector "$value"; then dropped="$dropped $key" continue fi ;; license) # A marker, never a value: the key itself lives in a Secret. if [ "$value" != 1 ]; then dropped="$dropped $key" continue fi ;; *) dropped="$dropped $key" continue ;; esac if [ -n "$kept" ]; then kept="$kept;$entry" else kept="$entry" fi done unset IFS if [ -n "$dropped" ]; then ui_warn "⚠️ Ignoring unusable recorded Deployment settings:$dropped" >&2 fi printf '%s' "$kept" } # Record the values in both stores. The ConfigMap is the one that survives an # uninstall, so a failure to write it is worth reporting even though the Pod has # already been resized. deployment_overrides_write() { local deployment="$1" overrides="$2" rc=0 kubectl -n "$NAMESPACE" annotate deployment "$deployment" \ "$DEPLOYMENT_OVERRIDES_ANNOTATION=$overrides" --overwrite >/dev/null || rc=1 # One record, not two: retire the old name rather than leaving a second copy # to drift out of step with this one. Only when a read actually found one -- # otherwise this is two API calls per save that can never do anything. if [ "${LEGACY_OVERRIDES_IN_USE:-false}" = true ]; then kubectl -n "$NAMESPACE" annotate deployment "$deployment" \ "${LEGACY_OVERRIDES_ANNOTATION}-" >/dev/null 2>&1 || true kubectl -n "$NAMESPACE" delete configmap \ "$LEGACY_OVERRIDES_CONFIGMAP_NAME" --ignore-not-found >/dev/null 2>&1 || true # Not cleared here: this function runs under with_loading, which forks, so # the assignment would never reach the caller. Every read sets the flag # afresh, and every flow reads before it writes. fi kubectl -n "$NAMESPACE" create configmap "$DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME" \ --from-literal=overrides="$overrides" --dry-run=client -o yaml | kubectl apply -f - >/dev/null || rc=1 kubectl -n "$NAMESPACE" label configmap "$DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true return "$rc" } deployment_override_get() { local wanted="$1" entry local IFS=';' for entry in $DEPLOYMENT_OVERRIDES; do case "$entry" in "$wanted"=*) printf '%s' "${entry#*=}"; return 0 ;; esac done return 1 } # The value the release itself ships, used for anything the operator left alone. manifest_resource_default() { local manifest="$1" section="$2" field="$3" awk -v section="$section" -v field="$field" ' /^ resources:$/ { inres = 1; next } inres && /^ [a-z]+:$/ { cur = $1 sub(":", "", cur) next } inres && /^ [a-z]+:/ { key = $1 sub(":", "", key) if (cur == section && key == field) { value = $2 gsub(/"/, "", value) print value exit } next } inres { exit } ' "$manifest" } # The Lease name appears twice in the release manifest: the env var the # process reads, and the resourceNames of the Role that lets it renew that # one Lease. Renaming only the first leaves a release named anything but # "kwatch" unable to hold its Lease, so both are rewritten together. rewrite_release_lease_name() { local manifest="$1" sed -i.bak \ -e "s/^\( *value: \)\"kwatch-leader\"$/\1\"${RELEASE}-leader\"/" \ -e "s/^\( *resourceNames: \[\)\"kwatch-leader\"\]$/\1\"${RELEASE}-leader\"]/" \ "$manifest" rm -f "$manifest.bak" } # Every resource the release ships is named "kwatch" or "kwatch-", # including the container and the Pod's serviceAccountName, so a release # named anything else gets its own set: the Roles for the Lease, the restart # evidence and the control-plane Leases, the data claim, the ServiceAccount # the Pod runs as, and the container the manager looks up by name. # The control-plane Role's "namespace: kube-system" is left alone. # kwatch-license-instance keeps its name: the kwatch binary and the license # Role's resourceNames hardcode it. rewrite_release_names() { local manifest="$1" version="$2" sed -i.bak \ -e "/^kind: Namespace$/,/^---$/ s/^ name: kwatch$/ name: __KWATCH_NAMESPACE__/" \ -e "s/^\( *name: \)kwatch-license-instance$/\1__KWATCH_LICENSE_INSTANCE__/" \ -e "s/^\( *-\{0,1\} *name: \)kwatch$/\1$RELEASE/g" \ -e "s/^\( *serviceAccountName: \)kwatch$/\1$RELEASE/" \ -e "s/^\( *name: \)kwatch-\([a-z][a-z-]*\)$/\1${RELEASE}-\2/g" \ -e "s/^\( *claimName: \)kwatch-data$/\1${RELEASE}-data/" \ -e "s/^\( *namespace: \)kwatch$/\1$NAMESPACE/g" \ -e "s/^\( *app.kubernetes.io\/instance: \)kwatch$/\1$RELEASE/g" \ -e "s#ghcr.io/abahmed/kwatch:[^[:space:]]*#ghcr.io/abahmed/kwatch:$version#g" \ -e "s/secretName: kwatch/secretName: $CONFIG_SECRET_NAME/g" \ -e "s/__KWATCH_NAMESPACE__/$NAMESPACE/g" \ -e "s/__KWATCH_LICENSE_INSTANCE__/kwatch-license-instance/" \ "$manifest" rm -f "$manifest.bak" rewrite_release_lease_name "$manifest" } # A ReadWriteOnce claim binds only through a StorageClass. The claim in the # release manifest names none, so it binds only when the cluster marks one # as default. default_storage_class() { kubectl get storageclass \ -o 'jsonpath={range .items[*]}{.metadata.name}{" "}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{" "}{.metadata.annotations.storageclass\.beta\.kubernetes\.io/is-default-class}{"\n"}{end}' \ 2>/dev/null | awk '$2 == "true" || $3 == "true" { print $1; exit }' || true } storage_class_exists() { kubectl get storageclass "$1" >/dev/null 2>&1 } # The data volume the running Deployment uses, so an upgrade keeps it: a # claim is never swapped for an emptyDir, or the other way round, unless the # operator asks for it. live_storage_mode() { local deployment="$1" volume volume=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.volumes[?(@.name=="data")].emptyDir}' \ 2>/dev/null || true) if [ -n "$volume" ]; then printf 'emptydir' else printf 'pvc' fi } # Decide where the state lives before the manifest is applied. The release # default is a claim; without a default StorageClass that claim stays Pending # and the Pod with it, so the choice is made here, where the cluster can be # asked, instead of being discovered from a rollout that never completes. choose_storage_mode() { local existing_deployment="$1" default_class choice case "$STORAGE_MODE" in pvc|emptydir|"") ;; *) die "KWATCH_STORAGE must be pvc or emptydir, not '$STORAGE_MODE'" ;; esac valid_memory_quantity "$STORAGE_SIZE" || die "KWATCH_STORAGE_SIZE must be a memory quantity such as 2Gi, not '$STORAGE_SIZE'" if [ -n "$STORAGE_CLASS" ] && ! storage_class_exists "$STORAGE_CLASS"; then die "StorageClass '$STORAGE_CLASS' (KWATCH_STORAGE_CLASS) does not exist" fi if [ -n "$existing_deployment" ]; then [ -n "$STORAGE_MODE" ] || STORAGE_MODE=$(live_storage_mode "$existing_deployment") return 0 fi [ -z "$STORAGE_MODE" ] || return 0 if [ -n "$STORAGE_CLASS" ]; then STORAGE_MODE=pvc return 0 fi if [ "$(resolve_access list storageclasses)" = denied ]; then ui_info "ℹ️ Cannot list StorageClasses; assuming the cluster has a default one." STORAGE_MODE=pvc return 0 fi default_class=$(default_storage_class) if [ -n "$default_class" ]; then STORAGE_MODE=pvc ui_detail "💾 State volume: a $STORAGE_SIZE claim on the default StorageClass '$default_class'." return 0 fi ui_warn "⚠️ The cluster has no default StorageClass, so the release's state volume claim cannot bind." ui_detail " kwatch keeps incidents and change history on that volume across restarts." choice=$(ui_select 0 \ "📦 Use an emptyDir (no StorageClass needed; state is lost when the Pod moves)" \ "💾 Name a StorageClass for the claim" \ "↩️ Cancel") || return 1 case "$choice" in 0) STORAGE_MODE=emptydir ;; 1) STORAGE_CLASS=$(ask "StorageClass name" "") || exit_expected storage_class_exists "$STORAGE_CLASS" || die "StorageClass '$STORAGE_CLASS' does not exist" STORAGE_MODE=pvc ;; *) return 1 ;; esac } # Remove one whole document (kind and metadata.name) from a multi-document # manifest, leaving no empty document behind. remove_manifest_document() { local manifest="$1" kind="$2" name="$3" awk -v want_kind="$kind" -v want_name="$name" ' function flush() { if (doc != "" && !(doc_kind == want_kind && doc_name == want_name)) { if (printed_any) print "---" printf "%s", doc printed_any = 1 } doc = ""; doc_kind = ""; doc_name = ""; in_meta = 0 } /^---[[:space:]]*$/ { flush(); next } { doc = doc $0 "\n" if ($1 == "kind:") doc_kind = $2 if ($0 ~ /^metadata:/) in_meta = 1 else if ($0 ~ /^[^ #]/) in_meta = 0 if (in_meta && $0 ~ /^ name:/ && doc_name == "") doc_name = $2 } END { flush() } ' "$manifest" >"$manifest.tmp" && mv "$manifest.tmp" "$manifest" } manifest_has_document() { local manifest="$1" kind="$2" name="$3" awk -v want_kind="$kind" -v want_name="$name" ' BEGIN { missing = 1 } /^---/ { doc_kind = ""; doc_name = ""; in_meta = 0; next } $1 == "kind:" { doc_kind = tolower($2) } /^metadata:/ { in_meta = 1; next } /^[^ #]/ { in_meta = 0 } in_meta && /^ name:/ && doc_name == "" { doc_name = $2 if (doc_kind == want_kind && doc_name == want_name) missing = 0 } END { exit missing } ' "$manifest" } # The release ships a claim. In emptyDir mode the Deployment gets an emptyDir # of the same size, the claim document is dropped, and the container learns # the cap through KWATCH_VOLUME_LIMIT so the store's rewrite can check the # real limit. In pvc mode a StorageClass named by the operator is written # into the claim, and a claim that already exists is left out of the apply: # its spec is immutable, and the existing claim is the state being kept. apply_storage_mode_to_manifest() { local manifest="$1" claim="${RELEASE}-data" case "$STORAGE_MODE" in emptydir) awk -v size="$STORAGE_SIZE" ' /^ persistentVolumeClaim:$/ { print " emptyDir:" print " sizeLimit: " size skip_claim = 1 next } skip_claim && /^ claimName:/ { skip_claim = 0; next } /^ - name: KWATCH_DATA_DIR$/ { print " - name: KWATCH_VOLUME_LIMIT" print " value: \"" size "\"" } { print } ' "$manifest" >"$manifest.tmp" && mv "$manifest.tmp" "$manifest" || return 1 remove_manifest_document "$manifest" PersistentVolumeClaim "$claim" ;; *) if kubectl -n "$NAMESPACE" get pvc "$claim" >/dev/null 2>&1; then remove_manifest_document "$manifest" PersistentVolumeClaim "$claim" return 0 fi if [ -n "$STORAGE_CLASS" ]; then awk -v class="$STORAGE_CLASS" ' { print } in_claim && /^ accessModes:/ { print " storageClassName: " class } /^kind: PersistentVolumeClaim$/ { in_claim = 1 } /^---/ { in_claim = 0 } ' "$manifest" >"$manifest.tmp" && mv "$manifest.tmp" "$manifest" || return 1 fi if [ "$STORAGE_SIZE" != 2Gi ]; then sed -i.bak \ "/^kind: PersistentVolumeClaim$/,\$ s/^\( *storage: \)2Gi$/\1$STORAGE_SIZE/" \ "$manifest" rm -f "$manifest.bak" fi ;; esac } # The control-plane Lease Role lives in kube-system, where an operator's own # access is often narrower than in the kwatch namespace. When it is denied, # the two kube-system documents are left out rather than failing the whole # apply: kwatch then reports the scheduler and controller-manager checks as # unavailable in /health, and everything else runs. strip_kube_system_rbac_if_denied() { local manifest="$1" grep -q '^ namespace: kube-system$' "$manifest" || return 0 if [ "$(resolve_access create roles ns:kube-system)" != denied ] && [ "$(resolve_access create rolebindings ns:kube-system)" != denied ]; then return 0 fi ui_warn "⚠️ No permission to create a Role in kube-system; the control-plane Lease checks are skipped." ui_detail " kwatch reports scheduler and controller-manager health as unavailable until an" ui_detail " administrator grants ${RELEASE}-control-plane-leases in kube-system." remove_manifest_document "$manifest" Role "${RELEASE}-control-plane-leases" remove_manifest_document "$manifest" RoleBinding "${RELEASE}-control-plane-leases" } # Documents the installed release carried that the new one does not ship: # the PodDisruptionBudget and the persistence Role of releases before the # data volume, for example. Only manager-owned objects of this release go. prune_superseded_resources() { local manifest="$1" kind name for kind in poddisruptionbudget role rolebinding; do while IFS= read -r name; do [ -n "$name" ] || continue manifest_has_document "$manifest" "$kind" "$name" && continue ui_detail "🧹 Removing $kind $name: this release no longer ships it." delete_owned namespace "$kind" "$name" done < <(kubectl -n "$NAMESPACE" get "$kind" \ -l "app.kubernetes.io/instance=$RELEASE,app.kubernetes.io/managed-by=kwatch.sh" \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null || true) done } # Crossing into the data-volume releases rebuilds the state: incidents open # in the old version are forgotten, and paging alerts it opened keep their # old keys, so the new version does not resolve them. state_reset_notice() { local previous="$1" target="$2" [ -n "$previous" ] || return 0 [ "$(compare_release_versions "$previous" "$VOLUME_STATE_VERSION")" = -1 ] || return 0 [ "$(compare_release_versions "$target" "$VOLUME_STATE_VERSION")" != -1 ] || return 0 ui_warn "⚠️ $target keeps its state in a file on a new $STORAGE_SIZE volume; nothing is migrated from $previous." ui_detail " Open incidents are rediscovered and announced again. Alerts the old version" ui_detail " opened in paging tools keep their old keys and must be resolved there by hand." } # A release before the data volume kept its state in ConfigMaps. After the # upgrade they hold nothing kwatch reads, so offer to remove them. Only # ConfigMaps named after this release are listed, and the manager's own are # left alone. cleanup_legacy_state_configmaps() { local previous="$1" name local -a leftovers=() [ -n "$previous" ] || return 0 [ "$(compare_release_versions "$previous" "$VOLUME_STATE_VERSION")" = -1 ] || return 0 while IFS= read -r name; do [ -n "$name" ] || continue case "$name" in "$STATE_CONFIGMAP_NAME"|"$CATALOG_CACHE_NAME"|\ "$FEATURE_CATALOG_CACHE_NAME"|"$PROVIDER_CATALOG_CACHE_NAME"|\ "$DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME"|"$LEGACY_OVERRIDES_CONFIGMAP_NAME") continue ;; "$RELEASE"-*) leftovers+=("$name") ;; esac done < <(kubectl -n "$NAMESPACE" get configmaps \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null || true) [ "${#leftovers[@]}" -gt 0 ] || return 0 ui_info "🗄️ kwatch $previous kept its state in ConfigMaps; since $VOLUME_STATE_VERSION it lives on the data volume." ui_detail " ConfigMaps in $NAMESPACE that nothing reads any more:" for name in "${leftovers[@]}"; do ui_detail " $name" done confirm_action "Remove these ${#leftovers[@]} ConfigMaps" "y" || return 0 check_access delete configmaps namespace for name in "${leftovers[@]}"; do kubectl -n "$NAMESPACE" delete configmap "$name" --ignore-not-found \ >/dev/null 2>&1 || true done ui_success "✅ Removed the previous release's state ConfigMaps." } # Secrets are watched by default so a rotated pull Secret or an expiring # TLS certificate can be named as a cause. Only a hash of each value is kept. secrets_watch_notice() { ui_detail "🔐 Secrets are watched (values are hashed, never stored). Turn this off with" ui_detail " watch.secrets in Edit settings; the Helm chart can also drop the RBAC for it." } # Where the state lives, and whether the claim is bound: a Pending claim is # the one reason a fresh install sits in Pending with nothing else wrong. show_storage_status() { local deployment="$1" mode phase class mode=$(live_storage_mode "$deployment") if [ "$mode" = emptydir ]; then ui_kv "💾" "State" "emptyDir (lost when the Pod moves)" return 0 fi phase=$(kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.status.phase}' 2>/dev/null || true) class=$(kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.spec.storageClassName}' 2>/dev/null || true) ui_kv "💾" "State" "claim ${RELEASE}-data ${phase:-missing}${class:+ on $class}" } # A component that stops is logged by the supervisor under a fixed phrase. # Optional components retry on their own; a required one ends the process, # so the last such lines are the best explanation of a Pod that is not ready. component_stop_lines() { local selector="$1" kubectl -n "$NAMESPACE" logs -l "$selector" --tail=400 2>/dev/null | grep -F -e 'application component stopped' \ -e 'optional component stopped' | tail -2 || true } claim_phase() { kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.status.phase}' 2>/dev/null || true } # The claim's last warning names the real cause of a Pending claim: a # StorageClass with no working provisioner, or a provisioner that cannot # serve the node the Pod was placed on. claim_warning() { kubectl -n "$NAMESPACE" get events \ --field-selector "involvedObject.kind=PersistentVolumeClaim,involvedObject.name=${RELEASE}-data,type=Warning" \ -o 'jsonpath={.items[-1:].message}' 2>/dev/null || true } is_storage_failure() { local diagnostic diagnostic=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$diagnostic" in *"state claim"*|*provisioningfailed*|*"no topology key"*|\ *"unbound immediate persistentvolumeclaims"*) return 0 ;; esac return 1 } # The CSI driver that serves a StorageClass, learned from the cluster # rather than assumed: a CSI class's provisioner is the driver name, so it # is found among the registered drivers; an in-tree class (kubernetes.io/*) # is served by whatever driver provisioned a volume of that class before; # only when neither answers is Kubernetes' own in-tree-to-CSI translation # list consulted. Empty when the driver cannot be determined. csi_driver_for_class() { local class="$1" provisioner="$2" registered driver registered=$(kubectl get csinodes \ -o 'jsonpath={range .items[*]}{range .spec.drivers[*]}{.name}{"\n"}{end}{end}' \ 2>/dev/null | sort -u) if printf '%s\n' "$registered" | grep -qx -- "$provisioner"; then printf '%s' "$provisioner" return 0 fi driver=$(kubectl get pv \ -o "jsonpath={range .items[?(@.spec.storageClassName==\"$class\")]}{.spec.csi.driver}{\"\\n\"}{end}" \ 2>/dev/null | grep . | head -1 || true) if [ -n "$driver" ]; then printf '%s' "$driver" return 0 fi driver=$(csi_driver_for_in_tree_provisioner "$provisioner") if [ -n "$driver" ] && printf '%s\n' "$registered" | grep -qx -- "$driver"; then printf '%s' "$driver" return 0 fi return 1 } # Kubernetes' in-tree-to-CSI translation list (csi-translation-lib). It is # complete for the in-tree plugins Kubernetes migrated; anything else is a # CSI driver already, or unknown. csi_driver_for_in_tree_provisioner() { case "$1" in kubernetes.io/aws-ebs) printf 'ebs.csi.aws.com' ;; kubernetes.io/gce-pd) printf 'pd.csi.storage.gke.io' ;; kubernetes.io/azure-disk) printf 'disk.csi.azure.com' ;; kubernetes.io/azure-file) printf 'file.csi.azure.com' ;; kubernetes.io/cinder) printf 'cinder.csi.openstack.org' ;; kubernetes.io/vsphere-volume) printf 'csi.vsphere.vmware.com' ;; kubernetes.io/portworx-volume) printf 'pxd.portworx.com' ;; kubernetes.io/rbd) printf 'rbd.csi.ceph.com' ;; *) printf '' ;; esac } # Provisioner and binding mode of a StorageClass in one read. Pinning # matters only for WaitForFirstConsumer: with Immediate binding the volume # exists before the Pod and carries the node affinity itself. storage_class_facts() { kubectl get storageclass "$1" \ -o 'jsonpath={.provisioner}{" "}{.volumeBindingMode}' 2>/dev/null || true } # How many nodes have the driver registered, out of all nodes. A driver # that runs on some nodes only leaves a WaitForFirstConsumer claim stuck # whenever the scheduler picks one of the others. csi_driver_node_count() { kubectl get csinodes \ -o "jsonpath={range .items[*]}{range .spec.drivers[?(@.name==\"$1\")]}{.name}{\"\\n\"}{end}{end}" \ 2>/dev/null | grep -c . || true } node_count() { kubectl get nodes -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null | grep -c . || true } # The driver's own topology label, which a node carries only once the driver # is registered on it. Keys every node has (os, arch, hostname, zone) would # not tell the nodes apart, so they are skipped. csi_driver_topology_key() { local driver="$1" key while IFS= read -r key; do key="${key// /}" [ -n "$key" ] || continue case "$key" in kubernetes.io/*|topology.kubernetes.io/*|node.kubernetes.io/*) continue ;; esac printf '%s' "$key" return 0 done < <(kubectl get csinodes \ -o "jsonpath={range .items[*]}{range .spec.drivers[?(@.name==\"$driver\")]}{.topologyKeys}{\"\\n\"}{end}{end}" \ 2>/dev/null | tr -d '[]"' | tr ',' '\n' | sort -u) return 1 } STORAGE_AFFINITY_ANNOTATION="kwatch.sh/storage-topology-key" # Nodes on which the driver is registered, by name: the fallback when the # driver has no topology label of its own. A snapshot, not a rule: nodes # added later are not covered, so it is not recorded for upgrades. csi_driver_node_names() { kubectl get csinodes \ -o "jsonpath={range .items[*]}{.metadata.name}{\" \"}{range .spec.drivers[*]}{.name}{\",\"}{end}{\"\\n\"}{end}" \ 2>/dev/null | awk -v driver="$1" ' index("," $2, "," driver ",") { print $1 }' } # A required node affinity: with one argument, nodes carrying that label; # with more, the named nodes. storage_affinity_json() { local key="$1" values="" name shift if [ "$#" -eq 0 ]; then printf '{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"%s","operator":"Exists"}]}]}}}' "$key" return 0 fi for name in "$@"; do values="${values:+$values,}\"$name\"" done printf '{"nodeAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"%s","operator":"In","values":[%s]}]}]}}}' "$key" "$values" } # A storage affinity recorded on the running Deployment is written into the # fresh release manifest, so an upgrade keeps kwatch on nodes the storage # driver serves instead of landing on one it cannot. inject_storage_affinity_into_manifest() { local manifest="$1" deployment="$2" key [ -n "$deployment" ] || return 0 key=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.metadata.annotations.${STORAGE_AFFINITY_ANNOTATION//./\\.}}" \ 2>/dev/null || true) [ -n "$key" ] || return 0 inject_affinity_key_into_manifest "$manifest" "$key" } inject_affinity_key_into_manifest() { local manifest="$1" key="$2" shift 2 awk -v key="$key" -v names="$*" ' { print } /^ serviceAccountName: / && !done { print " affinity:" print " nodeAffinity:" print " requiredDuringSchedulingIgnoredDuringExecution:" print " nodeSelectorTerms:" print " - matchExpressions:" print " - key: " key if (names == "") { print " operator: Exists" } else { print " operator: In" print " values:" n = split(names, list, " ") for (i = 1; i <= n; i++) print " - " list[i] } done = 1 } ' "$manifest" >"$manifest.tmp" && mv "$manifest.tmp" "$manifest" } # Before the first apply: when the StorageClass's CSI driver is registered # on only some nodes, a WaitForFirstConsumer claim binds only if the Pod # lands on one of them. Pin the Pod there now, from what the cluster # reports, instead of finding out from a claim that never binds. A driver # registered on no node cannot provision at all, so the choice is emptyDir # or stopping; in-tree provisioners without CSI registration are left to # try, since they may still work. plan_storage_affinity() { local manifest="$1" existing_deployment="$2" class provisioner driver local with_driver total key recorded="" choice binding name local -a names=() PLANNED_STORAGE_KEY="" [ "$STORAGE_MODE" = pvc ] || return 0 if [ -n "$existing_deployment" ]; then recorded=$(kubectl -n "$NAMESPACE" get deployment "$existing_deployment" \ -o "jsonpath={.metadata.annotations.${STORAGE_AFFINITY_ANNOTATION//./\\.}}" \ 2>/dev/null || true) [ -z "$recorded" ] || return 0 fi class="$STORAGE_CLASS" if [ -z "$class" ]; then class=$(kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.spec.storageClassName}' 2>/dev/null || true) fi [ -n "$class" ] || class=$(default_storage_class) [ -n "$class" ] || return 0 read -r provisioner binding <<<"$(storage_class_facts "$class")" [ -n "$provisioner" ] || return 0 total=$(node_count) [ "${total:-0}" -gt 0 ] || return 0 if ! driver=$(csi_driver_for_class "$class" "$provisioner"); then case "$provisioner" in # An in-tree plugin may still provision without a CSI registration. kubernetes.io/*) return 0 ;; esac ui_warn "⚠️ StorageClass '$class' is served by '$provisioner', which is registered on no node, so the state claim cannot bind." choice=$(ui_select 0 \ "📦 Use an emptyDir instead (state is lost when the Pod moves)" \ "↩️ Cancel") || return 1 [ "$choice" = 0 ] || return 1 STORAGE_MODE=emptydir apply_storage_mode_to_manifest "$manifest" return fi with_driver=$(csi_driver_node_count "$driver") [ "${with_driver:-0}" -lt "$total" ] || return 0 [ "$binding" = WaitForFirstConsumer ] || return 0 if key=$(csi_driver_topology_key "$driver"); then ui_detail "📍 The $driver storage driver runs on $with_driver of $total nodes; kwatch is kept on those (label $key)." inject_affinity_key_into_manifest "$manifest" "$key" || return 1 PLANNED_STORAGE_KEY="$key" return 0 fi # No label of its own: name the nodes, and say the list is a snapshot. while IFS= read -r name; do [ -n "$name" ] && names+=("$name") done < <(csi_driver_node_names "$driver") [ "${#names[@]}" -gt 0 ] || return 0 ui_detail "📍 The $driver storage driver runs on $with_driver of $total nodes and has no topology label; kwatch is kept on ${names[*]}." ui_detail " Nodes added later are not covered; re-run the manager after the node pool changes." inject_affinity_key_into_manifest "$manifest" kubernetes.io/hostname "${names[@]}" || return 1 } # A rollout that failed on an unbound claim. The claim's warning says why; # the two generic repairs are to keep kwatch on the nodes the storage driver # serves, or to give up persistent state and use an emptyDir. repair_storage() { local version="$1" class provisioner="" driver with_driver=0 total key="" local deployment warning phase choice local -a labels=() actions=() phase=$(claim_phase) if [ -z "$phase" ] || [ "$phase" = Bound ]; then return 1 fi deployment=$(deployment_name || true) [ -n "$deployment" ] || deployment="$RELEASE" warning=$(claim_warning) class=$(kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.spec.storageClassName}' 2>/dev/null || true) [ -n "$class" ] && read -r provisioner _ <<<"$(storage_class_facts "$class")" driver="" [ -n "$provisioner" ] && driver=$(csi_driver_for_class "$class" "$provisioner" || true) ui_warn "⚠️ The state volume claim ${RELEASE}-data is $phase, so the Pod cannot start." [ -n "$warning" ] && ui_detail " $(compact_reason "$warning")" total=$(node_count) if [ -n "$driver" ]; then with_driver=$(csi_driver_node_count "$driver") key=$(csi_driver_topology_key "$driver" || true) fi if [ "${with_driver:-0}" -gt 0 ] && [ "$with_driver" -lt "${total:-0}" ]; then ui_detail " The $driver storage driver is registered on $with_driver of $total nodes; the claim was placed on a node without it." actions+=(affinity) labels+=("📍 Keep kwatch on the $with_driver node(s) where the storage driver runs") fi actions+=(emptydir cancel) labels+=( "📦 Use an emptyDir instead (state is lost when the Pod moves)" "↩️ Cancel" ) choice=$(ui_select 0 "${labels[@]}") || return 1 case "${actions[$choice]}" in affinity) repair_storage_affinity "$deployment" "$key" "$driver" ;; emptydir) repair_storage_emptydir "$version" ;; *) return 1 ;; esac } repair_storage_affinity() { local deployment="$1" key="$2" driver="$3" details patch affinity name local -a names=() if [ -n "$key" ]; then details="This adds a node affinity requiring the label $key, which a node" details+=" carries only once the storage driver is registered on it, and lets" details+=" the scheduler place the claim again. The affinity is recorded on" details+=" the Deployment and kept across upgrades." affinity=$(storage_affinity_json "$key") else while IFS= read -r name; do [ -n "$name" ] && names+=("$name") done < <(csi_driver_node_names "$driver") [ "${#names[@]}" -gt 0 ] || return 1 details="The $driver driver has no topology label of its own, so this adds" details+=" a node affinity naming the nodes it runs on today (${names[*]})." details+=" Nodes added later are not covered; re-run the manager after the" details+=" node pool changes." affinity=$(storage_affinity_json kubernetes.io/hostname "${names[@]}") fi confirm_repair "keep kwatch on nodes the storage driver serves" "$details" || return 1 check_access patch deployments namespace check_access patch persistentvolumeclaims namespace if [ -n "$key" ]; then patch=$(printf '{"metadata":{"annotations":{"%s":"%s"}},"spec":{"template":{"spec":{"affinity":%s}}}}' \ "$STORAGE_AFFINITY_ANNOTATION" "$key" "$affinity") else patch=$(printf '{"spec":{"template":{"spec":{"affinity":%s}}}}' "$affinity") fi with_loading "Pinning kwatch to nodes with the storage driver" \ kubectl -n "$NAMESPACE" patch deployment "$deployment" --type=merge \ -p "$patch" >/dev/null || return 1 # The claim remembers the node it was first placed on and the provisioner # keeps retrying there; forget it so the scheduler chooses again. kubectl -n "$NAMESPACE" annotate pvc "${RELEASE}-data" \ volume.kubernetes.io/selected-node- >/dev/null 2>&1 || true with_loading "Waiting for kwatch rollout" \ wait_for_kwatch_rollout "$deployment" || return 1 ui_success "✅ kwatch is ready on a node the storage driver serves." } repair_storage_emptydir() { local version="$1" details details="The unbound claim ${RELEASE}-data is deleted (it never held data) and" details+=" the Deployment is re-applied with an emptyDir volume of $STORAGE_SIZE." details+=" Incidents and change history are then lost whenever the Pod moves." confirm_repair "run kwatch without persistent state" "$details" || return 1 check_access delete persistentvolumeclaims namespace STORAGE_MODE=emptydir kubectl -n "$NAMESPACE" delete pvc "${RELEASE}-data" --ignore-not-found \ --wait=false >/dev/null 2>&1 || true apply_manifests "$version" || return 1 verify_operational_security || return 1 ui_success "✅ kwatch is ready without persistent state." } # Put the operator's resources and placement into the release manifest before it # is applied. This is what makes them survive an upgrade: the manifest is fetched # fresh every run, so anything not written back here is silently reverted to the # release default. Anything the operator did not set keeps that default. apply_deployment_overrides_to_manifest() { local manifest="$1" section field value node_spec rendered="" local limits_cpu limits_memory requests_cpu requests_memory rewritten local want_resources=false want_placement=false applied_all=true local has_resources=false requested_resources=false can_write_resources=true [ -n "$DEPLOYMENT_OVERRIDES" ] || return 0 grep -q '^ spec:$' "$manifest" || return 1 if [ "$(deployment_override_get license || true)" = 1 ]; then inject_license_env_into_manifest "$manifest" || applied_all=false fi grep -q '^ resources:$' "$manifest" && has_resources=true for section in limits requests; do for field in cpu memory; do value=$(deployment_override_get "$section.$field" || true) if [ -n "$value" ]; then requested_resources=true else value=$(manifest_resource_default "$manifest" "$section" "$field") fi case "$section$field" in limitscpu) limits_cpu="$value" ;; limitsmemory) limits_memory="$value" ;; requestscpu) requests_cpu="$value" ;; requestsmemory) requests_memory="$value" ;; esac done done # A resources block with a hole in it is worse than no override at all: # the Go memory limit is derived from limits.memory (KWATCH_MEMORY_LIMIT), # so an incomplete set means the block is left exactly as the release # shipped it. if [ -z "$limits_memory" ] || [ -z "$limits_cpu" ] || [ -z "$requests_memory" ] || [ -z "$requests_cpu" ]; then can_write_resources=false fi [ "$has_resources" = true ] || can_write_resources=false if [ "$requested_resources" = true ]; then if [ "$can_write_resources" = true ]; then want_resources=true else # Placement is independent and can still be kept, so carry on and report # the shortfall to the caller rather than abandoning both. applied_all=false fi fi node_spec=$(deployment_override_get nodeSelector || true) if [ -n "$node_spec" ]; then want_placement=true rendered=$(node_selector_yaml_lines "$node_spec" ' ') fi if [ "$want_resources" != true ] && [ "$want_placement" != true ]; then [ "$applied_all" = true ] && return 0 return 1 fi rewritten=$(mktemp) || return 1 # A release that starts shipping its own nodeSelector would otherwise end up # with two of them -- a duplicate mapping key kubectl rejects -- so the # shipped block is dropped whenever this one replaces it. if ! KWATCH_LIMITS_CPU="$limits_cpu" KWATCH_LIMITS_MEMORY="$limits_memory" \ KWATCH_REQUESTS_CPU="$requests_cpu" \ KWATCH_REQUESTS_MEMORY="$requests_memory" \ KWATCH_NODE_SELECTOR="$rendered" \ KWATCH_WANT_RESOURCES="$want_resources" awk ' /^ spec:$/ && placed == 0 { print placed = 1 if (ENVIRON["KWATCH_NODE_SELECTOR"] != "") { print " nodeSelector:" printf "%s\n", substr(ENVIRON["KWATCH_NODE_SELECTOR"], 2) } next } /^ nodeSelector:$/ && ENVIRON["KWATCH_NODE_SELECTOR"] != "" { dropping_selector = 1 next } dropping_selector == 1 { if ($0 ~ /^ /) next dropping_selector = 0 } /^ resources:$/ && sized == 0 && ENVIRON["KWATCH_WANT_RESOURCES"] == "true" { sized = 1 print " resources:" print " limits:" printf " memory: \"%s\"\n", ENVIRON["KWATCH_LIMITS_MEMORY"] printf " cpu: \"%s\"\n", ENVIRON["KWATCH_LIMITS_CPU"] print " requests:" printf " memory: \"%s\"\n", ENVIRON["KWATCH_REQUESTS_MEMORY"] printf " cpu: \"%s\"\n", ENVIRON["KWATCH_REQUESTS_CPU"] # Drop the block the release shipped, whatever it contained. dropping = 1 next } dropping == 1 { if ($0 ~ /^ /) next dropping = 0 } { print } ' "$manifest" >"$rewritten"; then rm -f "$rewritten" return 1 fi # Trust the result only if what was asked for actually landed. if [ "$want_resources" = true ] && ! grep -qF " memory: \"$limits_memory\"" "$rewritten"; then rm -f "$rewritten" return 1 fi if [ "$want_placement" = true ] && ! grep -q '^ nodeSelector:$' "$rewritten"; then rm -f "$rewritten" return 1 fi mv "$rewritten" "$manifest" || { rm -f "$rewritten"; return 1; } [ "$applied_all" = true ] && return 0 return 1 } # Add the vendor's documented Pod-level opt-out to the kwatch Pod template only. apply_injection_opt_out() { local deployment="$1" profile="$2" provider target key value patch IFS='|' read -r provider target key value <<< "$profile" check_access patch deployments namespace if [ "$target" = labels ]; then patch='{"spec":{"template":{"metadata":{"labels":{' else patch='{"spec":{"template":{"metadata":{"annotations":{' fi patch+="\"$key\":\"$value\"}}}}}" with_loading "Excluding kwatch from $provider injection" \ kubectl -n "$NAMESPACE" patch deployment "$deployment" \ --type=merge -p "$patch" } # Ask the API server what the cluster's admission stack will do to a compliant # kwatch Pod before anything is applied. A server-side dry run executes the real # mutating webhooks and the real Pod Security check, so an injector that would # make the workload unschedulable is found in one round trip instead of after a # five-minute rollout timeout. The probe Pod is never persisted. # # The probe carries the workload's own security context, so it is admissible # under restricted on its own: any rejection is caused by injected content. It # prints the rejection and returns non-zero; a clean or unevaluable cluster # returns zero and the install proceeds untouched. admission_preflight_probe() { local image="$1" output probe_labels probe_annotations # Carry the opt-outs collected so far, so each pass asks whether *this* # candidate metadata is admissible rather than re-asking the first question. probe_labels=$(opt_out_yaml_lines labels ' ') probe_annotations=$(opt_out_yaml_lines annotations ' ') if [ -n "$probe_annotations" ]; then probe_annotations=$'\n annotations:'"$probe_annotations" fi # The status has to be taken from the assignment: a `|| rc=$?` inside the # command substitution would set rc in its subshell and lose it here. if output=$(kubectl -n "$NAMESPACE" create --dry-run=server -o name -f - 2>&1 </dev/null | sort -u || true } # Build one toleration object per taint. A taint with no value is tolerated with # operator Exists, which is what an empty value means on the node. tolerations_json() { local taint key value effect json="" entry while IFS= read -r taint; do [ -n "$taint" ] || continue key="${taint%%=*}" value="${taint#*=}" effect="${value##*:}" value="${value%:*}" if [ -n "$value" ]; then entry="{\"key\":\"$key\",\"operator\":\"Equal\",\"value\":\"$value\",\"effect\":\"$effect\"}" else entry="{\"key\":\"$key\",\"operator\":\"Exists\",\"effect\":\"$effect\"}" fi [ -n "$json" ] && json="$json," json="$json$entry" done printf '%s' "$json" } repair_scheduling() { local deployment details json taint existing local -a taints=() while IFS= read -r taint; do [ -n "$taint" ] || continue is_placement_taint "${taint%%=*}" || continue taints+=("$taint") done < <(cluster_placement_taints) if [ "${#taints[@]}" -eq 0 ]; then ui_warn "⚠️ No kwatch Pod can be scheduled, and no tolerable node taint was found." ui_info \ "ℹ️ The cluster may have no schedulable node. The manager will not change node policy." return 1 fi ui_detail "🏷️ Node taints blocking the Pod:" for taint in "${taints[@]}"; do printf ' %s\n' "$taint" done deployment=$(deployment_name || true) [ -n "$deployment" ] || deployment="$RELEASE" details="No node accepts the kwatch Pod because of the taints above. This adds" details+=" a matching toleration to the kwatch Pod template only. It does not" details+=" change any node, taint, or other workload." confirm_repair "tolerate those node taints for the kwatch Pod" "$details" || return 1 json=$(printf '%s\n' "${taints[@]}" | tolerations_json) [ -n "$json" ] || return 1 # The release ships tolerations of its own (a not-ready or unreachable node, # so the Pod moves quickly); a merge patch replaces the list, so keep them. existing=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.tolerations}' 2>/dev/null || true) if [[ "$existing" == \[*\] ]] && [ "$existing" != "[]" ]; then existing="${existing#\[}" json="${existing%\]},$json" fi check_access patch deployments namespace with_loading "Adding node tolerations" \ kubectl -n "$NAMESPACE" patch deployment "$deployment" --type=merge \ -p "{\"spec\":{\"template\":{\"spec\":{\"tolerations\":[$json]}}}}" || return 1 with_loading "Waiting for corrected kwatch rollout" \ wait_for_kwatch_rollout "$deployment" || return 1 ui_success "✅ kwatch is ready. The tolerations apply only to this Pod." return 0 } repair_injected_hostpath() { local failure="$1" deployment profile provider target key value details if ! profile=$(injection_opt_out_profile "$failure"); then ui_warn "⚠️ An injector added content rejected by Pod Security, but it was not identified." show_injector_candidates ui_info "ℹ️ The manager will not guess an opt-out label or change cluster policy." return 1 fi IFS='|' read -r provider target key value <<< "$profile" deployment=$(deployment_name || true) [ -n "$deployment" ] || deployment="$RELEASE" details="$provider injection added content rejected by restricted Pod" details+=" Security. This adds $key=$value to the kwatch Pod template, then" details+=" waits for a replacement Pod. It does not change the injector, Pod" details+=" Security, namespace policy, or any other workload." confirm_repair \ "exclude only the kwatch Pod from $provider injection" \ "$details" || return 1 if ! apply_injection_opt_out "$deployment" "$profile"; then return 1 fi if ! with_loading "Waiting for corrected kwatch rollout" \ wait_for_kwatch_rollout "$deployment"; then return 1 fi if ! verify_operational_security; then LAST_COMMAND_ERROR="kwatch rolled out, but its operational security check failed" return 1 fi ui_success "✅ kwatch is ready. $provider injection is disabled only for this Pod." return 0 } compact_reason() { local compact compact=$(printf '%s' "$1" | sed -E \ 's#https?://[^ ]+##g; s/((token|password|secret)[=:])[[:space:]]*[^[:space:]]+/\1/Ig; s/[[:space:]]+/ /g') printf '%s' "${compact:0:500}" } show_failure_diagnostics() { local operation="$1" reason="$2" safe_reason deployment pod pods summary events local pvc_phase safe_reason=$(compact_reason "$reason") ui_heading "🔎 $operation diagnostics" ui_error "❌ Kubernetes reported: $safe_reason" ui_info "💡 Likely cause: $(failure_hint "$safe_reason")" ui_info "🛠️ Recommended fix: $(failure_fix "$safe_reason")" ui_detail "The following read-only checks help identify the exact cause." deployment=$(deployment_name || true) if [ -z "$deployment" ]; then deployment=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o jsonpath='{.metadata.name}' 2>/dev/null || true) fi if [ -n "$deployment" ]; then ui_detail "📦 Deployment: $NAMESPACE/$deployment" summary=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o custom-columns='READY:.status.readyReplicas,DESIRED:.spec.replicas,UPDATED:.status.updatedReplicas,AVAILABLE:.status.availableReplicas,REASON:.status.conditions[-1].reason' \ --no-headers 2>/dev/null || true) [ -n "$summary" ] && printf ' %s\n' "$summary" || ui_detail "Deployment details are unavailable." pods=$(kubectl -n "$NAMESPACE" get pods \ -l "app.kubernetes.io/instance=$RELEASE" \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null || true) [ -n "$pods" ] || pods=$(kubectl -n "$NAMESPACE" get pods \ -l app=kwatch \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' \ 2>/dev/null || true) while IFS= read -r pod; do [ -n "$pod" ] || continue ui_detail "🧩 Pod: $NAMESPACE/$pod" summary=$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o custom-columns='PHASE:.status.phase,READY:.status.containerStatuses[0].ready,REASON:.status.containerStatuses[0].state.waiting.reason' \ --no-headers 2>/dev/null || true) [ -n "$summary" ] && printf ' %s\n' "$summary" || ui_detail "Pod details are unavailable." done <<< "$pods" fi pvc_phase=$(claim_phase) if [ -n "$pvc_phase" ]; then ui_detail "💾 State claim ${RELEASE}-data: $pvc_phase" [ "$pvc_phase" = Bound ] || ui_detail " $(compact_reason "$(claim_warning)")" fi ui_detail "🕒 Recent namespace events:" events=$(namespace_warning_events | tail -8 || true) if [ -n "$events" ]; then while IFS= read -r summary; do summary=$(printf '%s' "$summary" | sed -E \ 's#https?://[^ ]+##g; s/((token|password|secret)[=:])[[:space:]]*[^[:space:]]+/\1/Ig') printf ' %s\n' "${summary:0:320}" done <<< "$events" else ui_detail "No warning events were found or events are unavailable." fi } kubectl() { local attempt=1 diagnostic rc retryable=true stdout_file stderr_file [ -t 0 ] || retryable=false stdout_file=$(mktemp) stderr_file=$(mktemp) while [ "$attempt" -le "$MAX_KUBECTL_ATTEMPTS" ]; do : >"$stdout_file" : >"$stderr_file" if command kubectl --context "$SELECTED_CONTEXT" "$@" \ >"$stdout_file" 2>"$stderr_file"; then cat "$stdout_file" cat "$stderr_file" >&2 rm -f "$stdout_file" "$stderr_file" return 0 else rc=$? fi diagnostic=$(cat "$stderr_file" "$stdout_file") cat "$stdout_file" cat "$stderr_file" >&2 if ! is_transient_kubectl_error "$diagnostic" || [ "$attempt" = "$MAX_KUBECTL_ATTEMPTS" ] || [ "$retryable" = false ]; then rm -f "$stdout_file" "$stderr_file" return "$rc" fi ui_warn \ "⚠️ Temporary Kubernetes error; retrying" \ "($((attempt + 1))/$MAX_KUBECTL_ATTEMPTS)..." sleep $((attempt * 2)) attempt=$((attempt + 1)) done rm -f "$stdout_file" "$stderr_file" return 1 } select_context() { local current choice index context server default_choice="" local -a contexts=() while IFS= read -r context; do [ -n "$context" ] && contexts+=("$context") done < <(command kubectl config get-contexts -o name 2>/dev/null || true) [ "${#contexts[@]}" -gt 0 ] || die "no Kubernetes contexts found in kubeconfig" current=$(command kubectl config current-context 2>/dev/null || true) # An explicit context makes a command usable from a script or a pipeline, # where the manager cannot ask which cluster is meant. It is still named # explicitly: the manager never falls back to the current context on its own. if [ -n "${KWATCH_CONTEXT:-}" ]; then for context in "${contexts[@]}"; do if [ "$context" = "$KWATCH_CONTEXT" ]; then SELECTED_CONTEXT="$context" break fi done [ -n "$SELECTED_CONTEXT" ] || die "context '$KWATCH_CONTEXT' was not found in kubeconfig" elif [ "${#contexts[@]}" -eq 1 ]; then SELECTED_CONTEXT="${contexts[0]}" else [ -t 0 ] || die "multiple Kubernetes contexts found; choose one with KWATCH_CONTEXT= or run in an interactive terminal" ui_heading "🧭 Select the Kubernetes cluster to manage" local -a labels=() for index in "${!contexts[@]}"; do if [ "${contexts[$index]}" = "$current" ]; then labels+=("$(context_label "${contexts[$index]}") ${UI_GREEN}(current)${UI_RESET}") default_choice="$index" else labels+=("$(context_label "${contexts[$index]}")") fi done [ -n "$default_choice" ] || default_choice=0 # Cancelling the picker is a decision, not a failure. choice=$(ui_select "$default_choice" "${labels[@]}") || { ui_info "↩️ No cluster selected; nothing was changed." exit_expected 0 } SELECTED_CONTEXT="${contexts[$choice]}" fi server=$(command kubectl --context "$SELECTED_CONTEXT" config view --minify \ -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null || true) [ -n "$server" ] || die "could not read the Kubernetes server for the selected context" UI_TITLE_BASE="kwatch · $(context_label "$SELECTED_CONTEXT")" ui_title_step "" ui_detail " Selected cluster: $(context_label "$SELECTED_CONTEXT")" } load_catalog_file() { local file="$1" entry path type default category description status replacement local catalog_version="" seen="|" local -a loaded=() while IFS= read -r entry || [ -n "$entry" ]; do if [[ "$entry" =~ ^#\ kwatch\ config\ catalog\ v([0-9]+)$ ]]; then catalog_version="${BASH_REMATCH[1]}" continue fi [[ -z "$entry" || "$entry" = \#* ]] && continue IFS='|' read -r path type default category description status replacement <<<"$entry" [ -n "$path" ] && [ -n "$type" ] && [ -n "$category" ] && [ -n "$description" ] || return 1 case "$type" in boolean|float|integer|json|list|string) ;; *) return 1 ;; esac case "$status" in active|deprecated|secret) ;; *) return 1 ;; esac case "$seen" in *"|$path|"*) return 1 ;; esac seen="${seen}${path}|" loaded+=("$entry") done < "$file" [ "$catalog_version" = "$SUPPORTED_CATALOG_VERSION" ] || return 1 [ "${#loaded[@]}" -gt 0 ] || return 1 CATALOG_VERSION="$catalog_version" CATALOG=("${loaded[@]}") } cache_catalog() { local file="$1" kubectl -n "$NAMESPACE" create configmap "$CATALOG_CACHE_NAME" \ --from-file=catalog.tsv="$file" \ --from-literal=source="$CATALOG_SOURCE" \ --from-literal=updated-at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null 2>&1 || true } download_catalog_artifact() { local url="$1" destination="$2" curl -fsSL --location --retry 2 --retry-delay 1 --connect-timeout 8 \ "$url" -o "$destination" 2>/dev/null } load_catalog_for_version() { local version="${1:-}" tmp cached cached_source tmp=$(mktemp) trap 'if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi' RETURN if [ -n "$version" ] && with_loading "Loading settings catalog" \ download_catalog_artifact \ "$BASE_URL/$version/deploy/config-catalog.tsv" "$tmp" \ && load_catalog_file "$tmp"; then CATALOG_SOURCE="release:$version" cache_catalog "$tmp" return 0 fi cached_source=$(kubectl -n "$NAMESPACE" get configmap "$CATALOG_CACHE_NAME" \ -o jsonpath='{.data.source}' 2>/dev/null || true) [ "$cached_source" = "release:$version" ] || return 1 cached=$(kubectl -n "$NAMESPACE" get configmap "$CATALOG_CACHE_NAME" \ -o jsonpath='{.data.catalog\.tsv}' 2>/dev/null || true) if [ -n "$cached" ]; then printf '%s\n' "$cached" > "$tmp" if load_catalog_file "$tmp"; then CATALOG_SOURCE="cache" return 0 fi fi return 1 } load_feature_catalog_file() { local file="$1" entry id lifecycle description dependencies tier local catalog_version="" seen="|" local -a loaded=() while IFS= read -r entry || [ -n "$entry" ]; do if [[ "$entry" =~ ^#\ kwatch\ feature\ catalog\ v([0-9]+)$ ]]; then catalog_version="${BASH_REMATCH[1]}" continue fi [[ -z "$entry" || "$entry" = \#* ]] && continue IFS='|' read -r id lifecycle description dependencies tier <<<"$entry" [ -n "$id" ] && \ [ "$lifecycle" = startup -o "$lifecycle" = runtime ] && \ [ -n "$description" ] || return 1 # v1 has no tier column; v2 requires community or pro. case "$catalog_version" in 1) [ -z "$tier" ] || return 1 ;; 2) [ "$tier" = community -o "$tier" = pro ] || return 1 ;; *) return 1 ;; esac case "$seen" in *"|$id|"*) return 1 ;; esac seen="${seen}${id}|" loaded+=("$entry") done < "$file" case " $SUPPORTED_FEATURE_CATALOG_VERSIONS " in *" $catalog_version "*) ;; *) return 1 ;; esac [ "${#loaded[@]}" -gt 0 ] || return 1 FEATURE_CATALOG_VERSION="$catalog_version" FEATURE_CATALOG=("${loaded[@]}") } cache_feature_catalog() { local file="$1" kubectl -n "$NAMESPACE" create configmap "$FEATURE_CATALOG_CACHE_NAME" \ --from-file=features.tsv="$file" \ --from-literal=source="$FEATURE_CATALOG_SOURCE" \ --from-literal=updated-at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null 2>&1 || true } load_feature_catalog_for_version() { local version="${1:-}" tmp cached cached_source tmp=$(mktemp) trap 'if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi' RETURN if [ -n "$version" ] && with_loading "Loading capability catalog" \ download_catalog_artifact \ "$BASE_URL/$version/deploy/feature-catalog.tsv" "$tmp" \ && load_feature_catalog_file "$tmp"; then FEATURE_CATALOG_SOURCE="release:$version" cache_feature_catalog "$tmp" return 0 fi cached_source=$(kubectl -n "$NAMESPACE" get configmap "$FEATURE_CATALOG_CACHE_NAME" \ -o jsonpath='{.data.source}' 2>/dev/null || true) [ "$cached_source" = "release:$version" ] || return 1 cached=$(kubectl -n "$NAMESPACE" get configmap "$FEATURE_CATALOG_CACHE_NAME" \ -o jsonpath='{.data.features\.tsv}' 2>/dev/null || true) if [ -n "$cached" ]; then printf '%s\n' "$cached" > "$tmp" if load_feature_catalog_file "$tmp"; then FEATURE_CATALOG_SOURCE="cache" return 0 fi fi return 1 } load_provider_catalog_file() { local file="$1" entry provider display field type required secret validation default description local group condition local catalog_version="" seen="|" local -a loaded=() while IFS= read -r entry || [ -n "$entry" ]; do if [[ "$entry" =~ ^#\ kwatch\ provider\ catalog\ v([0-9]+)$ ]]; then catalog_version="${BASH_REMATCH[1]}" continue fi [[ -z "$entry" || "$entry" = \#* ]] && continue IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" [ -n "$provider" ] && [ -n "$display" ] && [ -n "$field" ] && \ case "$type" in string|integer|boolean|list|json|headers) ;; *) return 1 ;; esac [ -n "$provider" ] && [ -n "$display" ] && [ -n "$field" ] && \ { [ "$required" = true ] || [ "$required" = false ]; } && \ { [ "$secret" = true ] || [ "$secret" = false ]; } && \ [ -n "$description" ] || return 1 case "$validation" in ""|boolean|integer|json|list|port|signed-integer|url) ;; one-of:*) [ -n "${validation#one-of:}" ] || return 1 ;; *) return 1 ;; esac case "$seen" in *"|$provider.$field|"*) return 1 ;; esac seen="${seen}${provider}.${field}|" if [ -n "$condition" ]; then case "$condition" in choice:*) [ -n "$group" ] || return 1 ;; at-least-one) [ -n "$group" ] || return 1 ;; required-if:*) [[ "$condition" = *"="* ]] || return 1 ;; *) return 1 ;; esac fi loaded+=("$entry") done < "$file" [ "$catalog_version" = "$SUPPORTED_PROVIDER_CATALOG_VERSION" ] || return 1 [ "${#loaded[@]}" -gt 0 ] || return 1 PROVIDER_CATALOG_VERSION="$catalog_version" PROVIDER_CATALOG=("${loaded[@]}") } cache_provider_catalog() { local file="$1" kubectl -n "$NAMESPACE" create configmap "$PROVIDER_CATALOG_CACHE_NAME" \ --from-file=providers.tsv="$file" \ --from-literal=source="$PROVIDER_CATALOG_SOURCE" \ --from-literal=updated-at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null 2>&1 || true } load_provider_catalog_for_version() { local version="${1:-}" tmp cached cached_source tmp=$(mktemp) trap 'if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi' RETURN if [ -n "$version" ] && with_loading "Loading provider catalog" \ download_catalog_artifact \ "$BASE_URL/$version/deploy/provider-catalog.tsv" "$tmp" \ && load_provider_catalog_file "$tmp"; then PROVIDER_CATALOG_SOURCE="release:$version" cache_provider_catalog "$tmp" return 0 fi cached_source=$(kubectl -n "$NAMESPACE" get configmap "$PROVIDER_CATALOG_CACHE_NAME" \ -o jsonpath='{.data.source}' 2>/dev/null || true) [ "$cached_source" = "release:$version" ] || return 1 cached=$(kubectl -n "$NAMESPACE" get configmap "$PROVIDER_CATALOG_CACHE_NAME" \ -o jsonpath='{.data.providers\.tsv}' 2>/dev/null || true) if [ -n "$cached" ]; then printf '%s\n' "$cached" > "$tmp" if load_provider_catalog_file "$tmp"; then PROVIDER_CATALOG_SOURCE="cache" return 0 fi fi return 1 } BACKUP_NAME="" LEGACY_BACKUP_NAME="" # Backup names are per-second; these keep a second backup inside the same second # from overwriting the first. LAST_BACKUP_TIMESTAMP="" LAST_BACKUP_SUFFIX=0"" backup_config() { local resource timestamp candidate license_key license_backup_file backup_rc=0 local -a license_backup_args=() resource=$(kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" -o json) timestamp=$(date -u +%Y%m%d%H%M%S) # The name is only accurate to the second, and two backups can land inside # one -- entering the settings takes one, changing a value takes another. # `apply` would then overwrite the first, quietly costing a generation of the # thing that exists to protect the operator. Counting within the session # settles it without asking the API server: a collision means this session # just wrote that name. if [ "$timestamp" = "$LAST_BACKUP_TIMESTAMP" ]; then LAST_BACKUP_SUFFIX=$((LAST_BACKUP_SUFFIX + 1)) candidate="${RELEASE}-config-$timestamp-$LAST_BACKUP_SUFFIX" else LAST_BACKUP_TIMESTAMP="$timestamp" LAST_BACKUP_SUFFIX=0 candidate="${RELEASE}-config-$timestamp" fi BACKUP_NAME="$candidate" # The license key (the ${RELEASE}-license Secret) travels with the backup so a # restore does not lose it. kwatch-license-instance is deliberately NOT # included: it is kwatch's record of which install it belongs to, and putting # it back on a different cluster -- or into a copy of this one -- would make # that cluster look like a clone of the original and trip the one-key, # one-cluster check. kwatch rebuilds it on its own. license_backup_args=() license_backup_file="" if license_key=$(license_current_key); then license_backup_file=$(mktemp) chmod 600 "$license_backup_file" printf '%s' "$license_key" >"$license_backup_file" license_backup_args=(--from-file="$LICENSE_SECRET_KEY=$license_backup_file") fi kubectl -n "$NAMESPACE" create secret generic "$BACKUP_NAME" \ --from-literal=resource.json="$resource" \ ${license_backup_args[@]+"${license_backup_args[@]}"} \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null || backup_rc=$? [ -z "$license_backup_file" ] || rm -f "$license_backup_file" [ "$backup_rc" -eq 0 ] || return "$backup_rc" kubectl -n "$NAMESPACE" label secret "$BACKUP_NAME" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true prune_config_backups } backup_legacy_install() { local timestamp tmp_dir metadata_file cache_path local -a files=() timestamp=$(date -u +%Y%m%d%H%M%S) LEGACY_BACKUP_NAME="${RELEASE}-legacy-backup-$timestamp" tmp_dir=$(mktemp -d) trap 'if [ -n "${tmp_dir:-}" ]; then rm -rf "$tmp_dir"; fi' RETURN metadata_file="$tmp_dir/metadata.txt" cat >"$metadata_file" <"$tmp_dir/deployment.yaml" 2>/dev/null; then files+=(--from-file=deployment.yaml="$tmp_dir/deployment.yaml") fi if kubectl -n "$NAMESPACE" get configmap "$RELEASE" \ -o yaml >"$tmp_dir/legacy-configmap.yaml" 2>/dev/null; then files+=(--from-file=legacy-configmap.yaml="$tmp_dir/legacy-configmap.yaml") fi if kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o yaml >"$tmp_dir/kwatchconfig.yaml" 2>/dev/null; then files+=(--from-file=kwatchconfig.yaml="$tmp_dir/kwatchconfig.yaml") fi if kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ -o yaml >"$tmp_dir/config-secret.yaml" 2>/dev/null; then files+=(--from-file=config-secret.yaml="$tmp_dir/config-secret.yaml") fi if kubectl -n "$NAMESPACE" get configmap "$STATE_CONFIGMAP_NAME" \ -o yaml >"$tmp_dir/manager-state.yaml" 2>/dev/null; then files+=(--from-file=manager-state.yaml="$tmp_dir/manager-state.yaml") fi if kubectl -n "$NAMESPACE" get configmap "$CATALOG_CACHE_NAME" \ -o yaml >"$tmp_dir/config-catalog-cache.yaml" 2>/dev/null; then files+=( --from-file=config-catalog-cache.yaml="$tmp_dir/config-catalog-cache.yaml" ) fi if kubectl -n "$NAMESPACE" get configmap "$FEATURE_CATALOG_CACHE_NAME" \ -o yaml >"$tmp_dir/feature-catalog-cache.yaml" 2>/dev/null; then cache_path="$tmp_dir/feature-catalog-cache.yaml" files+=("--from-file=feature-catalog-cache.yaml=$cache_path") fi if kubectl -n "$NAMESPACE" get configmap "$PROVIDER_CATALOG_CACHE_NAME" \ -o yaml >"$tmp_dir/provider-catalog-cache.yaml" 2>/dev/null; then cache_path="$tmp_dir/provider-catalog-cache.yaml" files+=("--from-file=provider-catalog-cache.yaml=$cache_path") fi [ "${#files[@]}" -gt 1 ] || die "could not collect legacy kwatch configuration" kubectl -n "$NAMESPACE" create secret generic "$LEGACY_BACKUP_NAME" \ "${files[@]}" \ --from-literal=backup-kind=legacy-install \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null || die "could not create the legacy kwatch backup" kubectl -n "$NAMESPACE" label secret "$LEGACY_BACKUP_NAME" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh \ kwatch.dev/backup-kind=legacy-install --overwrite >/dev/null || die "could not label the legacy kwatch backup" ui_success "✅ Legacy configuration backup created." ui_info "📦 Backup Secret: $NAMESPACE/$LEGACY_BACKUP_NAME" ui_info \ "🔎 Inspect it with: kubectl -n $NAMESPACE get secret $LEGACY_BACKUP_NAME" } restore_backup() { local encoded deployment backup_file [ -n "$BACKUP_NAME" ] || return 0 encoded=$(kubectl -n "$NAMESPACE" get secret "$BACKUP_NAME" \ -o 'jsonpath={.data.resource\.json}') [ -n "$encoded" ] || die "backup is missing resource.json; refusing to restore it" backup_file=$(mktemp) if ! decode_base64_file "$encoded" "$backup_file"; then rm -f "$backup_file" die "backup is not valid base64; refusing to restore it" fi if ! kubectl -n "$NAMESPACE" apply -f "$backup_file" >/dev/null; then rm -f "$backup_file" die "backup resource could not be restored" fi rm -f "$backup_file" restore_license_from_backup deployment=$(deployment_name) [ -n "$deployment" ] || return 0 restart_kwatch } # Puts the license Secret back from a backup when it has gone missing. An # existing Secret is left alone: it may hold a newer key than the backup. restore_license_from_backup() { local encoded key deployment encoded=$(secret_data_base64 "$BACKUP_NAME" "$LICENSE_SECRET_KEY") [ -n "$encoded" ] || return 0 license_secret_exists && return 0 key=$(printf '%s' "$encoded" | base64 -d 2>/dev/null || printf '%s' "$encoded" | base64 --decode 2>/dev/null || printf '%s' "$encoded" | base64 -D 2>/dev/null) || return 0 [ -n "$key" ] || return 0 license_store_secret "$key" || return 0 deployment=$(deployment_name || true) [ -n "$deployment" ] && license_wire_env "$deployment" || true ui_info "🔑 Restored the license key ($(license_mask "$key"))." } restore_backup_after_failure() { local details="$1" if confirm_repair \ "restore the previous configuration" \ "$details The saved backup will be applied and kwatch will be restarted."; then restore_backup return 0 fi ui_warn "⚠️ Configuration restore skipped; the current state was left in place." return 1 } config_value() { local path="$1" kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o "jsonpath={.spec.$path}" 2>/dev/null || true } migrate_legacy_telemetry() { local current encoded tmp old_path value current=$(config_value telemetry.enabled) case "$current" in true|false) return 0 ;; esac encoded=$(secret_data_base64 "$CONFIG_SECRET_NAME" config.yaml || true) [ -n "$encoded" ] || return 0 tmp=$(mktemp) trap 'if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi' RETURN decode_base64_file "$encoded" "$tmp" || return 0 old_path="${OLD_CONFIG_PATH:-}" OLD_CONFIG_PATH="$tmp" value=$(old_config_value telemetry.enabled || true) OLD_CONFIG_PATH="$old_path" case "$value" in true|false) patch_config_value telemetry.enabled boolean "$value" || return 1 ui_info "🔧 Preserved the existing telemetry setting in KwatchConfig." ;; esac } # Applying a CRD that drops a version the cluster already stores objects in # is rejected by the API server with a raw error. Check first. crd_keeps_stored_versions() { local crd_file="$1" stored v stored=$(kubectl get crd kwatchconfigs.kwatch.abahmed.dev \ -o 'jsonpath={.status.storedVersions[*]}' 2>/dev/null || true) for v in $stored; do grep -Eq "^ - name: $v\$" "$crd_file" && continue LAST_COMMAND_ERROR="This release cannot manage a cluster that already stores KwatchConfig as $v; choose a newer release." return 1 done } # Releases that ship v1beta1 mark v1alpha1 deprecated, and kubectl warns on # every write to it. Older CRDs serve only v1alpha1, so ask the installed CRD # which version it serves; the fields written below are the same in both. config_api_version() { local served served=$(kubectl get crd kwatchconfigs.kwatch.abahmed.dev \ -o 'jsonpath={.spec.versions[?(@.served==true)].name}' 2>/dev/null || true) case " $served " in *" v1beta1 "*) printf '%s' kwatch.abahmed.dev/v1beta1 ;; *) printf '%s' kwatch.abahmed.dev/v1alpha1 ;; esac } # The installer creates the namespace itself, without the annotation that # `kubectl apply` merges against, so applying the release's Namespace document # onto it prints a warning. Drop that document when the namespace exists. drop_namespace_document() { local manifest="$1" kubectl get namespace "$NAMESPACE" >/dev/null 2>&1 || return 0 awk 'function flush() { if (!skip) printf "%s", buf; buf = ""; skip = 0 } /^---[[:space:]]*$/ { flush(); buf = $0 "\n"; next } /^kind: Namespace[[:space:]]*$/ { skip = 1 } { buf = buf $0 "\n" } END { flush() }' "$manifest" >"$manifest.ns" && mv "$manifest.ns" "$manifest" } ensure_config_resource() { if kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" >/dev/null 2>&1; then kubectl -n "$NAMESPACE" annotate kwatchconfig "$RELEASE" \ "kwatch.dev/config-schema=$CATALOG_VERSION" --overwrite >/dev/null kubectl -n "$NAMESPACE" label kwatchconfig "$RELEASE" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true return 0 fi local api_version api_version=$(config_api_version) kubectl -n "$NAMESPACE" apply -f - </dev/null apiVersion: $api_version kind: KwatchConfig metadata: name: $RELEASE namespace: $NAMESPACE labels: app.kubernetes.io/instance: "$RELEASE" app.kubernetes.io/managed-by: kwatch.sh kwatch.dev/config-schema: "$CATALOG_VERSION" spec: crd: enabled: true EOF } ensure_crd() { local version="${1:-}" tmp if [ -z "$version" ] && kubectl get crd kwatchconfigs.kwatch.abahmed.dev >/dev/null 2>&1; then return 0 fi if [ -z "$version" ]; then version=$(latest_version) || die "could not determine kwatch version from GitHub" fi valid_release_version "$version" || die "invalid kwatch release version: $version" tmp=$(mktemp) trap 'if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi' RETURN with_loading "Downloading CRD for $version" curl -fsSL --location \ --retry 3 --retry-delay 2 --connect-timeout 10 \ "$BASE_URL/$version/deploy/crd.yaml" -o "$tmp" || die "could not download the CRD for $version" verify_release_file "$version" deploy/crd.yaml "$tmp" || die "${LAST_COMMAND_ERROR:-the CRD for $version could not be verified}" grep -q '^kind: CustomResourceDefinition$' "$tmp" || die "downloaded CRD for $version is invalid" crd_keeps_stored_versions "$tmp" || die "$LAST_COMMAND_ERROR" with_loading "Applying CRD" \ kubectl apply --server-side --field-manager=kwatch-manager -f "$tmp" \ >/dev/null || die "could not apply the CRD for $version" with_loading "Waiting for CRD readiness" kubectl wait \ --for=condition=Established crd/kwatchconfigs.kwatch.abahmed.dev \ --timeout=60s >/dev/null || die "CRD did not become ready" } json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g; s/\n/\\n/g' } json_array() { local raw="$1" item first=true if [ -z "$raw" ] || [ "$raw" = all ] || [ "$raw" = empty ]; then printf '[]' return fi printf '[' IFS=',' read -ra items <<<"$raw" for item in "${items[@]}"; do item="${item#${item%%[![:space:]]*}}" item="${item%${item##*[![:space:]]}}" [ -n "$item" ] || continue [ "$first" = true ] || printf ',' printf '"%s"' "$(json_escape "$item")" first=false done printf ']' } write_provider_value() { local file="$1" field="$2" type="$3" value="$4" local key parent child yaml_value case "$type" in boolean) [[ "$value" = true || "$value" = false ]] || die "$field must be true or false" yaml_value="$value" ;; integer) [[ "$value" =~ ^[0-9]+$ ]] || die "$field must be a non-negative integer" yaml_value="$value" ;; list) yaml_value=$(json_array "$value") ;; json) [ -n "$value" ] || die "$field cannot be empty" yaml_value="$value" ;; *) yaml_value="\"$(json_escape "$value")\"" ;; esac if [[ "$field" == *.* ]]; then parent="${field%%.*}" child="${field#*.}" key="|$parent|" case "$WRITTEN_PROVIDER_SECTIONS" in *"$key"*) ;; *) printf ' %s:\n' "$parent" >>"$file" WRITTEN_PROVIDER_SECTIONS="${WRITTEN_PROVIDER_SECTIONS}${parent}|" ;; esac printf ' %s: %s\n' "$child" "$yaml_value" >>"$file" return fi printf ' %s: %s\n' "$field" "$yaml_value" >>"$file" } write_provider_secret() { local file="$1" field="$2" value="$3" tmp_dir="$4" local secret_key value_file case "$value" in *$'\n'*|*$'\r'*) die "$field must be one line" ;; esac secret_key="${PROVIDER}-${field//./-}" value_file="$tmp_dir/$secret_key" printf '%s' "$value" >"$value_file" chmod 600 "$value_file" SECRET_ARGS+=(--from-file="$secret_key=$value_file") if [[ "$field" == *.* ]]; then local parent="${field%%.*}" child="${field#*.}" key="|$parent|" case "$WRITTEN_PROVIDER_SECTIONS" in *"$key"*) ;; *) printf ' %s:\n' "$parent" >>"$file" WRITTEN_PROVIDER_SECTIONS="${WRITTEN_PROVIDER_SECTIONS}${parent}|" ;; esac printf ' %s: "${file:%s/%s}"\n' \ "$child" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" return fi printf ' %s: "${file:%s/%s}"\n' \ "$field" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" } write_config_secret_value() { local file="$1" path="$2" value="$3" tmp_dir="$4" local secret_key value_file parent child key case "$value" in *$'\n'*|*$'\r'*) return 1 ;; esac if [ "$path" = heartbeatMonitor.url ] && [[ ! "$value" =~ ^https?:// ]]; then return 1 fi secret_key="${RELEASE}-config-${path//./-}" value_file="$tmp_dir/$secret_key" printf '%s' "$value" >"$value_file" chmod 600 "$value_file" SECRET_ARGS+=(--from-file="$secret_key=$value_file") if [[ "$path" == *.* ]]; then parent="${path%%.*}" child="${path#*.}" key="|$parent|" case "$WRITTEN_CONFIG_SECTIONS" in *"$key"*) ;; *) printf '%s:\n' " $parent" >>"$file" WRITTEN_CONFIG_SECTIONS="${WRITTEN_CONFIG_SECTIONS}${parent}|" ;; esac printf ' %s: "${file:%s/%s}"\n' \ "$child" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" return fi printf ' %s: "${file:%s/%s}"\n' \ "$path" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" } secret_data_base64() { local secret_name="$1" key="$2" encoded encoded=$(kubectl -n "$NAMESPACE" get secret "$secret_name" \ -o "go-template={{index .data \"$key\"}}" 2>/dev/null || true) case "$encoded" in ""|""|null|\(null\)) return 0 ;; esac printf '%s' "$encoded" } decode_base64_file() { local encoded="$1" file="$2" [ -n "$encoded" ] || return 1 printf '%s' "$encoded" | base64 --decode >"$file" 2>/dev/null && return 0 printf '%s' "$encoded" | base64 -d >"$file" 2>/dev/null && return 0 printf '%s' "$encoded" | base64 -D >"$file" 2>/dev/null } preserve_secret_file() { local key="$1" tmp_dir="$2" encoded value_file encoded=$(secret_data_base64 "$CONFIG_SECRET_NAME" "$key") [ -n "$encoded" ] || return 1 value_file="$tmp_dir/$key" decode_base64_file "$encoded" "$value_file" || return 1 chmod 600 "$value_file" SECRET_ARGS+=(--from-file="$key=$value_file") } preserve_provider_secret() { local file="$1" provider="$2" field="$3" tmp_dir="$4" local secret_key key parent child secret_key="${provider}-${field//./-}" preserve_secret_file "$secret_key" "$tmp_dir" || return 1 if [[ "$field" == *.* ]]; then parent="${field%%.*}" child="${field#*.}" key="|$parent|" case "$WRITTEN_PROVIDER_SECTIONS" in *"$key"*) ;; *) printf ' %s:\n' "$parent" >>"$file" WRITTEN_PROVIDER_SECTIONS="${WRITTEN_PROVIDER_SECTIONS}${parent}|" ;; esac printf ' %s: "${file:%s/%s}"\n' \ "$child" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" return 0 fi printf ' %s: "${file:%s/%s}"\n' \ "$field" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" } preserve_config_secret() { local file="$1" path="$2" tmp_dir="$3" local secret_key parent child key secret_key="${RELEASE}-config-${path//./-}" preserve_secret_file "$secret_key" "$tmp_dir" || return 1 parent="${path%%.*}" child="${path#*.}" key="|$parent|" case "$WRITTEN_CONFIG_SECTIONS" in *"$key"*) ;; *) printf ' %s:\n' "$parent" >>"$file" WRITTEN_CONFIG_SECTIONS="${WRITTEN_CONFIG_SECTIONS}${parent}|" ;; esac printf ' %s: "${file:%s/%s}"\n' \ "$child" "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" } old_provider_field_block() { local provider="$1" field="$2" old_file="$3" local parent child if [[ "$field" == *.* ]]; then parent="${field%%.*}" child="${field#*.}" awk -v provider=" $provider:" -v parent=" $parent:" \ -v child=" $child:" ' $0 == provider { in_provider=1; next } in_provider && $0 ~ /^ [^ ]/ { exit } in_provider && $0 == parent { in_parent=1; next } in_parent && $0 ~ /^ [^ ]/ { in_parent=0 } in_parent && index($0, child) == 1 { print; exit } ' "$old_file" return fi awk -v provider=" $provider:" -v field=" $field:" ' $0 == provider { in_provider=1; next } in_provider && $0 ~ /^ [^ ]/ { exit } in_provider && index($0, field) == 1 { print; exit } ' "$old_file" } old_provider_section() { local provider="$1" field="$2" old_file="$3" awk -v provider=" $provider:" -v field=" $field:" ' $0 == provider { in_provider=1; next } in_provider && $0 ~ /^ [^ ]/ { exit } in_provider && index($0, field) == 1 { in_field=1 } in_field && $0 ~ /^ [^ ]/ && index($0, field) != 1 { exit } in_field { print } ' "$old_file" } old_provider_exists() { local provider="$1" [ -s "${OLD_CONFIG_PATH:-}" ] || return 1 awk -v wanted=" $provider:" ' $0 == wanted { found=1; exit } END { exit(found ? 0 : 1) } ' "$OLD_CONFIG_PATH" } old_config_value() { local path="$1" parent child [ -s "${OLD_CONFIG_PATH:-}" ] || return 1 parent="${path%%.*}" child="${path#*.}" awk -v parent="$parent:" -v child=" $child:" ' $0 == parent { in_parent=1; next } in_parent && $0 ~ /^[^ ]/ { exit } in_parent && index($0, child) == 1 { value=substr($0, length(child) + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) print value exit } ' "$OLD_CONFIG_PATH" } existing_provider_names() { local provider display entry catalog_provider [ -s "${OLD_CONFIG_PATH:-}" ] || return 0 while IFS= read -r provider; do display="$provider" for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r catalog_provider display _ <<<"$entry" [ "$catalog_provider" = "$provider" ] && break display="$provider" done printf '%s|%s\n' "$provider" "$display" done < <(awk ' $0 == "alert:" { in_alert=1; next } in_alert && $0 ~ /^[^ ]/ { exit } in_alert && $0 ~ /^ [^ ][^:]*:$/ { name=$0 sub(/^ /, "", name) sub(/:$/, "", name) print name } ' "$OLD_CONFIG_PATH") } preserve_provider_block() { local file="$1" provider="$2" tmp_dir="$3" block ref key [ -s "${OLD_CONFIG_PATH:-}" ] || return 1 block=$(awk -v provider=" $provider:" ' $0 == provider { found=1 } found && $0 ~ /^ [^ ]/ && $0 != provider { exit } found { print } ' "$OLD_CONFIG_PATH") [ -n "$block" ] || return 1 while IFS= read -r ref; do key="${ref##*/}" key="${key%\}}" [ -n "$key" ] || continue preserve_secret_file "$key" "$tmp_dir" || return 1 done < <(printf '%s\n' "$block" | grep -o '\${file:[^}]*}' || true) printf '%s\n' "$block" >>"$file" } provider_in_catalog() { local wanted="$1" entry provider for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider _ <<<"$entry" [ "$provider" = "$wanted" ] && return 0 done return 1 } old_provider_plain_value() { local provider="$1" field="$2" block value block=$(old_provider_field_block "$provider" "$field" "$OLD_CONFIG_PATH") [ -n "$block" ] || return 1 value="${block#*:}" value="${value#${value%%[![:space:]]*}}" value="${value%${value##*[![:space:]]}}" case "$value" in \$\{file:*) return 1 ;; \"*\") value="${value#\"}"; value="${value%\"}" ;; \'*\') value="${value#\'}"; value="${value%\'}" ;; esac [ -n "$value" ] || return 1 value="${value//\\\"/\"}" value="${value//\\\\/\\}" printf '%s' "$value" } preserve_provider_from_catalog() { local file="$1" provider="$2" tmp_dir="$3" entry catalog_provider local display field type required secret validation default description local group condition value previous_provider previous_provider="${PROVIDER:-}" PROVIDER="$provider" WRITTEN_PROVIDER_SECTIONS="|" printf ' %s:\n' "$provider" >>"$file" for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r catalog_provider display field type required secret \ validation default description group condition <<<"$entry" [ "$catalog_provider" = "$provider" ] || continue if [ "$secret" = true ]; then if preserve_provider_secret "$file" "$provider" "$field" "$tmp_dir"; then continue fi value=$(old_provider_plain_value "$provider" "$field" || true) [ -n "$value" ] || continue write_provider_secret "$file" "$field" "$value" "$tmp_dir" continue fi preserve_provider_optional "$file" "$provider" "$field" "$type" \ "$tmp_dir" || true done PROVIDER="$previous_provider" } preserve_existing_providers() { local file="$1" tmp_dir="$2" only_unselected="${3:-false}" local provider display preserved=false while IFS='|' read -r provider display; do [ -n "$provider" ] || continue if [ "$only_unselected" = true ]; then case "$CONFIGURED_PROVIDERS" in *"|$provider|"*) continue ;; esac fi if provider_in_catalog "$provider"; then preserve_provider_from_catalog "$file" "$provider" "$tmp_dir" else preserve_provider_block "$file" "$provider" "$tmp_dir" || die "could not safely preserve existing provider $display" fi preserved=true done < <(existing_provider_names) [ "$preserved" = true ] } # Providers that will exist after this edit: the ones already configured plus # the ones chosen in this session, each counted once. provider_total_count() { local provider display { existing_provider_names | while IFS='|' read -r provider display; do printf '%s\n' "$provider" done printf '%s' "${CONFIGURED_PROVIDERS:-|}" | tr '|' '\n' } | sed '/^$/d' | sort -u | wc -l | tr -d ' ' } has_unselected_existing_provider() { local provider display while IFS='|' read -r provider display; do case "$CONFIGURED_PROVIDERS" in *"|$provider|"*) ;; *) return 0 ;; esac done < <(existing_provider_names) return 1 } preserve_provider_optional() { local file="$1" provider="$2" field="$3" type="$4" tmp_dir="$5" local block ref key parent child section_key [ -s "${OLD_CONFIG_PATH:-}" ] || return 1 if [ "$type" = headers ]; then block=$(old_provider_section "$provider" "$field" "$OLD_CONFIG_PATH") [ -n "$block" ] || return 1 while IFS= read -r ref; do key="${ref##*/}" key="${key%\}}" [ -n "$key" ] || continue preserve_secret_file "$key" "$tmp_dir" || return 1 done < <(printf '%s\n' "$block" | grep -o '\${file:[^}]*}' || true) printf '%s\n' "$block" >>"$file" return 0 fi block=$(old_provider_field_block "$provider" "$field" "$OLD_CONFIG_PATH") [ -n "$block" ] || return 1 if [[ "$field" == *.* ]]; then parent="${field%%.*}" child="${field#*.}" section_key="|$parent|" case "$WRITTEN_PROVIDER_SECTIONS" in *"$section_key"*) ;; *) printf ' %s:\n' "$parent" >>"$file" WRITTEN_PROVIDER_SECTIONS="${WRITTEN_PROVIDER_SECTIONS}${parent}|" ;; esac printf ' %s\n' "${block#* }" >>"$file" return 0 fi printf '%s\n' "$block" >>"$file" } write_webhook_headers() { local file="$1" tmp_dir="$2" count name value i secret_key value_file while true; do count=$(ask_or_back "Number of custom webhook headers" "0") || return 2 [[ "$count" =~ ^[0-9]+$ ]] && break ui_warn "⚠️ Header count must be a non-negative integer." done [ "$count" -gt 0 ] || return 0 printf ' headers:\n' >>"$file" for ((i = 1; i <= count; i++)); do while true; do name=$(ask_or_back "Header $i name") || return 2 [ -n "$name" ] && break ui_warn "⚠️ Header name cannot be empty." done while true; do value=$(ask_secret_or_back "Header $i value") || return 2 if [ -n "$value" ] && [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]]; then break fi ui_warn "⚠️ Header value must be a non-empty single line." done secret_key="${PROVIDER}-header-${i}" value_file="$tmp_dir/$secret_key" printf '%s' "$value" >"$value_file" chmod 600 "$value_file" SECRET_ARGS+=(--from-file="$secret_key=$value_file") printf ' - name: "%s"\n' "$(json_escape "$name")" >>"$file" printf ' value: "${file:%s/%s}"\n' \ "$CONFIG_MOUNT_PATH" "$secret_key" >>"$file" done } patch_config_value() { local path="$1" type="$2" value="$3" top field json_value if [[ "$path" == *.* ]]; then top="${path%%.*}" field="${path#*.}" case "$type" in boolean) [[ "$value" = true || "$value" = false ]] || return 1; json_value="$value" ;; integer) [[ "$value" =~ ^[0-9]+$ ]] || return 1; json_value="$value" ;; float) [[ "$value" =~ ^[0-9]+([.][0-9]+)?$ ]] || return 1; json_value="$value" ;; list) json_value="$(json_array "$value")" ;; json) json_value="$value" ;; *) json_value="\"$(json_escape "$value")\"" ;; esac kubectl -n "$NAMESPACE" patch kwatchconfig "$RELEASE" --type merge \ -p "{\"spec\":{\"$top\":{\"$field\":$json_value}}}" >/dev/null else case "$type" in boolean) [[ "$value" = true || "$value" = false ]] || return 1; json_value="$value" ;; integer) [[ "$value" =~ ^[0-9]+$ ]] || return 1; json_value="$value" ;; float) [[ "$value" =~ ^[0-9]+([.][0-9]+)?$ ]] || return 1; json_value="$value" ;; list) json_value="$(json_array "$value")" ;; json) json_value="$value" ;; *) json_value="\"$(json_escape "$value")\"" ;; esac kubectl -n "$NAMESPACE" patch kwatchconfig "$RELEASE" --type merge \ -p "{\"spec\":{\"$path\":$json_value}}" >/dev/null fi } show_catalog_entry() { local entry="$1" path type default category description status replacement current IFS='|' read -r path type default category description status replacement <<<"$entry" current=$(config_value "$path") [ -n "$current" ] || current="default ($default)" ui_heading "⚙️ $path" ui_detail "$description" ui_detail "Type: $type | Current: $current | Default: $default" if [ "$status" = deprecated ]; then ui_warn "⚠️ Deprecated: use $replacement instead." fi } migration_notice() { local schema entry path type default category description status replacement current [ "${#CATALOG[@]}" -gt 0 ] || return 0 schema=$(kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o 'jsonpath={.metadata.labels.kwatch\.dev/config-schema}' 2>/dev/null || true) if [ -n "$schema" ] && [ "$schema" -lt "$CATALOG_VERSION" ] 2>/dev/null; then ui_warn \ "⚠️ Configuration schema $schema is older than this manager's schema $CATALOG_VERSION." ui_info "ℹ️ New settings use documented defaults; existing settings are preserved." fi for entry in "${CATALOG[@]-}"; do IFS='|' read -r path type default category description status replacement <<<"$entry" [ "$status" = deprecated ] || continue current=$(config_value "$path") [ -z "$current" ] || ui_warn "⚠️ Deprecated config detected: $path (use $replacement)" done return 0 } migrate_legacy_silences() { local marker values item field rule_key first marker=$(kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o 'jsonpath={.metadata.annotations.kwatch\.dev/legacy-silences-migrated}' 2>/dev/null || true) [ "$marker" = "$CATALOG_VERSION" ] && return 0 for field in ignoreContainerNames ignorePodNames ignoreLogPatterns ignoreContainerMessages ignoreNodeReasons ignoreNodeMessages; do case "$field" in ignoreContainerNames) rule_key=containerNames ;; ignorePodNames) rule_key=podNamePatterns ;; ignoreLogPatterns) rule_key=logPatterns ;; ignoreContainerMessages) rule_key=containerMessages ;; ignoreNodeReasons) rule_key=nodeReasons ;; ignoreNodeMessages) rule_key=nodeMessages ;; esac values=$(kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o "jsonpath={range .spec.${field}[*]}{.}{\"\\n\"}{end}" 2>/dev/null || true) [ -n "$values" ] || continue first=$(kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o 'jsonpath={.spec.silences}' 2>/dev/null || true) if [ -z "$first" ]; then kubectl -n "$NAMESPACE" patch kwatchconfig "$RELEASE" --type merge \ -p '{"spec":{"silences":[]}}' >/dev/null fi while IFS= read -r item; do [ -n "$item" ] || continue kubectl -n "$NAMESPACE" patch kwatchconfig "$RELEASE" --type=json \ -p "[{\"op\":\"add\",\"path\":\"/spec/silences/-\",\"value\":{\"$rule_key\":[\"$(json_escape "$item")\"]}}]" >/dev/null done <<< "$values" kubectl -n "$NAMESPACE" patch kwatchconfig "$RELEASE" --type=json \ -p "[{\"op\":\"remove\",\"path\":\"/spec/$field\"}]" >/dev/null done kubectl -n "$NAMESPACE" annotate kwatchconfig "$RELEASE" \ "kwatch.dev/legacy-silences-migrated=$CATALOG_VERSION" --overwrite >/dev/null ui_success "✅ Legacy ignore settings migrated into scoped silences." } # Reading each setting with its own API call meant ten round trips to draw one # category. jsonpath can emit several fields in one response, one per line. config_values_for_paths() { local path jsonpath="" for path in "$@"; do jsonpath="${jsonpath}{.spec.${path}}{\"\\n\"}" done [ -n "$jsonpath" ] || return 0 kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ -o "jsonpath=$jsonpath" 2>/dev/null || true } configure_flow() { local deployment choice category entry path type default category_name local description status replacement current value display_value prompt_default local decision index row path_width value_width max_value local -a categories=() category_labels=() entries=() labels=() values=() paths=() local -a captions=() CONFIG_FLOW_CHANGED=false require_config_catalog # No confirmation to reach the list: nothing is written by browsing it, and # each individual change is confirmed on its own below. ensure_crd # Opening the settings is around twenty seconds of API calls on a remote # cluster the first time -- a permission preflight, the configuration # resource, two migrations and a backup. Name each step rather than leaving a # blank screen; the permission answers are cached, so a second visit is quick. ui_busy "Checking permissions" preflight_access manage ui_busy_done ui_busy "Reading the configuration resource" preflight_config_resource ensure_config_resource ui_busy_done ui_busy "Preserving existing settings" migrate_legacy_telemetry || die "could not preserve the existing telemetry setting" ui_busy_done ui_busy "Saving a configuration backup" backup_config ui_busy_done ui_busy "Migrating legacy settings" if ! migrate_legacy_silences; then ui_busy_done ui_error "❌ Legacy configuration migration failed; the previous configuration can be restored." restore_backup_after_failure \ "This reverses the legacy settings migration that just failed." || true die "configuration migration failed" fi ui_busy_done categories=("Alerts" "Scope" "Performance" "Incident memory" "Noise reduction" "Monitors" "Operations" "Compatibility" "Product control" "Security") category_labels=("🚨 Alerts" "🎯 Scope" "⚡ Performance" "🧠 Incident memory" "🔇 Noise reduction" "🔍 Monitors" "🛠️ Operations" "🔄 Compatibility" "🎛️ Product control" "🔒 Security" "↩️ Back") while true; do ui_screen_clear ui_heading "⚙️ kwatch configuration" show_flow_notice # Leaving the settings without editing anything changed nothing, so the # session does not need to re-read the installation on the way back. choice=$(ui_select 0 "${category_labels[@]}") || { [ "$CONFIG_FLOW_CHANGED" = true ] || MENU_READ_ONLY=true return 0 } [ "$choice" -lt "${#categories[@]}" ] || return 0 category="${categories[$choice]}" entries=() paths=() for entry in ${CATALOG[@]+"${CATALOG[@]}"}; do IFS='|' read -r path type default category_name description status replacement <<<"$entry" [ "$category_name" = "$category" ] || continue entries+=("$entry") paths+=("$path") done if [ "${#entries[@]}" -eq 0 ]; then flow_notice info "ℹ️ No settings in $category." continue fi values=() ui_busy "Reading current values" while IFS= read -r current; do values+=("$current") done < <(config_values_for_paths "${paths[@]}") ui_busy_done # Three columns: the setting, what it is set to, and what it does. The # description comes straight from the release catalog, so it always matches # the version installed. path_width=0 for path in "${paths[@]}"; do [ "${#path}" -gt "$path_width" ] && path_width="${#path}" done [ "$path_width" -gt 34 ] && path_width=34 # Size the value column to the values actually present so the description # keeps the rest of the line, and leave room for the row marker or the # numbered prefix the plain fallback adds. value_width=8 for index in "${!entries[@]}"; do IFS='|' read -r path type default category_name description status replacement <<<"${entries[$index]}" display_value="${values[$index]:-default: $default}" [ "${#display_value}" -gt "$value_width" ] && value_width="${#display_value}" done # With the description off the row there is room for the value in full; # cap it only at what the window can actually show. max_value=$(($(ui_columns) - path_width - 10)) [ "$max_value" -lt 8 ] && max_value=8 [ "$value_width" -gt "$max_value" ] && value_width="$max_value" # Two columns, not three. The description used to share the row and was # truncated to fit -- a clipped sentence is worse than none -- so it now # captions the highlighted row on the line below the window, in full. labels=() captions=() for index in "${!entries[@]}"; do IFS='|' read -r path type default category_name description status replacement <<<"${entries[$index]}" display_value="${values[$index]:-}" [ -n "$display_value" ] || display_value="default: $default" printf -v row '%-*s %-*s' \ "$path_width" "$(ui_truncate "$path" "$path_width")" \ "$value_width" "$(ui_truncate "$display_value" "$value_width")" config_path_is_pro "$path" && row="$row $(pro_badge)" labels+=("$row") captions+=("$description") done labels+=("↩️ Back") captions+=("Return to the previous menu") ui_screen_clear ui_heading "📋 $category settings" choice=$(ui_select_captioned 0 "${#labels[@]}" "${labels[@]}" \ "${captions[@]}") || continue [ "$choice" -lt "${#entries[@]}" ] || continue entry="${entries[$choice]}" IFS='|' read -r path type default category_name description status replacement <<<"$entry" if [ "$status" = secret ]; then ui_info "🔐 Secret-backed settings are changed with Edit notification providers." continue fi current="${values[$choice]:-}" show_catalog_entry "$entry" [ "$status" = deprecated ] && ui_warn "⚠️ Existing values are preserved; migration is not destructive." case "$type" in boolean) # A true/false setting is a choice, not something to spell out. if [ "${current:-$default}" = true ]; then decision=$(ask_yes_no_or_back "Enable ${path%.enabled}?" "y") || continue else decision=$(ask_yes_no_or_back "Enable ${path%.enabled}?" "n") || continue fi value="$decision" ;; *) prompt_default="$current" [ -n "$prompt_default" ] || prompt_default="$default" value=$(ask_or_back "New value (Enter keeps current)" \ "$prompt_default") || continue [ -n "$value" ] || continue ;; esac [ "$value" = "$current" ] && { flow_notice info "↩️ $path is already $value." continue } confirm_change \ "⚙️ Change $path to '$value'." \ "The current value is '${current:-default ($default)}'. kwatch will validate the new value and restart the workload if needed." || { flow_notice info "↩️ Keeping the existing value for $path." continue } CONFIG_FLOW_CHANGED=true backup_config if ! patch_config_value "$path" "$type" "$value"; then ui_error "❌ Invalid configuration value; the previous configuration can be restored." restore_backup_after_failure \ "The attempted value was rejected by Kubernetes." || true continue fi kubectl -n "$NAMESPACE" annotate kwatchconfig "$RELEASE" \ "kwatch.dev/config-schema=$CATALOG_VERSION" --overwrite >/dev/null deployment=$(deployment_name || true) if [ -n "$deployment" ]; then if ! restart_kwatch; then ui_error "❌ Configuration failed validation; the previous configuration can be restored." restore_backup_after_failure \ "The workload did not become ready after this configuration change." || true die "configuration update failed" fi else ui_info "ℹ️ Configuration saved; no kwatch Deployment is currently running." ui_info "🛠️ Run the manager again to install or repair the workload." fi flow_notice success "✅ Updated $path." done } configure_alert_flow() { local backup deployment had_backup=false rc LICENSE_COMMUNITY_CACHE="" require_provider_catalog # No confirmation here: reaching this flow is already a deliberate choice, and # nothing is written until the save step. Every prompt below accepts "back". PROVIDER_EDIT_INTENT=true ui_detail "Nothing is saved until the end; type back at any prompt to leave." ui_busy "Reading the configuration Secret" adopt_existing_config_secret ui_busy_done ensure_crd ui_busy "Reading the configuration resource" preflight_config_resource ensure_config_resource ui_busy_done ui_busy "Preserving existing settings" migrate_legacy_telemetry || die "could not preserve the existing telemetry setting" ui_busy_done ui_busy "Checking permissions" preflight_alert_access ui_busy_done backup=$(mktemp) trap 'if [ -n "${backup:-}" ]; then rm -f "$backup"; fi' RETURN if kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ -o yaml >"$backup" 2>/dev/null; then had_backup=true fi if write_config_secret; then : else rc=$? if [ "$rc" -eq 2 ]; then ui_info "↩️ Provider configuration cancelled; no changes were saved." MENU_READ_ONLY=true return 0 fi die "could not save the notification configuration" fi deployment=$(deployment_name) if [ -n "$deployment" ] && ! restart_kwatch; then if [ "$had_backup" = true ]; then ui_warn "⚠️ Notification rollout failed; the previous configuration can be restored." if confirm_repair \ "restore the previous notification configuration" \ "The saved Secret will be applied and kwatch will be restarted."; then kubectl apply -f "$backup" >/dev/null 2>&1 || true restart_kwatch || true else ui_warn "⚠️ Notification restore skipped; the current Secret was left in place." fi else ui_warn \ "⚠️ No previous Secret existed; the new configuration was preserved" \ "so it can be corrected without re-entering every value." fi die "could not restart kwatch after changing notification providers" fi ui_success "✅ Notification providers updated." if ui_interactive && confirm_action "Send a test alert now" "y"; then testalert_flow fi } configure_after_install() { local choice choice=$(ask_yes_no \ "🛠️ Configure additional kwatch settings now? This includes all catalog settings" \ "y") [ "$choice" = true ] && configure_flow # Every install path ends here, so this is the last thing an install asks. license_install_prompt dashboard_after_install return 0 } # With Pro and a kwatch that has the dashboard, offer it right after the # install, so a new user gets a working link without finding the menu. dashboard_after_install() { [ -t 0 ] || return 0 dash_version_ok || return 0 LICENSE_COMMUNITY_CACHE="" if license_is_community; then ui_info "📊 kwatch has a web dashboard for your team. It's part of Pro, free during early access: get a key at https://app.kwatch.dev, add it under \"License / Pro\", then open \"Dashboard\" in the menu." return 0 fi dash_state [ "$DASH_STATE" = off ] || return 0 [ "$(ask_yes_no "📊 Turn on the dashboard and share it with your team now?" "y")" = true ] || return 0 dash_share_flow return 0 } ensure_runtime_config_secret() { local encoded tmp adopt_existing_config_secret encoded=$(secret_data_base64 "$CONFIG_SECRET_NAME" config.yaml) if [ -n "$encoded" ]; then tmp=$(mktemp) if decode_base64_file "$encoded" "$tmp" && [ -s "$tmp" ]; then rm -f "$tmp" return 0 fi rm -f "$tmp" die "existing configuration Secret contains invalid config.yaml data" fi ui_warn "⚠️ This installation does not yet have a Secret-backed configuration." confirm_action \ "Create it now and migrate any legacy provider configuration" "y" || die "upgrade cancelled before changing the workload" write_config_secret } preflight_alert_access() { check_access get deployments namespace check_access patch deployments namespace check_access get secrets namespace check_access create secrets namespace check_access patch secrets namespace } NAMESPACE="${KWATCH_NAMESPACE:-$DEFAULT_NAMESPACE}" RELEASE="${KWATCH_RELEASE:-$DEFAULT_RELEASE}" NAMESPACE_CREATED=false # Set by the legacy replacement once it removed a legacy kwatch from # NAMESPACE: the namespace is kwatch's own, not a shared one, even though # no kwatch workload is left in it for the label guard to find. LEGACY_REPLACED=false CONFIG_SECRET_NAME="${RELEASE}-config" STATE_CONFIGMAP_NAME="${RELEASE}-manager-state" # The Pod sizing is recorded twice: on the Deployment, where it is visible in a # plain `kubectl get deploy -o yaml`, and in this ConfigMap, which outlives the # Deployment. Uninstalling with configuration kept and then reinstalling would # otherwise come back on the release defaults, which is not what "keep # configuration" promises. A full purge removes both. DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME="${RELEASE}-deployment-overrides" LEGACY_OVERRIDES_CONFIGMAP_NAME="${RELEASE}-pod-overrides" CATALOG_CACHE_NAME="${RELEASE}-config-catalog" FEATURE_CATALOG_CACHE_NAME="${RELEASE}-feature-catalog" PROVIDER_CATALOG_CACHE_NAME="${RELEASE}-provider-catalog" valid_kubernetes_name "$NAMESPACE" || die "invalid namespace: $NAMESPACE (maximum 40 characters)" valid_kubernetes_name "$RELEASE" || die "invalid release name: $RELEASE (maximum 40 characters)" record_state() { local phase="$1" version="${2:-}" message="${3:-}" kubectl -n "$NAMESPACE" create configmap "$STATE_CONFIGMAP_NAME" \ --from-literal=phase="$phase" \ --from-literal=version="$version" \ --from-literal=context="$SELECTED_CONTEXT" \ --from-literal=message="$message" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null 2>&1 || true kubectl -n "$NAMESPACE" label configmap "$STATE_CONFIGMAP_NAME" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true } previous_state() { kubectl -n "$NAMESPACE" get configmap "$STATE_CONFIGMAP_NAME" \ -o jsonpath='{.data.phase}' 2>/dev/null || true } confirm_resume() { local phase choice phase=$(previous_state) [ -n "$phase" ] && [ "$phase" != complete ] || return 0 [ -t 0 ] || die "a previous kwatch operation is '$phase'; an interactive terminal is required to resume safely" ui_warn "⚠️ A previous kwatch operation stopped during: $phase" choice=$(ask_yes_no "Continue and resume the operation" "y") [ "$choice" = true ] || die "operation cancelled; no changes were made" } # Answers one access question, from the session cache when it is already there. # Prints allowed, denied or unknown; never exits, so it is safe to run several # at once in the background. # # An access preflight is 28 SelfSubjectAccessReviews. Asked one at a time # against a remote cluster that is about eleven seconds before a flow can draw # anything, and the answers cannot change while the manager runs -- so they are # asked together and remembered for the session. resolve_access() { local verb="$1" resource="$2" scope="${3:-}" result file="" if [ -n "$SESSION_CACHE_DIR" ]; then file="$SESSION_CACHE_DIR/access-$verb-$resource-${scope:-cluster}" if [ -f "$file" ]; then cat "$file" return 0 fi fi # A scope of "namespace" means the managed namespace; "ns:" asks # about another one, such as kube-system. local target_namespace="$NAMESPACE" case "$scope" in ns:*) target_namespace="${scope#ns:}" ;; esac if [ -n "$scope" ]; then result=$(kubectl auth can-i "$verb" "$resource" --namespace "$target_namespace" 2>/dev/null || true) else result=$(kubectl auth can-i "$verb" "$resource" 2>/dev/null || true) fi # The kubectl wrapper keeps stderr with command output for retry diagnostics, # so an authorization warning may surround the actual yes/no answer. Extract # an exact answer instead of treating the warning text as the result. result=$(printf '%s\n' "$result" | awk ' { line=tolower($0); gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) } line == "yes" { answer="yes" } line == "no" { answer="no" } END { print answer } ') case "$result" in yes|yes\ *) [ -n "$file" ] && printf 'allowed' >"$file" 2>/dev/null printf 'allowed' ;; no|no\ *) [ -n "$file" ] && printf 'denied' >"$file" 2>/dev/null printf 'denied' ;; *) # Some Kubernetes distributions reject a preflight SelfSubjectAccessReview # for cluster resources even though the real operation is allowed. Do not # turn that discovery limitation into a noisy false warning; the command # that follows remains the authoritative check. Not remembered either: # the next answer may be conclusive. printf 'unknown' ;; esac return 0 } check_access() { local verb="$1" resource="$2" scope="${3:-}" where="" case "$scope" in "") ;; ns:*) where=" in namespace ${scope#ns:}" ;; *) where=" in namespace $NAMESPACE" ;; esac if [ "$(resolve_access "$verb" "$resource" "$scope")" = denied ]; then die "missing Kubernetes permission: $verb $resource$where" fi return 0 } preflight_access() { local mode="${1:-manage}" check_access get deployments namespace check_access get secrets namespace check_access get pods namespace check_access get roles namespace check_access get rolebindings namespace check_access get serviceaccounts namespace check_access get configmaps namespace check_access get customresourcedefinitions check_access patch namespaces check_access get persistentvolumeclaims namespace if [ "$mode" = install ]; then check_access create namespaces check_access create deployments namespace check_access patch deployments namespace check_access create persistentvolumeclaims namespace check_access create secrets namespace check_access patch secrets namespace check_access create roles namespace check_access patch roles namespace check_access create rolebindings namespace check_access patch rolebindings namespace check_access create serviceaccounts namespace check_access patch serviceaccounts namespace check_access create configmaps namespace check_access patch configmaps namespace check_access create customresourcedefinitions check_access patch customresourcedefinitions check_access create clusterroles check_access patch clusterroles check_access create clusterrolebindings check_access patch clusterrolebindings else check_access patch deployments namespace check_access patch secrets namespace check_access patch customresourcedefinitions if [ "$mode" = upgrade ]; then check_access create deployments namespace check_access create persistentvolumeclaims namespace check_access create secrets namespace check_access create configmaps namespace check_access create roles namespace check_access patch roles namespace check_access create rolebindings namespace check_access patch rolebindings namespace check_access create serviceaccounts namespace check_access patch serviceaccounts namespace check_access create clusterroles check_access patch clusterroles check_access create clusterrolebindings check_access patch clusterrolebindings fi fi } preflight_config_resource() { check_access get kwatchconfigs namespace if kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" >/dev/null 2>&1; then check_access patch kwatchconfigs namespace else check_access create kwatchconfigs namespace fi } latest_version() { local response version response=$(with_loading "Checking stable release" curl -fsSL --location \ --retry 3 --retry-delay 2 --connect-timeout 10 "$RELEASES_URL") || return 1 version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 || true) valid_release_version "$version" || return 1 printf '%s' "$version" } latest_release_candidate() { local response tag response=$(with_loading "Checking release candidates" curl -fsSL --location \ --retry 3 --retry-delay 2 --connect-timeout 10 "$RELEASES_LIST_URL") || return 1 while IFS= read -r tag; do if [[ "$tag" == *-rc.* ]] && valid_release_version "$tag"; then printf '%s' "$tag" return 0 fi done < <( printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' ) return 1 } have_command() { type -P "$1" >/dev/null 2>&1; } # Prints the SHA-256 of a file with whichever tool the system has (GNU # coreutils or the macOS/BSD shasum). sha256_of_file() { local file="$1" out if have_command sha256sum; then out=$(sha256sum "$file") || return 1 elif have_command shasum; then out=$(shasum -a 256 "$file") || return 1 else return 1 fi printf '%s' "${out%% *}" } # Checks a file downloaded from a release tag against the release's signed # SHA256SUMS (" " lines for deploy/deploy.yaml and # deploy/crd.yaml). Returns 0 when the file is verified, or when the release # predates SHA256SUMS and the operator accepts that. Returns 1 with # LAST_COMMAND_ERROR set on a mismatch, a missing entry, a failed signature # or when the checksums cannot be fetched: those are never skipped. A release # without SHA256SUMS fails when non-interactive unless KWATCH_ALLOW_UNVERIFIED=1. verify_release_file() { local version="$1" repo_path="$2" file="$3" local sums bundle code expected actual sums=$(mktemp) || return 1 bundle=$(mktemp) || { rm -f "$sums"; return 1; } code=$(curl -sSL --retry 3 --retry-delay 2 --connect-timeout 10 \ -o "$sums" -w '%{http_code}' \ "$RELEASE_DOWNLOAD_URL/$version/SHA256SUMS" 2>/dev/null) || code=000 case "$code" in 200) ;; 404) rm -f "$sums" "$bundle" ui_warn "⚠️ $version publishes no SHA256SUMS, so $repo_path cannot be verified." ui_detail " Releases from v1.0.0-rc.20 on are checksummed; older ones are not." if [ -t 0 ]; then if ! confirm_action "Continue without verification" "n"; then LAST_COMMAND_ERROR="download of $repo_path for $version was not verified and you declined to continue" return 1 fi elif [ "${KWATCH_ALLOW_UNVERIFIED:-}" != "1" ]; then LAST_COMMAND_ERROR="$version publishes no SHA256SUMS and this run is non-interactive, so $repo_path was not applied. Set KWATCH_ALLOW_UNVERIFIED=1 to install without verification." return 1 fi return 0 ;; *) rm -f "$sums" "$bundle" LAST_COMMAND_ERROR="could not fetch SHA256SUMS for $version (HTTP $code); refusing to apply an unverified $repo_path" return 1 ;; esac expected=$(awk -v path="$repo_path" \ '$2 == path || $2 == "*" path { print $1; exit }' "$sums") if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then rm -f "$sums" "$bundle" LAST_COMMAND_ERROR="SHA256SUMS for $version has no valid entry for $repo_path" return 1 fi if have_command cosign; then code=$(curl -sSL --retry 3 --retry-delay 2 --connect-timeout 10 \ -o "$bundle" -w '%{http_code}' \ "$RELEASE_DOWNLOAD_URL/$version/SHA256SUMS.sigstore.json" \ 2>/dev/null) || code=000 if [ "$code" = 200 ]; then if ! cosign verify-blob --bundle "$bundle" \ --certificate-oidc-issuer=https://token.actions.githubusercontent.com \ --certificate-identity-regexp='^https://github.com/abahmed/kwatch/.github/workflows/publish.yml@' \ "$sums" >/dev/null 2>&1; then rm -f "$sums" "$bundle" LAST_COMMAND_ERROR="the cosign signature on SHA256SUMS for $version did not verify" return 1 fi else ui_warn "⚠️ No signature was published for SHA256SUMS of $version; checking SHA-256 only." fi else ui_detail "ℹ️ cosign is not installed, so the SHA256SUMS signature was not checked (SHA-256 only)." fi rm -f "$sums" "$bundle" actual=$(sha256_of_file "$file") || { LAST_COMMAND_ERROR="sha256sum or shasum is required to verify $repo_path" return 1 } if [ "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" != \ "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" ]; then LAST_COMMAND_ERROR="checksum mismatch for $repo_path at $version: expected $expected, got $actual. Nothing was applied." return 1 fi ui_detail "🔐 Verified $repo_path ($version) against SHA256SUMS." return 0 } release_catalog_available() { local version="$1" file for file in config-catalog.tsv feature-catalog.tsv provider-catalog.tsv; do curl -fsSL --location --retry 2 --retry-delay 1 --connect-timeout 8 \ -o /dev/null "$BASE_URL/$version/deploy/$file" 2>/dev/null || return 1 done } select_release_version() { local stable preview choice stable=$(latest_version) || return 1 preview=$(latest_release_candidate || true) # A release candidate older than the stable release (left over once the # stable one ships) is not a choice worth offering. if [ -n "$preview" ] && [ "$(compare_release_versions "$preview" "$stable")" != 1 ]; then preview="" fi # Until the first stable 1.x, "latest" on GitHub is a 0.x release this # manager cannot run. Recommending it would only lead to a refusal. if version_is_legacy "$stable"; then if [ -z "$preview" ]; then ui_warn "⚠️ The latest stable release ($stable) predates v1.0.0 and cannot be managed by this manager." return 1 fi ui_info "ℹ️ The latest stable release ($stable) predates v1.0.0; the current release candidate is $preview." confirm_action "Use release candidate $preview" "y" || return 1 printf '%s' "$preview" return 0 fi if [ -z "$preview" ]; then printf '%s' "$stable" return 0 fi if ! release_catalog_available "$stable" && release_catalog_available "$preview"; then ui_warn \ "⚠️ Stable $stable has no release catalogs; catalog-ready RC $preview is available." confirm_action \ "Use release candidate $preview for this operation" "n" || return 1 printf '%s' "$preview" return 0 fi ui_info "📦 Available kwatch releases:" choice=$(ui_select 0 \ "✅ Stable ($stable) ${UI_DIM}[recommended]${UI_RESET}" \ "🧪 Release candidate ($preview)") || return 1 case "$choice" in 0) printf '%s' "$stable" ;; *) printf '%s' "$preview" ;; esac } # Discovering the Deployment costs up to five API calls, and it is asked for by # the status screen, the menus, the diagnostics and every health check. Resolve # it once per action and invalidate whenever the workload itself changes. # The cache lives in a file, not a variable: almost every caller asks for the # name inside a command substitution, and a variable set there would be lost # with the subshell. deployment_name() { local file="" name [ -n "$SESSION_CACHE_DIR" ] && file="$SESSION_CACHE_DIR/deployment-name" if [ -n "$file" ] && [ -f "$file" ]; then cat "$file" return 0 fi name=$(deployment_name_uncached || true) if [ -n "$file" ]; then printf '%s' "$name" >"$file" 2>/dev/null || true fi printf '%s' "$name" } # Everything derived from the workload is invalidated together: they all go # stale for the same reasons. invalidate_deployment_name() { [ -n "$SESSION_CACHE_DIR" ] || return 0 rm -f "$SESSION_CACHE_DIR/deployment-name" \ "$SESSION_CACHE_DIR/workload-selector" 2>/dev/null || true return 0 } deployment_name_uncached() { local name app template_app service_account name=$(kubectl -n "$NAMESPACE" get deployment \ -l "app.kubernetes.io/instance=$RELEASE,app.kubernetes.io/managed-by=kwatch.sh" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [ -n "$name" ]; then printf '%s' "$name" return fi name=$(kubectl -n "$NAMESPACE" get deployment \ -l 'app=kwatch,app.kubernetes.io/managed-by=kwatch.sh' \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [ -n "$name" ]; then printf '%s' "$name" return fi # Older releases did not add the managed-by label. The release name is the # manager's ownership boundary, so discover that exact Deployment as a # compatibility fallback and adopt its existing configuration Secret. app=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o 'jsonpath={.metadata.labels.app}' 2>/dev/null || true) [ "$app" = kwatch ] && { kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o jsonpath='{.metadata.name}' 2>/dev/null || true return 0 } # Some legacy manifests put the app label only on the Pod template and # leave Deployment metadata unlabeled. The exact release name plus this # template identity is sufficient to recognize kwatch safely. template_app=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o 'jsonpath={.spec.template.metadata.labels.app}' 2>/dev/null || true) service_account=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o 'jsonpath={.spec.template.spec.serviceAccountName}' 2>/dev/null || true) if [ "$template_app" = kwatch ] || [ "$service_account" = "$RELEASE" ]; then kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o jsonpath='{.metadata.name}' 2>/dev/null || true fi } lease_name_for_deployment() { local deployment="$1" lease lease=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.containers[0].env[?(@.name=="KWATCH_LEADER_ELECTION_NAME")].value}' \ 2>/dev/null || true) [ -n "$lease" ] || lease="${RELEASE}-leader" printf '%s' "$lease" } rollout_timeout_seconds() { local value case "$ROLLOUT_TIMEOUT" in (*s) value="${ROLLOUT_TIMEOUT%s}" [[ "$value" =~ ^[0-9]+$ ]] || { printf '%s' 300; return; } printf '%s' "$value" ;; (*m) value="${ROLLOUT_TIMEOUT%m}" [[ "$value" =~ ^[0-9]+$ ]] || { printf '%s' 300; return; } printf '%s' "$((10#$value * 60))" ;; (*h) value="${ROLLOUT_TIMEOUT%h}" [[ "$value" =~ ^[0-9]+$ ]] || { printf '%s' 300; return; } printf '%s' "$((10#$value * 3600))" ;; (*) printf '%s' 300 ;; esac } # A Pod being Running and 1/1 only describes that Pod. Wait for Kubernetes to # finish the current Deployment revision, then separately confirm that Kwatch # has elected a ready leader. wait_for_kwatch_rollout() { local deployment="$1" deadline timeout_seconds local lease holder holder_ready phase timeout_seconds=$(rollout_timeout_seconds) lease=$(lease_name_for_deployment "$deployment") if ! kubectl -n "$NAMESPACE" rollout status "deployment/$deployment" \ --timeout="${timeout_seconds}s" >/dev/null; then LAST_COMMAND_ERROR="Deployment rollout did not complete within ${timeout_seconds}s." phase=$(claim_phase) if [ -n "$phase" ] && [ "$phase" != Bound ]; then LAST_COMMAND_ERROR="$LAST_COMMAND_ERROR State claim ${RELEASE}-data is $phase: $(claim_warning)" fi ui_error "❌ Kwatch Deployment rollout did not complete." kubectl -n "$NAMESPACE" get deployment "$deployment" -o yaml >&2 || true kubectl -n "$NAMESPACE" get pods -l "$(workload_selector)" -o wide >&2 || true kubectl -n "$NAMESPACE" get lease "$lease" -o yaml >&2 || true return 1 fi # Leader election starts after the new Pods are available. Give it a fresh, # bounded window instead of reusing time consumed by the Deployment rollout. deadline=$((SECONDS + timeout_seconds)) while (( SECONDS < deadline )); do holder=$(kubectl -n "$NAMESPACE" get lease "$lease" \ -o jsonpath='{.spec.holderIdentity}' 2>/dev/null || true) holder_ready="" if [ -n "$holder" ]; then holder_ready=$(kubectl -n "$NAMESPACE" get pod "$holder" \ -o 'jsonpath={range .status.conditions[?(@.type=="Ready")]}{.status}{end}' \ 2>/dev/null || true) fi if [ -n "$holder" ] && [ "$holder_ready" = True ]; then return 0 fi sleep 2 done LAST_COMMAND_ERROR="Deployment rolled out, but its Lease holder is not ready." ui_error "❌ Kwatch did not complete its leader-aware rollout." kubectl -n "$NAMESPACE" get deployment "$deployment" -o yaml >&2 || true kubectl -n "$NAMESPACE" get pods -l "$(workload_selector)" -o wide >&2 || true kubectl -n "$NAMESPACE" get lease "$lease" -o yaml >&2 || true return 1 } adopt_existing_config_secret() { local deployment secret_name deployment=$(deployment_name || true) [ -n "$deployment" ] || return 0 secret_name=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.volumes[?(@.name=="config-volume")].secret.secretName}' \ 2>/dev/null || true) if [ -n "$secret_name" ]; then CONFIG_SECRET_NAME="$secret_name" ui_info "🔐 Using existing configuration Secret: $CONFIG_SECRET_NAME" fi } restart_kwatch() { local deployment deployment=$(deployment_name || true) if [ -z "$deployment" ]; then ui_error "❌ kwatch Deployment was not found; configuration was not activated." return 1 fi with_loading "Restarting kwatch" kubectl -n "$NAMESPACE" \ rollout restart "deployment/$deployment" >/dev/null || return 1 with_loading "Waiting for kwatch rollout" \ wait_for_kwatch_rollout "$deployment" || return 1 } installed_version() { local image tag deployment image=$(kubectl -n "$NAMESPACE" get deployment \ -l "app.kubernetes.io/instance=$RELEASE,app.kubernetes.io/managed-by=kwatch.sh" \ -o "jsonpath={.items[0].spec.template.spec.containers[?(@.name==\"$RELEASE\")].image}" \ 2>/dev/null || true) if [ -z "$image" ]; then image=$(kubectl -n "$NAMESPACE" get deployment \ -l 'app=kwatch,app.kubernetes.io/managed-by=kwatch.sh' \ -o "jsonpath={.items[0].spec.template.spec.containers[?(@.name==\"$RELEASE\")].image}" \ 2>/dev/null || true) fi if [ -z "$image" ]; then # Pre-catalog releases did not carry the manager labels. Reuse the same # app-labelled compatibility lookup used for upgrades and cleanup. deployment=$(deployment_name || true) if [ -n "$deployment" ]; then image=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.spec.template.spec.containers[?(@.name==\"$RELEASE\")].image}" \ 2>/dev/null || true) fi fi if [ -z "$image" ]; then # Installs made before the container followed the release name still # call it "kwatch"; the Deployment has one container either way. deployment=$(deployment_name || true) if [ -n "$deployment" ]; then image=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.containers[0].image}' \ 2>/dev/null || true) fi fi image="${image%%@*}" tag="${image##*:}" valid_release_version "$tag" && printf '%s' "$tag" } version_is_legacy() { local version="$1" major major="${version#v}" major="${major%%.*}" [ "$major" = 0 ] } deployment_is_running() { local deployment="$1" available available=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.status.availableReplicas}' 2>/dev/null || true) [[ "$available" =~ ^[1-9][0-9]*$ ]] } assess_installation() { INSTALL_STATE=absent HEADER_HEALTH=unknown INSTALL_DEPLOYMENT="" INSTALL_VERSION="" INSTALL_REASON="" INSTALL_DEPLOYMENT=$(deployment_name || true) if [ -z "$INSTALL_DEPLOYMENT" ]; then INSTALL_REASON="no kwatch Deployment was found" HEADER_HEALTH=absent return 0 fi INSTALL_VERSION=$(installed_version || true) if [ -z "$INSTALL_VERSION" ]; then INSTALL_STATE=broken INSTALL_REASON="the Deployment image version could not be determined" return 0 fi if version_is_legacy "$INSTALL_VERSION"; then INSTALL_STATE=legacy return 0 fi if ! deployment_is_running "$INSTALL_DEPLOYMENT"; then INSTALL_STATE=broken INSTALL_REASON="the Deployment has no available replicas" return 0 fi INSTALL_STATE=supported HEADER_HEALTH=healthy } stale_resources_present() { kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ >/dev/null 2>&1 && return 0 kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ >/dev/null 2>&1 && return 0 # A state claim left by an uninstall is reused by the next install, so the # incident history survives; worth saying before that install starts. kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ >/dev/null 2>&1 && return 0 kubectl -n "$NAMESPACE" get configmap "$RELEASE" \ >/dev/null 2>&1 } confirm_config_secret_replacement() { local owner if ! kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ >/dev/null 2>&1; then return 0 fi owner=$(kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}' \ 2>/dev/null || true) [ "$owner" = kwatch.sh ] && return 0 ui_warn "⚠️ An unowned Secret named '$CONFIG_SECRET_NAME' already exists." confirm_action \ "Replace this Secret with the new kwatch configuration" "n" || die "installation cancelled; the existing Secret was preserved" } maybe_load_catalog() { local version="${1:-}" CATALOG=() FEATURE_CATALOG=() PROVIDER_CATALOG=() CATALOG_SOURCE="unavailable" FEATURE_CATALOG_SOURCE="unavailable" PROVIDER_CATALOG_SOURCE="unavailable" if [ -z "$version" ]; then version=$(installed_version || true) fi if [ -z "$version" ]; then version=$(latest_version || true) fi [ -n "$version" ] || { ui_error "❌ Could not determine the kwatch release for catalog loading." return 1 } if load_catalog_bundle "$version" false; then return 0 fi ui_error "❌ No usable catalogs were found for $version." return 1 } # Ask once. Retrying here cannot help: the picker offers the same two releases # every time, so a legacy-only choice repeated the warning for ever -- and once # input ran out, `die` inside the command substitution ended only that subshell # while this loop kept calling GitHub. select_modern_release_version() { local version version=$(select_release_version) || return 1 if version_is_legacy "$version"; then ui_warn "⚠️ $version is older than v1.0.0 and cannot be managed by this manager." return 1 fi printf '%s' "$version" } compare_release_versions() { local left="${1#v}" right="${2#v}" local left_base right_base left_rc right_rc local left_major left_minor left_patch right_major right_minor right_patch local pair left_part right_part left_base="${left%%-rc.*}" right_base="${right%%-rc.*}" IFS=. read -r left_major left_minor left_patch <<<"$left_base" IFS=. read -r right_major right_minor right_patch <<<"$right_base" for pair in \ "$left_major:$right_major" "$left_minor:$right_minor" \ "$left_patch:$right_patch"; do left_part="${pair%%:*}" right_part="${pair#*:}" if [ "$left_part" -gt "$right_part" ]; then printf '1' return 0 fi if [ "$left_part" -lt "$right_part" ]; then printf '%s' '-1' return 0 fi done if [[ "$left" == *-rc.* ]] && [[ "$right" != *-rc.* ]]; then printf '%s' '-1' return 0 fi if [[ "$left" != *-rc.* ]] && [[ "$right" == *-rc.* ]]; then printf '1' return 0 fi if [[ "$left" == *-rc.* ]]; then left_rc="${left#*-rc.}" right_rc="${right#*-rc.}" else left_rc=0 right_rc=0 fi if [ "$left_rc" -gt "$right_rc" ]; then printf '1' elif [ "$left_rc" -lt "$right_rc" ]; then printf '%s' '-1' else printf '0' fi } # A repair re-applies the release that is already installed, so the equal case is # accepted when the caller asks for it. Without that, a broken installation on # the newest release had no way forward: every offered release compares as "not # newer", and the prompt repeated for ever. # Compare before prompting. The channel question used to come first, so an # installation already on the newest release was asked to pick a channel, told # to confirm the release candidate, and only then informed there was nothing to # upgrade to. select_upgrade_version() { local current="$1" allow_same="${2:-false}" stable preview version comparison local choice label local -a versions=() labels=() stable=$(latest_version || true) preview=$(latest_release_candidate || true) for version in "$stable" "$preview"; do [ -n "$version" ] || continue version_is_legacy "$version" && continue case " ${versions[*]-} " in *" $version "*) continue ;; esac comparison=$(compare_release_versions "$version" "$current") case "$comparison" in 1) label="newer" ;; 0) [ "$allow_same" = true ] || continue label="reinstall the running release" ;; *) continue ;; esac versions+=("$version") if [ "$version" = "$preview" ]; then labels+=("🧪 $version ${UI_DIM}release candidate · $label${UI_RESET}") else labels+=("✅ $version ${UI_DIM}stable · $label${UI_RESET}") fi done if [ "${#versions[@]}" -eq 0 ]; then ui_success "✅ kwatch $current is the newest available release; nothing to upgrade." return 1 fi if [ "${#versions[@]}" -eq 1 ]; then printf '%s' "${versions[0]}" return 0 fi ui_info "📦 Releases newer than $current:" choice=$(ui_select 0 "${labels[@]}") || return 2 printf '%s' "${versions[$choice]}" } # Three catalogs load before most screens. Announcing each one cost six lines # every time a configuration screen was opened, and said the same thing each # time. Report only what the operator cannot infer: a failure, or a catalog # served from cache rather than the release. load_catalog_bundle() { local version="$1" report="${2:-true}" ok=true if load_catalog_for_version "$version"; then case "$CATALOG_SOURCE" in release:*) ;; *) ui_detail "📚 Configuration catalog served from $CATALOG_SOURCE." ;; esac else [ "$report" = true ] && ui_error "❌ Configuration catalog unavailable for $version." ok=false fi if load_feature_catalog_for_version "$version"; then case "$FEATURE_CATALOG_SOURCE" in release:*) ;; *) ui_detail "🧩 Feature catalog served from $FEATURE_CATALOG_SOURCE." ;; esac else [ "$report" = true ] && ui_error "❌ Feature catalog unavailable for $version." ok=false fi if load_provider_catalog_for_version "$version"; then case "$PROVIDER_CATALOG_SOURCE" in release:*) ;; *) ui_detail "🔌 Provider catalog served from $PROVIDER_CATALOG_SOURCE." ;; esac else [ "$report" = true ] && ui_error "❌ Provider catalog unavailable for $version." ok=false fi [ "$ok" = true ] } require_config_catalog() { [ "${#CATALOG[@]}" -gt 0 ] || die "configuration catalog is unavailable; retry while the release artifact is reachable" } require_provider_catalog() { [ "${#PROVIDER_CATALOG[@]}" -gt 0 ] || die "provider catalog is unavailable; retry while the release artifact is reachable" } # Seventy-odd capabilities printed at once is a wall, not a screen. They are # already namespaced by area, so browse by area and print one area at a time. feature_areas() { printf '%s\n' "${FEATURE_CATALOG[@]}" | cut -d'|' -f1 | cut -d. -f1 | sort -u } show_feature_area() { local area="$1" entry id lifecycle description dependencies tier line local shown=0 pro_shown=0 for entry in ${FEATURE_CATALOG[@]+"${FEATURE_CATALOG[@]}"}; do IFS='|' read -r id lifecycle description dependencies tier <<<"$entry" case "$area" in "") ;; *) case "$id" in "$area".*|"$area") ;; *) continue ;; esac ;; esac if [ -n "$dependencies" ]; then printf -v line '%-42s %-7s %s (needs: %s)' \ "$id" "$lifecycle" "$description" "$dependencies" else printf -v line '%-42s %-7s %s' "$id" "$lifecycle" "$description" fi if [ "$tier" = pro ]; then line="$line $(pro_badge)${UI_DIM}" pro_shown=$((pro_shown + 1)) fi ui_detail " $line" shown=$((shown + 1)) done printf '\n' >&2 ui_detail " $shown capabilities" if [ "$pro_shown" -gt 0 ]; then ui_detail " PRO features need a license — kwatch Pro is free during early access: https://kwatch.dev/signup?src=installer" fi } features_flow() { local area choice count local -a areas=() labels=() [ "${#FEATURE_CATALOG[@]}" -gt 0 ] || die "feature catalog is unavailable; retry while the release artifact is reachable" while IFS= read -r area; do [ -n "$area" ] && areas+=("$area") done < <(feature_areas) while true; do ui_screen "capabilities" labels=() for area in "${areas[@]}"; do count=$(printf '%s\n' "${FEATURE_CATALOG[@]}" | cut -d'|' -f1 | grep -c "^$area\." || true) labels+=("🧩 $area ${UI_DIM}$count${UI_RESET}") done labels+=("📋 Everything" "↩️ Back") choice=$(ui_select 0 "${labels[@]}") || return 0 if [ "$choice" -lt "${#areas[@]}" ]; then ui_screen "capabilities · ${areas[$choice]}" show_feature_area "${areas[$choice]}" ui_pause elif [ "$choice" -eq "${#areas[@]}" ]; then ui_screen "capabilities · all" show_feature_area "" ui_pause else return 0 fi done } provider_available() { local entry provider display field type required secret validation default local description group condition for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" case "${CONFIGURED_PROVIDERS:-|}" in *"|$provider|"*) continue ;; esac return 0 done return 1 } # Defined at top level: nesting it meant the helper was redefined on every call # and leaked into the global scope anyway. # Scope is "namespace" (the managed one), "ns:" (another namespace, # such as kube-system for the control-plane Lease Role) or "cluster". delete_owned() { local scope="$1" kind="$2" name="$3" owner app service_account local ns="$NAMESPACE" case "$scope" in ns:*) ns="${scope#ns:}"; scope=namespace ;; esac if [ "$scope" = namespace ]; then owner=$(kubectl -n "$ns" get "$kind" "$name" \ -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}' \ 2>/dev/null || true) else owner=$(kubectl get "$kind" "$name" \ -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}' \ 2>/dev/null || true) fi if [ "$owner" != kwatch.sh ] && [ "$kind" = deployment ] && [ "$name" = "$RELEASE" ]; then app=$(kubectl -n "$NAMESPACE" get deployment "$name" \ -o 'jsonpath={.metadata.labels.app}' 2>/dev/null || true) if [ "$app" = kwatch ]; then owner=kwatch.sh else app=$(kubectl -n "$NAMESPACE" get deployment "$name" \ -o 'jsonpath={.spec.template.metadata.labels.app}' \ 2>/dev/null || true) service_account=$(kubectl -n "$NAMESPACE" get deployment "$name" \ -o 'jsonpath={.spec.template.spec.serviceAccountName}' \ 2>/dev/null || true) if [ "$app" = kwatch ] || [ "$service_account" = "$RELEASE" ]; then owner=kwatch.sh fi fi fi [ "$owner" = kwatch.sh ] || return 0 if [ "$scope" = namespace ]; then check_access delete "$kind" "ns:$ns" kubectl -n "$ns" delete "$kind" "$name" \ --ignore-not-found >/dev/null else check_access delete "$kind" kubectl delete "$kind" "$name" --ignore-not-found >/dev/null fi } remove_namespaced_workload() { invalidate_deployment_name delete_owned namespace deployment "$RELEASE" delete_owned namespace service "$RELEASE" delete_owned namespace serviceaccount "$RELEASE" delete_owned namespace role "${RELEASE}-leader-election" delete_owned namespace rolebinding "${RELEASE}-leader-election" delete_owned namespace role "${RELEASE}-restart-evidence" delete_owned namespace rolebinding "${RELEASE}-restart-evidence" delete_owned namespace role "${RELEASE}-license" delete_owned namespace rolebinding "${RELEASE}-license" delete_owned ns:kube-system role "${RELEASE}-control-plane-leases" delete_owned ns:kube-system rolebinding "${RELEASE}-control-plane-leases" # Releases before the data volume shipped these. delete_owned namespace poddisruptionbudget "$RELEASE" delete_owned namespace rolebinding "$RELEASE" delete_owned namespace role "${RELEASE}-configmap-manager" delete_owned namespace rolebinding "${RELEASE}-configmap-manager" delete_owned cluster clusterrolebinding "$RELEASE" delete_owned cluster clusterrole "$RELEASE" } remove_legacy_install() { local secret_name="${CONFIG_SECRET_NAME:-${RELEASE}-config}" local secret_owner ui_info "🧹 Uninstalling the legacy kwatch installation." remove_namespaced_workload check_access delete configmaps namespace kubectl -n "$NAMESPACE" delete configmap "$RELEASE" \ --ignore-not-found >/dev/null kubectl -n "$NAMESPACE" delete configmap "$STATE_CONFIGMAP_NAME" \ --ignore-not-found >/dev/null if kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" \ >/dev/null 2>&1; then check_access delete kwatchconfigs namespace kubectl -n "$NAMESPACE" delete kwatchconfig "$RELEASE" \ --ignore-not-found >/dev/null fi if kubectl -n "$NAMESPACE" get secret "$secret_name" \ >/dev/null 2>&1; then secret_owner=$(kubectl -n "$NAMESPACE" get secret "$secret_name" \ -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}' \ 2>/dev/null || true) if [ "$secret_owner" = kwatch.sh ]; then check_access delete secrets namespace kubectl -n "$NAMESPACE" delete secret "$secret_name" \ --ignore-not-found >/dev/null else ui_info "🔐 Preserving unowned Secret '$secret_name'." fi fi ui_success \ "✅ Legacy kwatch workload and configuration removed; the backup was preserved." } rollback_deployment() { local deployment deployment=$(deployment_name) [ -n "$deployment" ] || return 0 kubectl -n "$NAMESPACE" rollout undo "deployment/$deployment" >/dev/null 2>&1 || return 0 wait_for_kwatch_rollout "$deployment" >/dev/null 2>&1 || true } # Fifty-six providers: too many to read, and the old prompt asked for "name, # number, or search" without saying which the input would be treated as. The # selection list filters as you type, so one list replaces the search, the # browse listing, the numeric match and the disambiguation prompt. choose_provider() { local entry provider display field type required secret validation default local description group condition seen="|" choice local -a providers=() displays=() for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" case "${CONFIGURED_PROVIDERS:-|}" in *"|$provider|"*) continue ;; esac case "$seen" in *"|$provider|"*) continue ;; esac providers+=("$provider") displays+=("🔌 $display") seen="${seen}${provider}|" done if [ "${#PROVIDER_CATALOG[@]}" -eq 0 ]; then ui_error "❌ The provider catalog is unavailable; provider selection cannot continue." return 2 fi if [ "${#providers[@]}" -eq 0 ]; then ui_warn "⚠️ Every provider in the catalog is already configured." return 2 fi ui_heading "📣 Where should kwatch send alerts?" displays+=("↩️ Back") choice=$(ui_select 0 "${displays[@]}") || return 2 [ "$choice" -lt "${#providers[@]}" ] || return 2 PROVIDER="${providers[$choice]}" ui_success "✅ Provider selected: ${displays[$choice]#🔌 }" } provider_group_value() { local group="$1" rest rest="${PROVIDER_GROUP_SELECTIONS#*|$group=}" [ "$rest" != "$PROVIDER_GROUP_SELECTIONS" ] || return 1 printf '%s' "${rest%%|*}" } set_provider_group_value() { local group="$1" value="$2" PROVIDER_GROUP_SELECTIONS="${PROVIDER_GROUP_SELECTIONS}${group}=${value}|" } provider_group_present() { local group="$1" case "${PROVIDER_GROUP_PRESENCE:-|}" in *"|$group|"*) return 0 ;; *) return 1 ;; esac } mark_provider_group_present() { local group="$1" [ -n "$group" ] || return 0 provider_group_present "$group" || PROVIDER_GROUP_PRESENCE="${PROVIDER_GROUP_PRESENCE:-|}${group}|" } provider_group_has_later_field() { local target_group="$1" current_field="$2" local entry provider display field type required secret validation default local description group condition seen=false for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" [ "$provider" = "$PROVIDER" ] || continue [ "$group" = "$target_group" ] || continue [ "$condition" = at-least-one ] || continue if [ "$seen" = true ]; then return 0 fi [ "$field" = "$current_field" ] && seen=true done return 1 } select_provider_groups() { local entry provider display field type required secret validation default local description group condition choice value selected local seen="|" local -a groups=() values=() labels=() PROVIDER_GROUP_SELECTIONS="|" for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" [ "$provider" = "$PROVIDER" ] || continue case "$condition" in choice:*) case "$seen" in *"|$group|"*) ;; *) groups+=("$group"); seen="${seen}${group}|" ;; esac ;; esac done [ "${#groups[@]}" -gt 0 ] || return 0 for group in "${groups[@]}"; do values=() for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do IFS='|' read -r provider display field type required secret validation default \ description entry_group condition <<<"$entry" [ "$provider" = "$PROVIDER" ] || continue [ "$entry_group" = "$group" ] || continue case "$condition" in choice:*) values+=("${condition#choice:}|$field|$description") ;; esac done [ "${#values[@]}" -gt 0 ] || continue ui_heading "🔐 Choose $group" labels=() for value in "${values[@]}"; do IFS='|' read -r selected field description <<<"$value" labels+=("🔑 $(printf '%-10s' "$selected") ${UI_DIM}$description${UI_RESET}") done labels+=("↩️ Back") choice=$(ui_select 0 "${labels[@]}") || return 2 [ "$choice" -lt "${#values[@]}" ] || return 2 value="${values[$choice]}" IFS='|' read -r selected field description <<<"$value" set_provider_group_value "$group" "$selected" ui_detail " 🔑 $selected" done } provider_condition_matches() { local group="$1" condition="$2" selected expected expression case "$condition" in choice:*) selected=$(provider_group_value "$group" || true) expected="${condition#choice:}" [ "$selected" = "$expected" ] ;; at-least-one) return 0 ;; required-if:*) expression="${condition#required-if:}" group="${expression%%=*}" expected="${expression#*=}" selected=$(provider_group_value "$group" || true) [ "$selected" = "$expected" ] ;; "") return 0 ;; *) return 1 ;; esac } prompt_provider_value() { local field="$1" type="$2" secret="$3" validation="$4" local default="$5" description="$6" value allowed choice option local default_index=0 count local -a options=() option_labels=() case "$validation" in one-of:*) # A closed set is a choice. Typing one of the values by hand, and being # told off for a typo, is work the list can do instead. allowed="${validation#one-of:}" while IFS= read -r option; do [ -n "$option" ] || continue if [ "$option" = "$default" ]; then default_index="${#options[@]}" option_labels+=("$option ${UI_GREEN}(current)${UI_RESET}") else option_labels+=("$option") fi options+=("$option") # printf '%s\n', not '%s': without the trailing newline `read` returns # non-zero on the last value and the loop body never runs for it, which # silently dropped the final allowed value. done < <(printf '%s\n' "$allowed" | tr ',' '\n') if [ "${#options[@]}" -gt 0 ] && ui_interactive; then ui_info "🎯 $description" count="${#options[@]}" # An empty answer means "leave this field as it is", which the typed # prompt gets from pressing Enter; the list needs to offer it. choice=$(ui_select "$default_index" "${option_labels[@]}" \ "⏭️ Leave unchanged" "↩️ Back") || return 2 if [ "$choice" -lt "$count" ]; then printf '%s' "${options[$choice]}" return 0 fi [ "$choice" -eq "$count" ] || return 2 printf '' return 0 fi ui_info "🎯 Allowed values for $field: ${allowed//,/ · }" ;; esac while true; do if [ "$secret" = true ]; then value=$(ask_secret_or_back "$description") || return 2 else value=$(ask_or_back "$description" "$default") || return 2 fi [ -n "$value" ] || { printf ''; return 0; } case "$type" in boolean) [[ "$value" = true || "$value" = false ]] || { ui_warn "⚠️ $field must be true or false."; continue; } ;; integer) [[ "$value" =~ ^[0-9]+$ ]] || { ui_warn "⚠️ $field must be a non-negative integer."; continue; } ;; json) [[ "$value" = \[* || "$value" = \{* ]] || { ui_warn "⚠️ $field must be a JSON object or array."; continue; } ;; esac case "$validation" in url) [[ "$value" =~ ^https?:// ]] || { ui_warn "⚠️ $field must be an http or https URL."; continue; } ;; signed-integer) [[ "$value" =~ ^-?[0-9]+$ ]] || { ui_warn "⚠️ $field must be a signed integer."; continue; } ;; port) [[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ] || { ui_warn "⚠️ $field must be a port from 1 to 65535."; continue; } ;; integer) [[ "$value" =~ ^[0-9]+$ ]] || { ui_warn "⚠️ $field must be a non-negative integer."; continue; } ;; boolean) [[ "$value" = true || "$value" = false ]] || { ui_warn "⚠️ $field must be true or false."; continue; } ;; json) [[ "$value" = \[* || "$value" = \{* ]] || { ui_warn "⚠️ $field must be a JSON object or array."; continue; } ;; one-of:*) allowed="${validation#one-of:}" case ",$allowed," in *",$value,"*) ;; *) ui_warn "⚠️ $field must be one of: ${allowed//,/, }." continue ;; esac ;; "") ;; *) ui_error "❌ Provider catalog has unknown validation: $validation" return 1 ;; esac printf '%s' "$value" return 0 done } apply_config_secret() { local secret_name="$1" config_tmp="$2" if [ "${#SECRET_ARGS[@]}" -gt 0 ]; then kubectl -n "$NAMESPACE" create secret generic "$secret_name" \ --from-file=config.yaml="$config_tmp" \ "${SECRET_ARGS[@]}" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null else kubectl -n "$NAMESPACE" create secret generic "$secret_name" \ --from-file=config.yaml="$config_tmp" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null fi } write_provider_field() { local entry="$1" mode="$2" configure_optional="$3" priority="$4" local provider display field type required secret validation default description local group condition field_description value field_required field_priority rc IFS='|' read -r provider display field type required secret validation default \ description group condition <<<"$entry" [ "$provider" = "$PROVIDER" ] || return 0 case "$condition" in choice:*) provider_condition_matches "$group" "$condition" || return 0 required=true field_priority=1 ;; required-if:*) provider_condition_matches "$group" "$condition" || return 0 required=true field_priority=2 ;; at-least-one) field_priority=3 ;; *) field_priority=3 ;; esac if [ "$mode" = group ] && [ "$condition" != at-least-one ]; then return 0 fi if [ "$mode" != group ] && [ "$condition" = at-least-one ]; then return 0 fi [ "$field_priority" = "$priority" ] || return 0 field_required="$required" if [ "$condition" = at-least-one ] && ! provider_group_present "$group" && ! provider_group_has_later_field "$group" "$field"; then field_required=true fi if [ "$mode" = required ] && [ "$field_required" != true ]; then return 0 fi if [ "$mode" = optional ] && [ "$field_required" = true ]; then return 0 fi if [ "$mode" = group ] && [ "$field_required" != true ] && provider_group_present "$group"; then return 0 fi if [ "$mode" = optional ] && [ "$configure_optional" = false ]; then if [ "$secret" = true ] && preserve_provider_secret "$config_tmp" "$provider" "$field" "$tmp_dir"; then mark_provider_group_present "$group" elif [ -z "$condition" ] && old_provider_exists "$provider" && preserve_provider_optional "$config_tmp" "$provider" "$field" \ "$type" "$tmp_dir"; then mark_provider_group_present "$group" elif [ -n "$default" ] && [ "$type" != headers ]; then write_provider_value "$config_tmp" "$field" "$type" "$default" fi return 0 fi if [ "$type" = headers ]; then write_webhook_headers "$config_tmp" "$tmp_dir" return 0 fi while true; do field_description="$description" [ "$field_required" = true ] || field_description="$description (optional; Enter to skip)" if value=$(prompt_provider_value "$field" "$type" "$secret" \ "$validation" "$default" "$field_description"); then : else rc=$? return "$rc" fi if [ -z "$value" ]; then if [ "$secret" = true ] && preserve_provider_secret "$config_tmp" "$provider" "$field" "$tmp_dir"; then mark_provider_group_present "$group" break fi if [ "$secret" = true ]; then value=$(old_provider_plain_value "$provider" "$field" || true) if [ -n "$value" ]; then write_provider_secret "$config_tmp" "$field" "$value" "$tmp_dir" mark_provider_group_present "$group" break fi fi if old_provider_exists "$provider" && preserve_provider_optional "$config_tmp" "$provider" "$field" \ "$type" "$tmp_dir"; then mark_provider_group_present "$group" break fi if [ "$field_required" = true ]; then ui_warn "⚠️ $field cannot be empty." continue fi break fi if [ "$secret" = true ]; then write_provider_secret "$config_tmp" "$field" "$value" "$tmp_dir" else write_provider_value "$config_tmp" "$field" "$type" "$value" fi mark_provider_group_present "$group" break done } write_provider_block() { local entry mode configure_optional priority rc WRITTEN_PROVIDER_SECTIONS="|" PROVIDER_GROUP_PRESENCE="|" printf ' %s:\n' "$PROVIDER" >>"$config_tmp" # Authentication and other required fields are collected before optional # presentation settings, regardless of catalog row order. ui_info "🔐 Required settings for $PROVIDER" configure_optional=true for priority in 1 2 3; do for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do write_provider_field "$entry" required "$configure_optional" \ "$priority" || { rc=$?; return "$rc"; } done done # Keep at-least-one destination groups in catalog order. This lets the user # choose the first destination without being forced into the last row. for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do write_provider_field "$entry" group true 3 || { rc=$?; return "$rc"; } done ui_info "⚙️ Optional settings for $PROVIDER" if configure_optional=$(ask_yes_no_or_back \ "Configure optional settings for $PROVIDER too?" "n"); then : else rc=$? return "$rc" fi for priority in 1 2 3; do for entry in ${PROVIDER_CATALOG[@]+"${PROVIDER_CATALOG[@]}"}; do write_provider_field "$entry" optional "$configure_optional" \ "$priority" || { rc=$?; return "$rc"; } done done } choose_provider_config_action() { local choice provider display result rc local -a existing=() while IFS='|' read -r provider display; do [ -n "$provider" ] && existing+=("$display") done < <(existing_provider_names) if [ "${#existing[@]}" -eq 0 ]; then if [ "${PROVIDER_EDIT_INTENT:-false}" = true ]; then # The caller asked for provider editing; asking again answers itself. printf 'edit' return fi if result=$(ask_yes_no_or_back \ "📣 Configure notification providers now?" "y"); then : else rc=$? return "$rc" fi if [ "$result" = true ]; then printf 'edit' else printf 'empty' fi return fi ui_info "📣 Existing providers: ${existing[*]}" while true; do choice=$(ui_select 0 \ "✏️ Edit or add providers" \ "✅ Keep providers unchanged" \ "🗑️ Remove all providers" \ "↩️ Back") || { printf 'back'; return; } case "$choice" in 0) printf 'edit'; return ;; 1) printf 'keep'; return ;; 2) confirm_action "Remove all configured notification providers" "n" || continue printf 'empty' return ;; *) printf 'back'; return ;; esac done } write_config_secret() { local secret_name="${CONFIG_SECRET_NAME:-${RELEASE}-config}" local tmp_dir config_tmp provider_action provider_snapshot rc local add_provider keep_existing local entry path type default category description status replacement value local old_value local encoded legacy_config configure_secrets local -a secret_args_before=() secret_paths=() SECRET_ARGS=() WRITTEN_CONFIG_SECTIONS="|" CONFIGURED_PROVIDERS="|" tmp_dir=$(mktemp -d) config_tmp="$tmp_dir/config.yaml" trap 'if [ -n "${tmp_dir:-}" ]; then rm -rf "$tmp_dir"; fi' RETURN OLD_CONFIG_PATH="$tmp_dir/old-config.yaml" if [ "$FRESH_INSTALL" != true ]; then encoded=$(secret_data_base64 "$CONFIG_SECRET_NAME" config.yaml) if [ -n "$encoded" ]; then if ! decode_base64_file "$encoded" "$OLD_CONFIG_PATH"; then die "existing configuration Secret contains invalid config.yaml data" fi else legacy_config=$(kubectl -n "$NAMESPACE" get configmap "$RELEASE" \ -o 'go-template={{index .data "config.yaml"}}' 2>/dev/null || true) if [ -n "$legacy_config" ] && [ "$legacy_config" != '' ]; then printf '%s\n' "$legacy_config" >"$OLD_CONFIG_PATH" ui_info \ "🔐 Migrating the legacy ConfigMap configuration into a Secret." fi fi fi if provider_action=$(choose_provider_config_action); then : else rc=$? return "$rc" fi case "$provider_action" in edit) printf 'crd:\n enabled: true\nalert:\n' >"$config_tmp" while true; do if choose_provider; then : else rc=$? return "$rc" fi provider_snapshot="$tmp_dir/provider-before.yaml" cp "$config_tmp" "$provider_snapshot" secret_args_before=() if [ "${#SECRET_ARGS[@]}" -gt 0 ]; then secret_args_before=("${SECRET_ARGS[@]}") fi if select_provider_groups; then : else rc=$? if [ "$rc" -eq 2 ]; then cp "$provider_snapshot" "$config_tmp" SECRET_ARGS=() if [ "${#secret_args_before[@]}" -gt 0 ]; then SECRET_ARGS=("${secret_args_before[@]}") fi ui_info "↩️ Returning to provider selection." continue fi return "$rc" fi if write_provider_block; then : else rc=$? if [ "$rc" -eq 2 ]; then cp "$provider_snapshot" "$config_tmp" SECRET_ARGS=() if [ "${#secret_args_before[@]}" -gt 0 ]; then SECRET_ARGS=("${secret_args_before[@]}") fi ui_info "↩️ Returning to provider selection." continue fi return "$rc" fi CONFIGURED_PROVIDERS="${CONFIGURED_PROVIDERS}${PROVIDER}|" # A second provider is where Community stops sending: say so now, but # still let the operator save it. if [ "$(provider_total_count)" -gt 1 ]; then warn_community_provider_cap fi provider_available || break if add_provider=$(ask_yes_no_or_back \ "➕ Add another notification provider?" "n"); then : else rc=$? if [ "$rc" -eq 2 ]; then ui_info "↩️ Provider configuration cancelled." fi return "$rc" fi if [ "$add_provider" != true ]; then break fi done if has_unselected_existing_provider; then if keep_existing=$(ask_yes_no_or_back \ "Keep existing providers you did not edit unchanged?" "y"); then : else rc=$? return "$rc" fi if [ "$keep_existing" = true ]; then preserve_existing_providers "$config_tmp" "$tmp_dir" true || true else ui_warn "⚠️ Existing providers not selected above will be removed." confirm_action "Continue and remove the unselected providers" "n" || die "provider configuration cancelled; no changes were saved" fi fi ;; back) ui_info "↩️ Provider configuration cancelled." return 2 ;; keep) printf 'crd:\n enabled: true\nalert:\n' >"$config_tmp" preserve_existing_providers "$config_tmp" "$tmp_dir" || die "could not preserve the existing notification providers" ;; empty) printf 'crd:\n enabled: true\nalert: {}\n' >"$config_tmp" ;; esac # These are Secret-backed extras unrelated to providers, such as the # heartbeat URL. Asking for each one on every provider edit was a prompt that # is almost always answered with Enter, so ask once whether they are wanted # at all; declining keeps whatever is already stored. secret_paths=() for entry in ${CATALOG[@]+"${CATALOG[@]}"}; do IFS='|' read -r path type default category description status replacement <<<"$entry" [ "$status" = secret ] || continue secret_paths+=("$path") done configure_secrets=false if [ "${#secret_paths[@]}" -gt 0 ] && [ "$(ask_yes_no "🔐 Set the Secret-backed extras (${secret_paths[*]}) now?" "n")" = true ]; then configure_secrets=true fi for entry in ${CATALOG[@]+"${CATALOG[@]}"}; do IFS='|' read -r path type default category description status replacement <<<"$entry" [ "$status" = secret ] || continue if [ "$configure_secrets" != true ]; then if ! preserve_config_secret "$config_tmp" "$path" "$tmp_dir"; then old_value=$(old_config_value "$path" || true) case "$old_value" in ""|\$\{file:*) ;; *) if write_config_secret_value "$config_tmp" "$path" \ "$old_value" "$tmp_dir"; then ui_success "🔐 Migrated $path into the configuration Secret." fi ;; esac fi continue fi while true; do value=$(ask_secret \ "$description (leave empty to skip) $(back_hint)") || exit_expected is_back_choice "$value" && return 2 if [ -z "$value" ]; then if preserve_config_secret "$config_tmp" "$path" "$tmp_dir"; then break fi old_value=$(old_config_value "$path" || true) case "$old_value" in ""|\$\{file:*) ;; *) if write_config_secret_value "$config_tmp" "$path" \ "$old_value" "$tmp_dir"; then ui_success "🔐 Migrated $path into the configuration Secret." fi ;; esac break fi if write_config_secret_value "$config_tmp" "$path" "$value" "$tmp_dir"; then break fi ui_warn "⚠️ $path must be a valid single-line secret value. Try again." done done with_loading "Saving notification configuration" apply_config_secret \ "$secret_name" "$config_tmp" || die "could not save the notification configuration" kubectl -n "$NAMESPACE" label secret "$secret_name" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null CONFIG_SECRET_NAME="$secret_name" } # A Secret volume is written root:root with exactly the mode the manifest asks # for, and Kubernetes group-owns it only when the Pod sets fsGroup. The workload # mounts its configuration with defaultMode 0400 and runs as a non-root user, so # without fsGroup the container cannot read /config/config.yaml: the rollout # succeeds and the Pod then crash-loops on "permission denied". Add fsGroup when # the release manifest omits it, matching the group the container already runs # as. The Secret keeps mode 0400 in the manifest, and the volume becomes # readable by that group only. ensure_config_volume_readable() { local manifest="$1" gid rewritten grep -q '^ fsGroup:' "$manifest" && return 0 grep -q 'defaultMode: 0*400$' "$manifest" || return 0 gid=$(awk '/^[[:space:]]*runAsGroup:[[:space:]]*[0-9]+[[:space:]]*$/ { print $2; exit }' "$manifest") [ -n "$gid" ] || return 0 rewritten=$(mktemp) # Add fsGroup to the Pod security context, creating that block before the # container list when the release does not declare one at all. if ! awk -v gid="$gid" ' /^ securityContext:[[:space:]]*$/ && inserted == 0 { print print " fsGroup: " gid inserted = 1 next } /^ containers:[[:space:]]*$/ && inserted == 0 { print " securityContext:" print " fsGroup: " gid inserted = 1 } { print } ' "$manifest" >"$rewritten"; then rm -f "$rewritten" return 0 fi grep -q '^ fsGroup: ' "$rewritten" || { rm -f "$rewritten"; return 0; } mv "$rewritten" "$manifest" || { rm -f "$rewritten"; return 0; } ui_detail \ "🔑 Added fsGroup $gid so the non-root workload can read its 0400 configuration Secret." } # Updating a workload someone else may have hand-edited is easier to agree to # when the change is visible first, but the upgrade has already been confirmed # by the time this runs, so asking again is one prompt too many. Print the diff # for whoever wants it -- KWATCH_SHOW_DIFF=true -- and stay silent otherwise. # Never on a fresh install: there is nothing to compare against. preview_manifest_changes() { local manifest="$1" existing="$2" diff_output [ -n "$existing" ] || return 0 [ "${KWATCH_SHOW_DIFF:-false}" = true ] || return 0 diff_output=$(kubectl diff -f "$manifest" 2>&1 || true) if [ -z "$diff_output" ]; then ui_info "ℹ️ No differences: the cluster already matches this release." return 0 fi ui_rule printf '%s\n' "$diff_output" | head -120 | sed -E 's#https?://[^ ]+##g; s/((token|password|secret)[=:])[[:space:]]*[^[:space:]]+/\1/Ig' ui_rule } # kwatch holds its Lease through a Role scoped with resourceNames, so an # unnamed authorization query would report a false denial. Ask about the # exact Lease, and about creating it, as the workload's ServiceAccount. # A release whose RBAC is intact answers yes to all and nothing changes. self_check_rbac_missing() { local subject="system:serviceaccount:$NAMESPACE:$RELEASE" lease answer verb local deployment deployment=$(deployment_name || true) lease=$(lease_name_for_deployment "${deployment:-$RELEASE}") for verb in get update; do answer=$(kubectl auth can-i "$verb" leases.coordination.k8s.io \ --resource-name="$lease" -n "$NAMESPACE" --as="$subject" \ 2>/dev/null || true) case "$answer" in *no*) return 0 ;; *yes*) ;; # Impersonation is not allowed here, so the question cannot be asked. *) return 1 ;; esac done answer=$(kubectl auth can-i create leases.coordination.k8s.io \ -n "$NAMESPACE" --as="$subject" 2>/dev/null || true) case "$answer" in *no*) return 0 ;; esac return 1 } repair_self_check_rbac() { local role="${RELEASE}-leader-election" lease details patch deployment deployment=$(deployment_name || true) lease=$(lease_name_for_deployment "${deployment:-$RELEASE}") kubectl -n "$NAMESPACE" get role "$role" >/dev/null 2>&1 || return 1 details="kwatch cannot hold its Lease '$lease', so it never starts monitoring." details+=" This adds the create rule and the named get/update rule for that" details+=" Lease to $role in $NAMESPACE." confirm_repair "grant the Lease permissions" "$details" || return 1 check_access patch roles namespace patch=$(printf '%s%s%s%s' \ '[{"op":"add","path":"/rules/-","value":{"apiGroups":["coordination.k8s.io"],' \ '"resources":["leases"],"verbs":["create"]}},' \ '{"op":"add","path":"/rules/-","value":{"apiGroups":["coordination.k8s.io"],' \ "\"resources\":[\"leases\"],\"resourceNames\":[\"$lease\"],\"verbs\":[\"get\",\"update\"]}}]") with_loading "Updating $role" kubectl -n "$NAMESPACE" patch role "$role" \ --type=json -p "$patch" \ >/dev/null || return 1 restart_kwatch || return 1 ui_success "✅ Lease permissions granted." return 0 } apply_manifests() { local version="$1" tmp crd_tmp deployment existing_deployment profile local rollout_error event_detail local manifest_to_apply valid_release_version "$version" || die "invalid kwatch release version: $version" existing_deployment=$(deployment_name || true) if [ -n "$existing_deployment" ]; then adopt_existing_config_secret fi choose_storage_mode "$existing_deployment" || { LAST_COMMAND_ERROR="no state volume was chosen" return 1 } tmp=$(mktemp) crd_tmp=$(mktemp) trap 'rm -f "${tmp:-}" "${tmp:-}.bak" "${crd_tmp:-}" \ 2>/dev/null || true' RETURN with_loading "Downloading CRD for $version" curl -fsSL --location \ --retry 3 --retry-delay 2 --connect-timeout 10 \ "$BASE_URL/$version/deploy/crd.yaml" -o "$crd_tmp" || return 1 verify_release_file "$version" deploy/crd.yaml "$crd_tmp" || return 1 crd_keeps_stored_versions "$crd_tmp" || return 1 with_loading "Applying CRD" kubectl apply -f "$crd_tmp" >/dev/null || return 1 with_loading "Waiting for CRD readiness" kubectl wait \ --for=condition=Established crd/kwatchconfigs.kwatch.abahmed.dev \ --timeout=60s >/dev/null || return 1 preflight_config_resource ensure_config_resource with_loading "Downloading deployment for $version" curl -fsSL --location \ --retry 3 --retry-delay 2 --connect-timeout 10 \ "$BASE_URL/$version/deploy/deploy.yaml" -o "$tmp" || return 1 verify_release_file "$version" deploy/deploy.yaml "$tmp" || return 1 if ! grep -q '^kind: Deployment$' "$tmp"; then LAST_COMMAND_ERROR="Downloaded deployment manifest for $version does not contain a Deployment." return 1 fi rewrite_release_names "$tmp" "$version" drop_namespace_document "$tmp" ensure_config_volume_readable "$tmp" apply_storage_mode_to_manifest "$tmp" || { LAST_COMMAND_ERROR="the state volume could not be written into the manifest" return 1 } strip_kube_system_rbac_if_denied "$tmp" plan_storage_affinity "$tmp" "$existing_deployment" || { LAST_COMMAND_ERROR="no state volume was chosen" return 1 } inject_storage_affinity_into_manifest "$tmp" "$existing_deployment" # Carry the operator's resources and placement into the new manifest. Without # this the upgrade silently reverts them: the manifest is downloaded fresh # every run and ships the release's own values. deployment_overrides_read if [ -n "$DEPLOYMENT_OVERRIDES" ]; then if apply_deployment_overrides_to_manifest "$tmp"; then if [ -z "$(overrides_without_license)" ]; then : elif [ "$DEPLOYMENT_OVERRIDES_SOURCE" = record ]; then # Restoring sizing the operator set before an uninstall is helpful, but # only if they are told it happened. ui_detail "🧮 Restored the Deployment resources and placement recorded earlier." else ui_detail "🧮 Kept the configured Deployment resources and placement." fi if [ "$(deployment_override_get license || true)" = 1 ]; then ui_detail "🔑 Kept the Pro license key reference ($(license_secret_name))." fi else ui_warn "⚠️ The recorded Deployment resources could not be fully applied to this" ui_warn " release; the parts that did not apply use the release defaults." fi fi # Ask the cluster what it will do to this Pod, and put the answer into the # manifest before it is applied: the workload is then created already # excluded, instead of being created, rejected by Pod Security, and patched # afterwards. Only when the manifest cannot be rewritten does the patch after # the apply remain as the fallback. plan_injection_opt_out "ghcr.io/abahmed/kwatch:$version" if [ "${#INJECTOR_OPT_OUT_PROFILES[@]}" -gt 0 ] && ! inject_opt_out_into_manifest "$tmp"; then INJECTOR_OPT_OUT_PATCH_AFTER=true fi preview_manifest_changes "$tmp" "$existing_deployment" # Deployment selectors are immutable, so an older release whose selector # differs does need the Deployment recreated -- but omitting the selector from # the manifest is not how to get there. `kubectl apply` merges against the # last-applied-configuration annotation, which the manager's own previous # apply put a selector into, so a manifest without one asks the API server to # *delete* it: "spec.selector: Invalid value: null: field is immutable", on # every upgrade, including the ordinary one where the selector never changed. # Apply the manifest as the release ships it. A real selector change fails # here and the recreate below is offered, which is the only repair there is. manifest_to_apply="$tmp" invalidate_deployment_name if ! with_loading "Applying kwatch Deployment" kubectl apply -f \ "$manifest_to_apply"; then # The prompt below asks for a delete-and-recreate. Show what Kubernetes # objected to first: it is the only evidence for whether that is the right # answer, and with_loading has captured it rather than printed it. if [ -n "$LAST_COMMAND_ERROR" ]; then ui_detail "$(compact_reason "$LAST_COMMAND_ERROR")" fi if [ -n "$existing_deployment" ] && confirm_action \ "The Deployment update failed. Recreate it while preserving config and Secrets" \ "n" && recreate_deployment "$existing_deployment" "$tmp"; then ui_success \ "🛠️ Recreated the kwatch Deployment" \ "while preserving configuration." else [ -n "$LAST_COMMAND_ERROR" ] || LAST_COMMAND_ERROR="Kubernetes rejected the kwatch Deployment manifest." ui_error \ "❌ Could not apply the kwatch Deployment." \ "Existing configuration and Secrets were preserved." return 1 fi fi if ! verify_runtime_dependencies; then return 1 fi deployment=$(deployment_name) [ -n "$deployment" ] || deployment="$RELEASE" if [ -n "$PLANNED_STORAGE_KEY" ]; then kubectl -n "$NAMESPACE" annotate deployment "$deployment" \ "$STORAGE_AFFINITY_ANNOTATION=$PLANNED_STORAGE_KEY" --overwrite \ >/dev/null 2>&1 || true fi if [ "$INJECTOR_OPT_OUT_PATCH_AFTER" = true ]; then for profile in "${INJECTOR_OPT_OUT_PROFILES[@]-}"; do [ -n "$profile" ] || continue apply_injection_opt_out "$deployment" "$profile" || return 1 done fi # The sizing came from the surviving record, so this Deployment does not carry # the annotation yet. Mirror it back, so the values stay visible on the # workload rather than only in the record. if [ "$DEPLOYMENT_OVERRIDES_SOURCE" = record ] && [ -n "$DEPLOYMENT_OVERRIDES" ]; then deployment_overrides_write "$deployment" "$DEPLOYMENT_OVERRIDES" >/dev/null 2>&1 || true fi if with_loading "Waiting for kwatch rollout" \ wait_for_kwatch_rollout "$deployment"; then prune_superseded_resources "$tmp" return 0 fi rollout_error="${LAST_COMMAND_ERROR:-rollout did not become ready}" event_detail=$(admission_rejection_event) [ -n "$event_detail" ] || event_detail=$(latest_warning_event) LAST_COMMAND_ERROR="Deployment was applied, but its rollout failed: $rollout_error" [ -n "$event_detail" ] && LAST_COMMAND_ERROR="$LAST_COMMAND_ERROR Latest warning event: $event_detail" ui_error "❌ Deployment was applied, but the rollout did not become ready." return 1 } recreate_deployment() { local deployment="$1" manifest="$2" ui_warn \ "⚠️ Existing Deployment could not be reconciled; recreating it" \ "while preserving KwatchConfig and Secrets." check_access delete deployments namespace with_loading "Removing invalid kwatch Deployment" kubectl -n "$NAMESPACE" \ delete deployment "$deployment" --ignore-not-found --wait=true || return 1 invalidate_deployment_name with_loading "Recreating kwatch Deployment" kubectl apply -f "$manifest" || { ui_error \ "❌ Deployment recreation failed." \ "KwatchConfig and Secrets were not removed." return 1 } } verify_runtime_dependencies() { if ! kubectl -n "$NAMESPACE" get serviceaccount "$RELEASE" >/dev/null 2>&1; then LAST_COMMAND_ERROR="ServiceAccount $NAMESPACE/$RELEASE is missing; the Deployment cannot create Pods." return 1 fi if ! kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" >/dev/null 2>&1; then LAST_COMMAND_ERROR="Configuration Secret $NAMESPACE/$CONFIG_SECRET_NAME is missing; the Deployment cannot mount config.yaml." return 1 fi } # Six of these seven answers come from the same Deployment, and asked one at a # time they were six silent seconds at the end of every upgrade. One request # returns them all: none of the values -- true, false, RuntimeDefault, ALL, a # file mode -- can contain the separator. verify_operational_security() { local deployment enforce non_root read_only no_escalation seccomp dropped secret_mode local fields deployment=$(deployment_name) [ -n "$deployment" ] || deployment="$RELEASE" ui_busy "Verifying operational security" enforce=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/enforce}') fields=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.spec.template.spec.containers[?(@.name==\"$RELEASE\")].securityContext.runAsNonRoot}{\"|\"}{.spec.template.spec.containers[?(@.name==\"$RELEASE\")].securityContext.readOnlyRootFilesystem}{\"|\"}{.spec.template.spec.containers[?(@.name==\"$RELEASE\")].securityContext.allowPrivilegeEscalation}{\"|\"}{.spec.template.spec.containers[?(@.name==\"$RELEASE\")].securityContext.seccompProfile.type}{\"|\"}{.spec.template.spec.containers[?(@.name==\"$RELEASE\")].securityContext.capabilities.drop[0]}{\"|\"}{.spec.template.spec.volumes[?(@.name==\"config-volume\")].secret.defaultMode}") IFS='|' read -r non_root read_only no_escalation seccomp dropped secret_mode \ <<< "$fields" ui_busy_done [ "$enforce" = restricted ] || { ui_error "❌ Namespace Pod Security enforcement is not restricted." return 1 } [ "$non_root" = true ] || { ui_error "❌ kwatch deployment is not configured as non-root." return 1 } [ "$read_only" = true ] || { ui_error "❌ kwatch deployment root filesystem is writable." return 1 } [ "$no_escalation" = false ] || { ui_error "❌ kwatch deployment allows privilege escalation." return 1 } [ "$seccomp" = RuntimeDefault ] || { ui_error "❌ kwatch deployment lacks RuntimeDefault seccomp." return 1 } [ "$dropped" = ALL ] || { ui_error "❌ kwatch deployment does not drop all capabilities." return 1 } [ "$secret_mode" = 256 ] || { ui_error "❌ kwatch Secret volume is not mode 0400." return 1 } ui_success "🛡️ Kubernetes operational protection verified." } apply_operational_namespace_labels() { local enforce audit warn managed existing_workload existing_config enforce=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/enforce}' \ 2>/dev/null || true) managed=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.annotations.kwatch\.dev/managed-namespace}' \ 2>/dev/null || true) audit=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/audit}' \ 2>/dev/null || true) warn=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/warn}' \ 2>/dev/null || true) existing_workload=$(deployment_name || true) existing_config=false if kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" >/dev/null 2>&1; then existing_config=true fi # A legacy replacement that stopped after removing the old kwatch left # its backup here: on the next run the namespace is still kwatch's own. if [ "$LEGACY_REPLACED" != true ] && [ -n "$(kubectl -n "$NAMESPACE" \ get secret -l kwatch.dev/backup-kind=legacy-install -o name \ 2>/dev/null || true)" ]; then LEGACY_REPLACED=true fi if { [ "$enforce" != restricted ] || [ "$audit" != restricted ] || [ "$warn" != restricted ]; } && [ "$managed" != true ] && [ "$NAMESPACE_CREATED" != true ] && [ "$LEGACY_REPLACED" != true ] && [ -z "$existing_workload" ] && [ "$existing_config" != true ] && [ "${KWATCH_ALLOW_NAMESPACE_LABELS:-false}" != true ]; then die "namespace '$NAMESPACE' already exists without restricted Pod Security labels; set KWATCH_ALLOW_NAMESPACE_LABELS=true only after reviewing the shared namespace" fi if { [ -n "$existing_workload" ] || [ "$existing_config" = true ] || [ "$LEGACY_REPLACED" = true ]; } && { [ "$enforce" != restricted ] || [ "$audit" != restricted ] || [ "$warn" != restricted ]; }; then ui_info "🔐 Existing kwatch resources found; applying restricted Pod Security labels." confirm_action \ "Apply restricted Pod Security labels to namespace $NAMESPACE" "n" || die "namespace security labels were not approved" fi with_loading "Applying namespace security labels" kubectl label namespace \ "$NAMESPACE" pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/audit=restricted \ pod-security.kubernetes.io/warn=restricted --overwrite >/dev/null || die "could not apply namespace security labels" if [ "$NAMESPACE_CREATED" = true ]; then with_loading "Marking managed namespace" kubectl annotate namespace \ "$NAMESPACE" kwatch.dev/managed-namespace=true --overwrite >/dev/null || die "could not mark the managed namespace" fi } clear_managed_namespace_labels() { local managed enforce audit warn managed=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.annotations.kwatch\.dev/managed-namespace}' \ 2>/dev/null || true) [ "$managed" = true ] || return 0 enforce=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/enforce}' \ 2>/dev/null || true) audit=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/audit}' \ 2>/dev/null || true) warn=$(kubectl get namespace "$NAMESPACE" \ -o 'jsonpath={.metadata.labels.pod-security\.kubernetes\.io/warn}' \ 2>/dev/null || true) [ "$enforce" = restricted ] && [ "$audit" = restricted ] && \ [ "$warn" = restricted ] || return 0 kubectl label namespace "$NAMESPACE" \ pod-security.kubernetes.io/enforce- \ pod-security.kubernetes.io/audit- \ pod-security.kubernetes.io/warn- >/dev/null 2>&1 || true kubectl annotate namespace "$NAMESPACE" \ kwatch.dev/managed-namespace- >/dev/null 2>&1 || true } retry_install_apply() { local version="$1" failure="" confirm_repair \ "retry applying kwatch $version" \ "This repeats the failed Kubernetes apply and rollout checks; no additional configuration is requested." || return 1 record_state apply "$version" "retrying failed installation" if apply_manifests "$version"; then if verify_operational_security; then record_state complete "$version" "installation verified after retry" FRESH_INSTALL=false ui_success "✅ kwatch is ready after the retry." configure_after_install return 0 fi failure="operational security verification failed" else failure="${LAST_COMMAND_ERROR:-kwatch resources could not be applied}" fi LAST_COMMAND_ERROR="$failure" ui_error "❌ Retry failed." ui_error "Reason: $(compact_reason "$failure")" show_failure_diagnostics "Installation retry" "$failure" return 1 } retry_upgrade_apply() { local version="$1" failure="" confirm_repair \ "retry upgrading kwatch to $version" \ "This repeats the failed Kubernetes apply and rollout checks using the existing backup." || return 1 record_state apply "$version" "retrying failed upgrade" if apply_manifests "$version"; then if verify_operational_security; then record_state complete "$version" "upgrade verified after retry" ui_success "✅ kwatch upgraded successfully after the retry." return 0 fi failure="operational security verification failed" else failure="${LAST_COMMAND_ERROR:-kwatch resources could not be applied}" fi LAST_COMMAND_ERROR="$failure" ui_error "❌ Upgrade retry failed." ui_error "Reason: $(compact_reason "$failure")" show_failure_diagnostics "Upgrade retry" "$failure" return 1 } install_flow() { with_loading "Checking Kubernetes cluster" kubectl cluster-info >/dev/null || die "cannot reach the Kubernetes cluster" local version="${1:-}" skip_resume="${2:-false}" local catalogs_ready="${3:-false}" local change_confirmed="${4:-false}" local write_rc install_failure="" [ "$skip_resume" = true ] || confirm_resume if [ -z "$version" ]; then version=$(select_modern_release_version) || die "could not determine a modern kwatch release from GitHub" fi version_is_legacy "$version" && die "a fresh installation requires a kwatch release >= v1.0.0" if [ "$catalogs_ready" != true ]; then maybe_load_catalog "$version" || die "release catalogs are unavailable; installation cannot continue" fi require_config_catalog require_provider_catalog if [ "$change_confirmed" != true ]; then confirm_change \ "🚀 The manager will install kwatch $version on '$(context_label "$SELECTED_CONTEXT")'." \ "It will create or update resources in namespace '$NAMESPACE' and may create a configuration Secret." || { ui_info "↩️ Installation cancelled; no changes were made." return 0 } fi confirm_config_secret_replacement if kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then NAMESPACE_CREATED=false else with_loading "Creating namespace $NAMESPACE" kubectl create namespace \ "$NAMESPACE" >/dev/null || die "could not create namespace $NAMESPACE" NAMESPACE_CREATED=true fi apply_operational_namespace_labels ui_busy "Checking install permissions" preflight_access install ui_busy_done # Reuse the mounted Secret name when applying an existing workload. adopt_existing_config_secret FRESH_INSTALL=true record_state preflight "$version" "cluster selected and reachable" ui_info "🚀 Installing kwatch $version..." record_state backup "$version" "creating notification Secret" if write_config_secret; then : else write_rc=$? if [ "$write_rc" -eq 2 ]; then FRESH_INSTALL=false ui_info \ "↩️ Installation cancelled before notification configuration was saved." return 0 fi die "could not save the notification configuration" fi record_state apply "$version" "applying CRD and Deployment" if apply_manifests "$version"; then if ! verify_operational_security; then install_failure="operational security verification failed" fi else install_failure="${LAST_COMMAND_ERROR:-kwatch resources could not be applied}" fi if [ -n "$install_failure" ]; then record_state failed "$version" "installation failed; cleanup requires confirmation" ui_error "❌ Installation failed; the created workload can be cleaned up." ui_error "Reason: $(compact_reason "$install_failure")" show_failure_diagnostics "Installation" "$install_failure" if is_injected_admission_failure "$install_failure"; then if repair_injected_hostpath "$install_failure"; then record_state complete "$version" "installation repaired after injector opt-out" FRESH_INSTALL=false configure_after_install return 0 fi install_failure="${LAST_COMMAND_ERROR:-$install_failure}" elif self_check_rbac_missing && repair_self_check_rbac; then record_state complete "$version" "installation repaired after granting the Lease permissions" FRESH_INSTALL=false configure_after_install return 0 elif is_storage_failure "$install_failure" && repair_storage "$version"; then record_state complete "$version" "installation repaired after fixing the state volume" FRESH_INSTALL=false configure_after_install return 0 elif is_scheduling_failure "$install_failure"; then if repair_scheduling; then record_state complete "$version" "installation repaired after adding tolerations" FRESH_INSTALL=false configure_after_install return 0 fi install_failure="${LAST_COMMAND_ERROR:-$install_failure}" elif failure_is_retryable "$install_failure"; then if retry_install_apply "$version"; then return 0 fi install_failure="${LAST_COMMAND_ERROR:-$install_failure}" fi if confirm_repair \ "remove the kwatch workload resources created by this failed install" \ "The CRD and configuration resource will be preserved; only manager-owned workload resources will be removed."; then remove_namespaced_workload ui_info "🧹 Failed-install workload cleanup completed." else ui_warn "⚠️ Cleanup skipped; the failed workload resources were left in place." fi die "installation failed; review the reason above" fi record_state complete "$version" "installation verified" FRESH_INSTALL=false ui_success "✅ kwatch is ready." secrets_watch_notice configure_after_install } upgrade_flow() { local version upgrade_failure="" change_confirmed="${1:-false}" allow_same=false local previous="$INSTALL_VERSION" confirm_resume # Repairing an unhealthy installation may mean re-applying the release it is # already on, which is often the newest one available. if [ "$INSTALL_STATE" = broken ]; then allow_same=true fi if [ -n "$INSTALL_VERSION" ]; then version=$(select_upgrade_version "$INSTALL_VERSION" "$allow_same") || { # 1 means the manager already explained why there is nothing to do. [ "$?" -eq 2 ] && ui_info "↩️ Upgrade cancelled; nothing was changed." return 0 } elif ! version=$(select_modern_release_version); then ui_info "↩️ No release was selected; nothing was changed." return 0 fi maybe_load_catalog "$version" || die "release catalogs are unavailable; upgrade cannot continue" state_reset_notice "$previous" "$version" if [ "$change_confirmed" != true ]; then confirm_change \ "⬆️ The manager will upgrade kwatch to $version on '$(context_label "$SELECTED_CONTEXT")'." \ "It will update the CRD and Deployment. A configuration backup will be created first." || { ui_info "↩️ Upgrade cancelled; no changes were made." return 0 } fi ui_info "⬆️ Upgrading kwatch to $version..." apply_operational_namespace_labels record_state preflight "$version" "upgrade started" ensure_crd "$version" ui_busy "Checking upgrade permissions" preflight_access upgrade ui_busy_done preflight_config_resource ensure_config_resource ensure_runtime_config_secret migrate_legacy_telemetry || die "could not preserve the existing telemetry setting" backup_config record_state backup "$version" "configuration backup created" if ! migrate_legacy_silences; then ui_error "❌ Legacy configuration migration failed; the previous configuration can be restored." restore_backup_after_failure \ "This reverses the legacy settings migration that just failed." || true record_state failed "$version" "legacy configuration migration failed" die "upgrade migration failed" fi # A key supplied through the environment is recorded first, so the manifest # applied just below already carries it and the Pod rolls only once. license_apply_from_env record record_state apply "$version" "applying upgraded Deployment" if apply_manifests "$version"; then if ! verify_operational_security; then upgrade_failure="operational security verification failed" fi else upgrade_failure="${LAST_COMMAND_ERROR:-kwatch resources could not be applied}" fi if [ -n "$upgrade_failure" ]; then ui_error "❌ Upgrade failed; the previous configuration can be restored." ui_error "Reason: $(compact_reason "$upgrade_failure")" show_failure_diagnostics "Upgrade" "$upgrade_failure" if is_storage_failure "$upgrade_failure" && repair_storage "$version"; then record_state complete "$version" "upgrade repaired after fixing the state volume" return 0 fi if is_scheduling_failure "$upgrade_failure" && repair_scheduling; then record_state complete "$version" "upgrade repaired after adding tolerations" return 0 fi if is_injected_admission_failure "$upgrade_failure"; then if repair_injected_hostpath "$upgrade_failure"; then record_state complete "$version" "upgrade repaired after injector opt-out" ui_success "✅ kwatch upgraded successfully after the repair." return 0 fi upgrade_failure="${LAST_COMMAND_ERROR:-$upgrade_failure}" elif failure_is_retryable "$upgrade_failure"; then if retry_upgrade_apply "$version"; then return 0 fi upgrade_failure="${LAST_COMMAND_ERROR:-$upgrade_failure}" fi if confirm_repair \ "restore the previous configuration and roll back the Deployment" \ "The backup created before this upgrade will be applied, then Kubernetes will roll back the Deployment."; then restore_backup rollback_deployment record_state failed "$version" "deployment rollout failed; rollback completed" else ui_warn "⚠️ Rollback skipped; the failed upgrade state was left in place." record_state failed "$version" "deployment rollout failed; rollback skipped" fi die "upgrade failed" fi record_state complete "$version" "upgrade verified" ui_success "✅ kwatch upgraded successfully." cleanup_legacy_state_configmaps "$previous" } legacy_reinstall_flow() { local target confirmation target=$(select_modern_release_version) || die "could not determine a modern kwatch release from GitHub" MIGRATION_TARGET_VERSION="$target" maybe_load_catalog "$target" || die "release catalogs are unavailable; legacy replacement cannot continue" require_config_catalog require_provider_catalog adopt_existing_config_secret ui_heading "🔁 Replace legacy kwatch" ui_warn "⚠️ This is a legacy kwatch replacement, not an in-place upgrade." ui_detail "Installed version: ${INSTALL_VERSION:-unknown}" ui_detail "Target version: $target" ui_detail "The old configuration will be backed up before uninstalling." ui_detail "A fresh installation will configure providers again." ui_detail "No legacy settings migration will be attempted." confirm_change \ "🔁 The manager will uninstall the legacy workload and install kwatch $target." \ "The old configuration will be backed up first; the legacy workload will then be removed." || { ui_info "↩️ Legacy replacement cancelled; no changes were made." return 0 } confirmation=$(ask "Type uninstall to continue" "") || exit_expected [ "$confirmation" = uninstall ] || { ui_warn "↩️ Cancelled. The legacy installation was not changed." return 0 } backup_legacy_install record_state backup "$target" "legacy installation backup created" remove_legacy_install LEGACY_REPLACED=true CONFIG_SECRET_NAME="${RELEASE}-config" ui_info "🚀 Starting a fresh kwatch installation." install_flow "$target" true true true MIGRATION_TARGET_VERSION="" } show_absent_menu() { ui_screen "not installed" ui_info "No running kwatch installation was found." if stale_resources_present; then ui_info "Existing kwatch configuration resources were found, but no" \ "kwatch workload is running; they are not treated as an installation." ui_info \ "Settings are preserved where possible; provider setup starts fresh." fi local choice if choice=$(ui_select 0 "🚀 Install kwatch" "🚪 Exit"); then case "$choice" in 0) install_flow; return ;; esac fi MENU_EXIT=true } show_legacy_menu() { ui_screen "legacy release" ui_warn "kwatch $INSTALL_VERSION predates the guided catalogs." ui_detail "This release predates the guided configuration catalogs." ui_detail "Configuration editing is unavailable until it is replaced." local choice if choice=$(ui_select 1 \ "🔁 Uninstall legacy kwatch and fresh-install" \ "📊 View status" \ "📜 View recent logs" \ "🚪 Exit"); then case "$choice" in 0) legacy_reinstall_flow; return ;; 1) status_flow; MENU_READ_ONLY=true; return ;; 2) logs_flow; MENU_READ_ONLY=true; return ;; esac fi MENU_EXIT=true } # --------------------------------------------------------------------------- # Dashboard (Pro). Spec: packages/brand/specs/dashboard-phase2.md, sections 3 # and 7c. The manager installs from the release manifest, not Helm, so it does # what the chart would: it creates the -dashboard Secret and Service # and wires the KWATCH_DASHBOARD_* variables the chart's Deployment template # sets (enabled, port, trusted proxies, tls) onto the Deployment. Everything # that exposes the dashboard (Ingress, HTTPRoute, VirtualService, Route, # LoadBalancer or NodePort Service, Certificate, TLS Secret) is created here # and labelled app.kwatch.dev/managed-by=installer; only labelled objects are # ever deleted. # --------------------------------------------------------------------------- DASH_PORT=8061 DASH_LABEL_KEY="app.kwatch.dev/managed-by" DASH_LABEL_VALUE="installer" DASH_ALPHABET='ABCDEFGHJKLMNPQRSTUVWXYZ23456789' DASH_POLL_INTERVAL="${KWATCH_DASHBOARD_POLL_INTERVAL:-5}" DASH_POLL_MAX="${KWATCH_DASHBOARD_POLL_MAX:-120}" DASH_ADDRESS_WAIT="${KWATCH_DASHBOARD_ADDRESS_WAIT:-60}" # Answers collected by the share flow (reset by dash_reset_answers). DASH_WAY="" # local ingress gateway istio route lb nodeport proxy service DASH_ADDR="" DASH_ADDR_IP=false # the address is an IP (or, for a load balancer, not chosen yet) DASH_CLASS="" # ingress class name DASH_CTYPE="" # nginx traefik haproxy haproxytech contour kong alb gke agic tailscale ngrok other DASH_GW_NS="" DASH_GW_NAME="" DASH_CERT="" # auto own self acm google handled none upstream default resolver DASH_ISSUER="" DASH_ISSUER_KIND="" DASH_ISSUER_TYPE="" DASH_TLS_SECRET="" # Secret kwatch serves from (or the ingress presents) DASH_IPS="" # comma separated DASH_PROXIES="" # comma separated CIDRs of the proxies kwatch believes X-Forwarded-* from DASH_ALB_INTERNAL=true DASH_ACM_ARN="" DASH_GKE_IP="" DASH_AGIC_PRIVATE=true DASH_TS_NAME="" DASH_LB_INTERNAL=true DASH_LB_POOL="" DASH_LB_IP="" DASH_NODEPORT="" dash_reset_answers() { DASH_WAY=""; DASH_ADDR=""; DASH_ADDR_IP=false; DASH_CLASS=""; DASH_CTYPE="" DASH_GW_NS=""; DASH_GW_NAME=""; DASH_CERT=""; DASH_ISSUER="" DASH_ISSUER_KIND=""; DASH_ISSUER_TYPE=""; DASH_TLS_SECRET=""; DASH_IPS=""; DASH_PROXIES=""; DASH_ALB_INTERNAL=true DASH_ACM_ARN=""; DASH_GKE_IP=""; DASH_AGIC_PRIVATE=true; DASH_TS_NAME="" DASH_LB_INTERNAL=true; DASH_LB_POOL=""; DASH_LB_IP=""; DASH_NODEPORT="" DASH_PEER=false; DASH_PEER_ANN=""; DASH_ALB_PORTS=""; DASH_CERT_WHY="" DASH_NGINX_SNIPPETS=false; DASH_RESOLVER="" return 0 } dash_name() { printf '%s-dashboard' "$RELEASE"; } dash_tls_name() { printf '%s-dashboard-tls' "$RELEASE"; } dash_backend_tls_name() { printf '%s-dashboard-backend-tls' "$RELEASE"; } # Emoji are decoration: drop them where the terminal cannot show them. dash_emoji_ok() { case "${TERM:-}" in dumb) return 1 ;; esac if [ -n "${NO_COLOR:-}" ]; then case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8*|*utf-8*|*UTF8*|*utf8*) ;; *) return 1 ;; esac fi return 0 } # dash_e dash_e() { if dash_emoji_ok; then printf '%s %s' "$1" "$2"; else printf '%s' "$2"; fi } dash_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } dash_trim() { local v="$1" v="${v#"${v%%[![:space:]]*}"}" v="${v%"${v##*[![:space:]]}"}" printf '%s' "$v" } dash_is_ipv4() { local ip="$1" part [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1 local IFS=. for part in $ip; do [ "$((10#$part))" -le 255 ] || return 1 done return 0 } dash_is_ipv6() { [[ "$1" =~ ^[0-9a-fA-F:]+$ ]] && [[ "$1" == *:*:* ]] } dash_is_ip() { dash_is_ipv4 "$1" || dash_is_ipv6 "$1"; } dash_is_private_ip() { local ip="$1" second dash_is_ipv4 "$ip" || return 1 case "$ip" in 10.*|192.168.*|127.*) return 0 ;; 172.*) second="${ip#172.}"; second="${second%%.*}" [ "$second" -ge 16 ] && [ "$second" -le 31 ] && return 0 return 1 ;; esac return 1 } dash_ip_to_int() { local a b c d IFS=. read -r a b c d <<<"$1" printf '%s' "$(((10#$a << 24) + (10#$b << 16) + (10#$c << 8) + 10#$d))" } # True when an IPv4 address lies in one pool entry: "a-b" or "a/len". dash_ip_in_range() { local ip="$1" range="$2" lo hi n len base size dash_is_ipv4 "$ip" || return 1 n=$(dash_ip_to_int "$ip") case "$range" in */*) base="${range%/*}"; len="${range#*/}" dash_is_ipv4 "$base" || return 1 [[ "$len" =~ ^[0-9]+$ ]] && [ "$len" -le 32 ] || return 1 size=$((1 << (32 - len))) lo=$(($(dash_ip_to_int "$base") / size * size)) hi=$((lo + size - 1)) ;; *-*) lo=$(dash_trim "${range%-*}"); hi=$(dash_trim "${range#*-}") dash_is_ipv4 "$lo" && dash_is_ipv4 "$hi" || return 1 lo=$(dash_ip_to_int "$lo"); hi=$(dash_ip_to_int "$hi") ;; *) return 1 ;; esac [ "$n" -ge "$lo" ] && [ "$n" -le "$hi" ] } dash_yaml_quote() { local v="${1//\\/\\\\}" v="${v//\"/\\\"}" printf '"%s"' "$v" } dash_tty_out() { [ -t 1 ]; } # ---- address and IP list validation ---------------------------------------- DASH_ADDR_CLEAN="" DASH_ADDR_ERR="" dash_normalize_address() { local v DASH_ADDR_CLEAN="" DASH_ADDR_ERR="" v=$(dash_lower "$(dash_trim "$1")") case "$v" in http://*) DASH_ADDR_ERR="kwatch only shares the dashboard over https. Type just the address, like kwatch.example.com." return 1 ;; https://*) v="${v#https://}" ;; esac while [[ "$v" == */ ]]; do v="${v%/}"; done if dash_is_ip "$v"; then DASH_ADDR_ERR="Use a name, not an IP address. A certificate needs a name." return 1 fi case "$v" in *"*"*|*/*|*:*|*[[:space:]]*) DASH_ADDR_ERR="Type just the address, like kwatch.example.com." return 1 ;; esac if [ "${#v}" -gt 253 ] || ! [[ "$v" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then DASH_ADDR_ERR="That doesn't look like an address. Try kwatch.example.com." return 1 fi DASH_ADDR_CLEAN="$v" return 0 } # One IP or CIDR (IPv4 or IPv6). dash_valid_cidr() { local v="$1" ip len="" max ip="${v%%/*}" [ "$ip" != "$v" ] && len="${v#*/}" if dash_is_ipv4 "$ip"; then max=32 elif dash_is_ipv6 "$ip"; then max=128 else return 1 fi if [ -n "$len" ]; then [[ "$len" =~ ^[0-9]+$ ]] && [ "$len" -le "$max" ] || return 1 fi return 0 } # Parses "a, b c" into DASH_IPS_CLEAN. Returns 1 on a bad entry, 3 for 0.0.0.0/0. DASH_IPS_CLEAN="" DASH_IPS_ERR="" DASH_IPS_ALL_PRIVATE=false dash_parse_ips() { local raw="$1" item list="" all_private=true count=0 DASH_IPS_CLEAN="" DASH_IPS_ERR="" DASH_IPS_ALL_PRIVATE=false raw="${raw//,/ }" for item in $raw; do case "$item" in 0.0.0.0/0|::/0) DASH_IPS_ERR="0.0.0.0/0 means everyone. Leave this empty instead." return 3 ;; esac if ! dash_valid_cidr "$item"; then DASH_IPS_ERR="\"$item\" isn't an IP address or range. Try 203.0.113.0/24." return 1 fi dash_is_private_ip "${item%%/*}" || all_private=false list="${list:+$list,}$item" count=$((count + 1)) done DASH_IPS_CLEAN="$list" if [ "$count" -gt 0 ] && [ "$all_private" = true ]; then DASH_IPS_ALL_PRIVATE=true; fi return 0 } DASH_ARN_ERR="" dash_valid_arn() { DASH_ARN_ERR="" if ! [[ "$1" =~ ^arn:aws:acm:[a-z0-9-]+:[0-9]{12}:certificate/[A-Za-z0-9-]+$ ]]; then DASH_ARN_ERR="That doesn't look like an ACM certificate ARN. It starts with arn:aws:acm: and ends with certificate/ and an ID." return 1 fi return 0 } dash_valid_label() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] } # ---- detection (7c.1) ------------------------------------------------------ DASH_D_CLASSES="" # name|controller|default(true/false) DASH_D_GWAPI=false DASH_D_GATEWAYS="" # kind|ns|name|usable|label kind: gateway|istio DASH_D_GW_HIDDEN=0 DASH_D_ISTIO=false DASH_D_OPENSHIFT=false DASH_D_APPS_DOMAIN="" DASH_D_CLOUD="none" # aws gce azure other none DASH_D_REGION="" DASH_D_LB=false DASH_D_LB_KIND="" # cloud metallb kube-vip existing DASH_D_POOLS="" # name|addresses (comma separated) DASH_D_NODEIP=false DASH_D_NODES="" # name|address DASH_D_CERTMGR=false DASH_D_ISSUERS="" # name|kind|type only Ready issuers; type: ACME, ACME DNS, CA, other DASH_D_ISSUERS_SKIPPED=0 DASH_D_EXTDNS=false DASH_D_UPSTREAM="" # name|ssl label of controllers whose load balancer ends https dash_controller_type() { case "$1" in k8s.io/ingress-nginx) printf 'nginx' ;; traefik.io/ingress-controller) printf 'traefik' ;; haproxy-ingress.github.io/controller) printf 'haproxy' ;; haproxy.org/ingress-controller) printf 'haproxytech' ;; projectcontour.io/ingress-controller) printf 'contour' ;; ingress-controllers.konghq.com/kong) printf 'kong' ;; ingress.k8s.aws/alb) printf 'alb' ;; k8s.io/ingress-gce) printf 'gke' ;; azure/application-gateway) printf 'agic' ;; tailscale.com/ts-ingress) printf 'tailscale' ;; ngrok.com/ingress-controller) printf 'ngrok' ;; *) printf 'other' ;; esac } dash_type_label() { case "$1" in nginx) printf 'nginx' ;; traefik) printf 'Traefik' ;; haproxy|haproxytech) printf 'HAProxy' ;; contour) printf 'Contour' ;; kong) printf 'Kong' ;; alb) printf 'AWS ALB' ;; gke) printf 'GKE' ;; agic) printf 'Azure AGIC' ;; tailscale) printf 'Tailscale' ;; ngrok) printf 'ngrok' ;; *) printf 'other' ;; esac } dash_cloud_label() { case "$1" in aws) printf 'AWS' ;; gce) printf 'Google Cloud' ;; azure) printf 'Azure' ;; *) printf '%s' "$1" ;; esac } dash_has_crd() { kubectl get crd "$1" -o name >/dev/null 2>&1 } # Read-only kubectl calls, once per Share flow. dash_detect() { local name controller def provider zone normalised="" nodes="" ext int DASH_D_CLASSES=""; DASH_D_GWAPI=false; DASH_D_GATEWAYS=""; DASH_D_GW_HIDDEN=0 DASH_D_ISTIO=false; DASH_D_OPENSHIFT=false; DASH_D_APPS_DOMAIN="" DASH_D_CLOUD="none"; DASH_D_REGION=""; DASH_D_LB=false; DASH_D_LB_KIND="" DASH_D_POOLS=""; DASH_D_NODEIP=false; DASH_D_NODES=""; DASH_D_CERTMGR=false DASH_D_ISSUERS=""; DASH_D_ISSUERS_SKIPPED=0; DASH_D_EXTDNS=false; DASH_D_UPSTREAM="" DASH_D_CLASSES=$(kubectl get ingressclass -o \ 'jsonpath={range .items[*]}{.metadata.name}{"|"}{.spec.controller}{"|"}{.metadata.annotations.ingressclass\.kubernetes\.io/is-default-class}{"\n"}{end}' \ 2>/dev/null || true) while IFS='|' read -r name controller def; do [ -n "$name" ] || continue [ "$def" = true ] || def=false normalised="${normalised:+$normalised }$name|$controller|$def" done <<<"$DASH_D_CLASSES" DASH_D_CLASSES="$normalised" if dash_has_crd gateways.gateway.networking.k8s.io; then dash_detect_gateways gateway if printf '%s\n' "$DASH_D_GATEWAYS" | grep -q '^gateway|'; then DASH_D_GWAPI=true; fi fi if dash_has_crd virtualservices.networking.istio.io; then DASH_D_ISTIO=true dash_detect_gateways istio fi if kubectl api-versions 2>/dev/null | grep -qx 'route.openshift.io/v1'; then DASH_D_OPENSHIFT=true DASH_D_APPS_DOMAIN=$(kubectl get ingresses.config.openshift.io cluster \ -o 'jsonpath={.spec.domain}' 2>/dev/null || true) fi provider=$(kubectl get nodes -o 'jsonpath={.items[0].spec.providerID}' \ 2>/dev/null || true) case "$provider" in aws:*) DASH_D_CLOUD=aws zone="${provider#aws:///}"; zone="${zone%%/*}" DASH_D_REGION="${zone%[a-z]}" ;; gce:*) DASH_D_CLOUD=gce ;; azure:*) DASH_D_CLOUD=azure ;; "") DASH_D_CLOUD=none ;; *) DASH_D_CLOUD=other ;; esac case "$DASH_D_CLOUD" in aws|gce|azure) DASH_D_LB=true; DASH_D_LB_KIND=cloud ;; *) if dash_has_crd ipaddresspools.metallb.io; then DASH_D_LB=true; DASH_D_LB_KIND=metallb DASH_D_POOLS=$(kubectl get ipaddresspools.metallb.io -A -o \ 'jsonpath={range .items[*]}{.metadata.name}{"|"}{range .spec.addresses[*]}{@}{","}{end}{"\n"}{end}' \ 2>/dev/null || true) DASH_D_POOLS=$(printf '%s\n' "$DASH_D_POOLS" | sed 's/,$//' | sed '/^$/d') elif [ -n "$(kubectl -n kube-system get pods -l app.kubernetes.io/name=kube-vip-ds \ -o name 2>/dev/null || true)" ]; then DASH_D_LB=true; DASH_D_LB_KIND=kube-vip elif [ -n "$(kubectl get svc -A -o \ 'jsonpath={range .items[?(@.spec.type=="LoadBalancer")]}{.status.loadBalancer.ingress[0].ip}{.status.loadBalancer.ingress[0].hostname}{"\n"}{end}' \ 2>/dev/null | sed '/^$/d' | head -1 || true)" ]; then DASH_D_LB=true; DASH_D_LB_KIND=existing fi ;; esac DASH_D_NODES=$(kubectl get nodes -o \ 'jsonpath={range .items[*]}{.metadata.name}{"|"}{.status.addresses[?(@.type=="ExternalIP")].address}{"|"}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' \ 2>/dev/null || true) while IFS='|' read -r name ext int; do [ -n "$name" ] || continue ext="${ext%% *}"; int="${int%% *}" if [ -n "$ext" ]; then nodes="${nodes:+$nodes }$name|$ext" elif [ -n "$int" ]; then case "$DASH_D_CLOUD" in aws|gce|azure) ;; *) nodes="${nodes:+$nodes }$name|$int" ;; esac fi done <<<"$DASH_D_NODES" DASH_D_NODES="$nodes" if [ -n "$DASH_D_NODES" ]; then DASH_D_NODEIP=true; fi if dash_has_crd certificates.cert-manager.io; then DASH_D_CERTMGR=true DASH_D_ISSUERS=$(dash_detect_issuers) DASH_D_ISSUERS_SKIPPED=$(printf '%s\n' "$DASH_D_ISSUERS" | grep -c '|not-ready$' || true) DASH_D_ISSUERS=$(printf '%s\n' "$DASH_D_ISSUERS" | grep -v '|not-ready$' | sed '/^$/d' || true) fi # By image, not label: charts and hand-written manifests label it differently. if kubectl get deployments -A -o \ 'jsonpath={range .items[*]}{.spec.template.spec.containers[*].image}{"\n"}{end}' \ 2>/dev/null | grep -q 'external-dns'; then DASH_D_EXTDNS=true fi DASH_D_UPSTREAM=$(dash_detect_upstream_tls) dash_detect_peers return 0 } # ---- learning from the apps already shared here ----------------------------- # The quickest safe answer to "how should people reach this?" is "the way they # already reach your other apps". Every Ingress in the cluster is read once and # summarised per IngressClass: how many apps use it, whether its address is on # a private network, how they get https, their common domain and a shared # ALB group, so the menu can offer one "same as your # other apps" choice with the answers filled in. DASH_D_PEERS="" # class|count|private|tls|issuer|issuer_kind|domain|example|alb_group|alb_scheme|alb_arn|alb_ports|address DASH_PEER_GW_KIND="" # gateway (Gateway API) or istio, for DASH_PEER_GW DASH_ALB_PORTS="" # listen-ports copied from the other apps in the same ALB group DASH_PEER_CLASS="" # the class the "same as your other apps" choice uses DASH_PEER=false # the current answers came from that choice DASH_PEER_ANN="" # annotations copied from the other apps, key=value per line DASH_RESOLVER="" # Traefik certificate resolver, when DASH_CERT=resolver DASH_PEER_GW="" # ns|name|count|private|domain|example of the Gateway most HTTPRoutes use DASH_PEER_KIND="" # ingress or gateway: what the "same as your other apps" choice uses dash_peer_field() { local class="$1" index="$2" printf '%s\n' "$DASH_D_PEERS" | awk -F'|' -v c="$class" -v i="$index" '$1 == c { print $i; exit }' } # Whether an address (a name or an IP) is on a private network: yes, no or # unknown when it can't be resolved from here. dash_address_private() { local addr="$1" ip [ -n "$addr" ] || { printf 'unknown'; return 0; } if dash_is_ip "$addr"; then ip="$addr"; else ip=$(dash_resolve "$addr"); fi if [ -z "$ip" ]; then printf 'unknown' elif dash_is_private_ip "$ip" || dash_is_private_extra "$ip"; then printf 'yes' else printf 'no' fi } # Private ranges beyond RFC 1918: carrier-grade NAT 100.64.0.0/10 (Tailscale # and similar), IPv6 unique-local fc00::/7 and link-local fe80::/10. dash_is_private_extra() { local ip="$1" second case "$ip" in 100.*) second="${ip#100.}"; second="${second%%.*}" case "$second" in *[!0-9]*|"") return 1 ;; esac [ "$second" -ge 64 ] && [ "$second" -le 127 ] ;; [fF][cCdD]*:*|[fF][eE][89aAbB]*:*) return 0 ;; *) return 1 ;; esac } dash_detect_peers() { local raw default_class="" class line count addr scheme private best="" best_score=-1 score local ctype controller DASH_D_PEERS=""; DASH_PEER_CLASS="" default_class=$(dash_class_lines | awk -F'|' '$3 == "true" { print $1; exit }') # One line per Ingress: # ns|name|class|host|tls|cluster-issuer|issuer|alb-group|alb-scheme|alb-arn|alb-ports|address # shellcheck disable=SC2016 # the go-template variables are not shell raw=$(kubectl get ingress -A -o \ 'go-template={{range .items}}{{.metadata.namespace}}|{{.metadata.name}}|{{with .spec.ingressClassName}}{{.}}{{else}}{{with .metadata.annotations}}{{with index . "kubernetes.io/ingress.class"}}{{.}}{{end}}{{end}}{{end}}|{{range $i, $r := .spec.rules}}{{if eq $i 0}}{{with $r.host}}{{.}}{{end}}{{end}}{{end}}|{{if .spec.tls}}tls{{end}}|{{with .metadata.annotations}}{{with index . "cert-manager.io/cluster-issuer"}}{{.}}{{else}}{{with index . "traefik.ingress.kubernetes.io/router.tls.certresolver"}}traefik-resolver:{{.}}{{end}}{{end}}|{{with index . "cert-manager.io/issuer"}}{{.}}{{end}}|{{with index . "alb.ingress.kubernetes.io/group.name"}}{{.}}{{end}}|{{with index . "alb.ingress.kubernetes.io/scheme"}}{{.}}{{end}}|{{with index . "alb.ingress.kubernetes.io/certificate-arn"}}{{.}}{{end}}|{{with index . "alb.ingress.kubernetes.io/listen-ports"}}{{.}}{{end}}{{else}}|||||{{end}}|{{range $i, $a := .status.loadBalancer.ingress}}{{if eq $i 0}}{{with $a.hostname}}{{.}}{{end}}{{with $a.ip}}{{.}}{{end}}{{end}}{{end}}{{"\n"}}{{end}}' \ 2>/dev/null || true) # Apps routed by the controllers' own resources count toward their class. raw="$raw $(dash_peer_rows_traefik) $(dash_peer_rows_contour)" raw=$(printf '%s\n' "$raw" | sed '/^$/d') [ -n "$raw" ] || { dash_detect_gateway_peers; dash_pick_peer_kind; return 0; } # Summarise per class; the dashboard's own Ingress does not count. DASH_D_PEERS=$(printf '%s\n' "$raw" | awk -F'|' -v self_ns="$NAMESPACE" -v self="$(dash_name)" -v def="$default_class" ' function top(arr, k, best, bestk) { best = 0; bestk = ""; for (k in arr) if (arr[k] > best) { best = arr[k]; bestk = k }; return bestk } NF >= 12 { if ($1 == self_ns && $2 == self) next c = $3; if (c == "") c = def; if (c == "") next count[c]++ if ($5 == "tls") tls[c]++ if ($6 != "") { iss[c SUBSEP $6 "|ClusterIssuer"]++ } else if ($7 != "") { iss[c SUBSEP $7 "|Issuer"]++ } if ($4 != "" && $4 !~ /^\*/) { n = split($4, parts, ".") d = $4; if (n >= 3) { d = substr($4, length(parts[1]) + 2) } dom[c SUBSEP d]++ if (!(c in example)) example[c] = $4 } if ($8 != "") { grp[c SUBSEP $8]++ } if ($9 != "") { sch[c SUBSEP $9]++ } if ($10 != "") { arn[c SUBSEP $10]++ } if ($11 != "") { lp[c SUBSEP $11]++ } if ($12 != "" && !(c in addr)) addr[c] = $12 } END { for (c in count) { delete t1; delete t2; delete t5; delete t6; delete t7; delete t8 for (k in iss) { split(k, kk, SUBSEP); if (kk[1] == c) t1[kk[2]] = iss[k] } for (k in dom) { split(k, kk, SUBSEP); if (kk[1] == c) t2[kk[2]] = dom[k] } for (k in grp) { split(k, kk, SUBSEP); if (kk[1] == c) t5[kk[2]] = grp[k] } for (k in sch) { split(k, kk, SUBSEP); if (kk[1] == c) t6[kk[2]] = sch[k] } for (k in arn) { split(k, kk, SUBSEP); if (kk[1] == c) t7[kk[2]] = arn[k] } for (k in lp) { split(k, kk, SUBSEP); if (kk[1] == c) t8[kk[2]] = lp[k] } i = top(t1); split(i, ik, "|") # No load balancer address reported (CRD routes, some controllers): # an app name on the class resolves to the same load balancer. a = addr[c]; if (a == "") a = example[c] printf "%s|%d|%s|%d|%s|%s|%s|%s|%s|%s|%s|%s\n", c, count[c], a, tls[c] + 0, ik[1], ik[2], top(t2), example[c], top(t5), top(t6), top(t7), top(t8) } }') # Field 3 carried the address; replace it with yes/no/unknown, and pick the # class to recommend: private before public, then the most used. local out="" f1 f2 f3 rest while IFS='|' read -r f1 f2 f3 rest; do [ -n "$f1" ] || continue controller=$(dash_class_lines | awk -F'|' -v c="$f1" '$1 == c { print $2; exit }') [ -n "$controller" ] || continue ctype=$(dash_controller_type "$controller") scheme=$(printf '%s' "$rest" | awk -F'|' '{ print $7 }') case "$scheme" in internal) private=yes ;; internet-facing) private=no ;; *) private=$(dash_address_private "$f3") ;; esac out="${out:+$out }$f1|$f2|$private|$rest|$f3" case "$ctype" in nginx|traefik|haproxy|haproxytech|contour|kong|other|alb) ;; *) continue ;; esac score=$f2 [ "$private" = yes ] && score=$((score + 100000)) if [ "$score" -gt "$best_score" ]; then best_score=$score; best="$f1"; fi done <<<"$DASH_D_PEERS" DASH_D_PEERS="$out" DASH_PEER_CLASS="$best" dash_detect_gateway_peers dash_pick_peer_kind return 0 } # The IngressClass a controller's own resources belong to: the one they name, # else the only class of that controller type. dash_class_for_ctype() { local ctype="$1" want="${2:-}" name controller if [ -n "$want" ] && dash_class_lines | awk -F'|' -v n="$want" '$1 == n' | grep -q .; then printf '%s' "$want"; return 0 fi name=$(dash_class_lines | while IFS='|' read -r n controller _; do [ "$(dash_controller_type "$controller")" = "$ctype" ] && printf '%s\n' "$n" done) [ "$(printf '%s\n' "$name" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 ] && printf '%s' "$name" return 0 } # Traefik IngressRoutes as peer rows (same fields as the Ingress rows). dash_peer_rows_traefik() { local crd="" out ns name cls tls resolver match host class for crd in ingressroutes.traefik.io ingressroutes.traefik.containo.us; do dash_has_crd "$crd" && break crd="" done [ -n "$crd" ] || return 0 # The match expression goes last, where read keeps any "|" it holds # (Host(`a`) || Host(`b`)); "|" keeps empty fields, unlike a tab. out=$(kubectl get "$crd" -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.metadata.annotations.kubernetes\.io/ingress\.class}{"|"}{.spec.tls}{"|"}{.spec.tls.certResolver}{"|"}{.spec.routes[0].match}{"\n"}{end}' \ 2>/dev/null || true) while IFS='|' read -r ns name cls tls resolver match; do [ -n "$name" ] || continue class=$(dash_class_for_ctype traefik "$cls") [ -n "$class" ] || continue host=$(printf '%s' "$match" | sed -n 's/[^`]*Host(`\([^`]*\)`.*/\1/p') printf '%s|%s|%s|%s|%s|%s||||||\n' "$ns" "$name" "$class" "$host" \ "$([ -n "$tls" ] && printf tls)" "${resolver:+traefik-resolver:$resolver}" done <<<"$out" } # Contour HTTPProxies as peer rows. dash_peer_rows_contour() { local out ns name cls fqdn secret class dash_has_crd httpproxies.projectcontour.io || return 0 out=$(kubectl get httpproxies.projectcontour.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.spec.ingressClassName}{"|"}{.spec.virtualhost.fqdn}{"|"}{.spec.virtualhost.tls.secretName}{"\n"}{end}' \ 2>/dev/null || true) while IFS='|' read -r ns name cls fqdn secret; do [ -n "$fqdn" ] || continue class=$(dash_class_for_ctype contour "$cls") [ -n "$class" ] || continue printf '%s|%s|%s|%s|%s|||||||\n' "$ns" "$name" "$class" "$fqdn" "$([ -n "$secret" ] && printf tls)" done <<<"$out" } # Ingress class or Gateway: what "same as your other apps" uses. dash_pick_peer_kind() { DASH_PEER_KIND="" if [ -n "$DASH_PEER_CLASS" ]; then DASH_PEER_KIND=ingress; fi if [ -n "$DASH_PEER_GW" ]; then local gw_count gw_private IFS='|' read -r _ _ gw_count gw_private _ _ <<<"$DASH_PEER_GW" # A Gateway wins over an Ingress class when more apps use it, unless only # the class is on a private network. if [ -z "$DASH_PEER_KIND" ] || { [ "$gw_private" = yes ] && [ "$(dash_peer_field "$DASH_PEER_CLASS" 3)" != yes ]; } || { [ "$gw_private" = "$(dash_peer_field "$DASH_PEER_CLASS" 3)" ] && [ "$gw_count" -gt "$(dash_peer_field "$DASH_PEER_CLASS" 2)" ]; }; then DASH_PEER_KIND=gateway fi fi return 0 } # The usable Gateway the most routes attach to: Gateway API HTTPRoutes, or # Istio VirtualServices when there are more of those. dash_detect_gateway_peers() { local raw="" best ns name count private domain example addr kind istio_raw gw_lines DASH_PEER_GW=""; DASH_PEER_GW_KIND="" # One line per route: kind|parent-ns|parent-name|route-ns|first-hostname if [ "$DASH_D_GWAPI" = true ]; then raw=$(kubectl get httproute.gateway.networking.k8s.io -A -o \ 'jsonpath={range .items[*]}{"gateway|"}{.spec.parentRefs[0].namespace}{"|"}{.spec.parentRefs[0].name}{"|"}{.metadata.namespace}{"|"}{.spec.hostnames[0]}{"\n"}{end}' \ 2>/dev/null || true) fi if [ "$DASH_D_ISTIO" = true ]; then # gateways: "ns/name" or "name" (the VirtualService's namespace); "mesh" # is in-cluster traffic only. istio_raw=$(kubectl get virtualservices.networking.istio.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.spec.gateways[0]}{"|"}{.spec.hosts[0]}{"\n"}{end}' \ 2>/dev/null | awk -F'|' '$2 != "" && $2 != "mesh" { n = index($2, "/"); if (n) printf "istio|%s|%s|%s|%s\n", substr($2, 1, n - 1), substr($2, n + 1), $1, $3; else printf "istio|%s|%s|%s|%s\n", $1, $2, $1, $3 }') raw="$raw $istio_raw" fi raw=$(printf '%s\n' "$raw" | sed '/^$/d') [ -n "$raw" ] || return 0 best=$(printf '%s\n' "$raw" | awk -F'|' -v self_ns="$NAMESPACE" ' NF >= 5 && $3 != "" { ns = ($2 == "" ? $4 : $2); k = $1 "|" ns "|" $3 count[k]++ if ($5 != "" && $5 !~ /^\*/) { n = split($5, parts, "."); d = $5; if (n >= 3) d = substr($5, length(parts[1]) + 2) dom[k SUBSEP d]++ if (!(k in ex)) ex[k] = $5 } } END { for (k in count) { bd = ""; bc = 0 for (x in dom) { split(x, xx, SUBSEP); if (xx[1] == k && dom[x] > bc) { bc = dom[x]; bd = xx[2] } } printf "%d|%s|%s|%s\n", count[k], k, bd, ex[k] } }' | sort -t'|' -k1,1nr) gw_lines="$DASH_D_GATEWAYS" while IFS='|' read -r count kind ns name domain example; do [ -n "$name" ] || continue # Only a Gateway the share flow can use (https listener, routes allowed). printf '%s\n' "$gw_lines" | grep -q "^$kind|$ns|$name|" || continue addr="" if [ "$kind" = gateway ]; then addr=$(kubectl -n "$ns" get gateway.gateway.networking.k8s.io "$name" \ -o 'jsonpath={.status.addresses[0].value}' 2>/dev/null || true) fi [ -n "$addr" ] || addr="$example" private=$(dash_address_private "$addr") DASH_PEER_GW="$ns|$name|$count|$private|$domain|$example" DASH_PEER_GW_KIND="$kind" return 0 done <<<"$best" return 0 } # One line describing the class's apps, for the menu and the confirm screen. dash_peer_summary() { local class="$1" count private example where="" count=$(dash_peer_field "$class" 2) [ -n "$count" ] || return 0 private=$(dash_peer_field "$class" 3) example=$(dash_peer_field "$class" 8) case "$private" in yes) where=", private network" ;; no) where=", public internet" ;; esac if [ "$count" -eq 1 ]; then printf '%s (1 app%s)' "${example:-$class}" "$where" else printf '%s and %s more apps%s' "${example:-$class}" "$((count - 1))" "$where" fi } # The address to suggest: kwatch.. dash_peer_address() { local domain="" candidate case "$DASH_WAY" in ingress) [ -z "$DASH_CLASS" ] || domain=$(dash_peer_field "$DASH_CLASS" 7) ;; gateway|istio) if [ -n "$DASH_PEER_GW" ] && [[ "$DASH_PEER_GW" == "$DASH_GW_NS|$DASH_GW_NAME|"* ]]; then IFS='|' read -r _ _ _ _ domain _ <<<"$DASH_PEER_GW" fi ;; esac [ -n "$domain" ] || return 0 for candidate in "$RELEASE.$domain" "$RELEASE-dashboard.$domain"; do dash_address_taken "$candidate" && continue dash_dns_name_used "$candidate" && continue printf '%s' "$candidate"; return 0 done return 0 } # True when a name has a DNS record of its own (not just a wildcard) that # points away from the other apps' load balancer: something else uses it. dash_dns_name_used() { local name="$1" server="" have target if dash_dns_find_zone "$name"; then server=$(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }'); fi have=$(dash_dns_lookup "$name" "$server" | sort -u) [ -n "$have" ] || return 1 [ "$have" != "$(dash_dns_wildcard "$name" "$server")" ] || return 1 target="" [ "$DASH_WAY" != ingress ] || [ -z "$DASH_CLASS" ] || target=$(dash_peer_field "$DASH_CLASS" 13) if [ -n "$target" ] && dash_dns_points_at "$name" "$target" "$server"; then return 1; fi return 0 } # True when a wildcard record already sends the suggested address to the # load balancer the other apps on the class use, so DNS needs no change. dash_peer_wildcard_covers() { local addr="$1" target server [ "$DASH_WAY" = ingress ] && [ -n "$DASH_CLASS" ] || return 1 target=$(dash_peer_field "$DASH_CLASS" 13) [ -n "$target" ] || return 1 server="" if dash_dns_find_zone "$addr"; then server=$(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }'); fi [ -n "$(dash_dns_wildcard "$addr" "$server")" ] || return 1 dash_dns_points_at "$addr" "$target" "$server" } # The menu line for the "same as your other apps" choice. dash_peer_menu_text() { local ns name count private example where="" if [ "$DASH_PEER_KIND" = gateway ]; then IFS='|' read -r ns name count private _ example <<<"$DASH_PEER_GW" case "$private" in yes) where=", private network" ;; no) where=", public internet" ;; esac printf '%s %s/%s, like %s%s' "$([ "$DASH_PEER_GW_KIND" = istio ] && printf 'Istio gateway' || printf Gateway)" "$ns" "$name" \ "$( [ "$count" -gt 1 ] && printf '%s and %s more apps' "${example:-its routes}" "$((count - 1))" || printf '%s' "${example:-1 app}")" "$where" else printf 'Ingress class "%s", like %s' "$DASH_PEER_CLASS" "$(dash_peer_summary "$DASH_PEER_CLASS")" fi } # Fills DASH_CERT from how the other apps on the class get https. Returns 1 # when there is no clear answer, so the question is asked instead. dash_peer_tls() { local arn if [ "$DASH_CTYPE" = alb ]; then # Without an ARN the AWS Load Balancer Controller finds the ACM # certificate that matches the host, the way the other apps get theirs. arn=$(dash_peer_field "$DASH_CLASS" 11) DASH_ACM_ARN="$arn"; DASH_CERT=acm DASH_CERT_WHY="ACM, the way your other apps on \"$DASH_CLASS\" get theirs" return 0 fi dash_tls_smart || return 1 dash_smart_apply return 0 } # ---- choosing the certificate -------------------------------------------- # Works out how the dashboard gets https from what is already true for this # name, most certain first: # 1. a valid TLS Secret for the name is already in the namespace # 2. a live https check against the load balancer the other apps use: a # trusted certificate for the new name means https is already handled, # by the load balancer (the apps carry no TLS config) or by the # ingress's default certificate (they do) # 3. the cert-manager issuer the other apps on the class use # 4. the cert-manager issuer that already issues certificates for this # domain (proven to work for it, private names included) # 5. a load balancer in front of the ingress that ends https (annotations) # Sets DASH_SMART_* and returns 0, or returns 1 when nothing is certain. DASH_SMART_CERT=""; DASH_SMART_SECRET=""; DASH_SMART_ISSUER="" DASH_SMART_ISSUER_KIND=""; DASH_SMART_ISSUER_TYPE=""; DASH_SMART_WHY="" DASH_CERT_WHY="" dash_tls_smart() { local addr="$DASH_ADDR" secret lb tls line issuer kind DASH_SMART_CERT=""; DASH_SMART_SECRET=""; DASH_SMART_ISSUER="" DASH_SMART_ISSUER_KIND=""; DASH_SMART_ISSUER_TYPE=""; DASH_SMART_WHY="" [ -n "$addr" ] && [ "$DASH_ADDR_IP" != true ] || return 1 case "$DASH_WAY" in ingress|lb|service) ;; *) return 1 ;; esac if [ "$DASH_WAY" = ingress ]; then case "$DASH_CTYPE" in nginx|traefik|haproxy|haproxytech|contour|kong|other) ;; *) return 1 ;; esac fi secret=$(dash_tls_secret_covering "$addr") if [ -n "$secret" ]; then DASH_SMART_CERT=own; DASH_SMART_SECRET="$secret" DASH_SMART_WHY="the Secret \"$secret\" already holds a valid certificate for $addr" return 0 fi if [ "$DASH_WAY" = ingress ] && [ -n "$DASH_CLASS" ]; then lb=$(dash_peer_field "$DASH_CLASS" 13) tls=$(dash_peer_field "$DASH_CLASS" 4) if [ -n "$lb" ] && dash_tls_probe "$addr" "$lb"; then if dash_upstream_tls_found "$DASH_CTYPE" || [ "${tls:-0}" -eq 0 ]; then DASH_SMART_CERT=upstream DASH_SMART_WHY="the load balancer in front of the ingress already serves a trusted certificate for $addr" else DASH_SMART_CERT=default DASH_SMART_WHY="the ingress already serves a trusted certificate for $addr (its default certificate)" fi return 0 fi issuer=$(dash_peer_field "$DASH_CLASS" 5) kind=$(dash_peer_field "$DASH_CLASS" 6) case "$issuer" in traefik-resolver:*) if [ "$DASH_CTYPE" = traefik ]; then DASH_SMART_CERT=resolver; DASH_SMART_ISSUER="${issuer#traefik-resolver:}" DASH_SMART_WHY="your other apps on \"$DASH_CLASS\" get theirs from Traefik's \"$DASH_SMART_ISSUER\" certificate resolver" return 0 fi issuer="" ;; esac if [ -n "$issuer" ]; then line=$(printf '%s\n' "$DASH_D_ISSUERS" | awk -F'|' -v n="$issuer" -v k="$kind" '$1 == n && $2 == k' | sed -n 1p) if [ -n "$line" ]; then IFS='|' read -r DASH_SMART_ISSUER DASH_SMART_ISSUER_KIND DASH_SMART_ISSUER_TYPE <<<"$line" DASH_SMART_CERT=auto DASH_SMART_WHY="your other apps on \"$DASH_CLASS\" get theirs from cert-manager (\"$issuer\")" return 0 fi fi fi line=$(dash_issuer_for_domain "$addr") if [ -n "$line" ]; then IFS='|' read -r DASH_SMART_ISSUER DASH_SMART_ISSUER_KIND DASH_SMART_ISSUER_TYPE <<<"$line" DASH_SMART_CERT=auto DASH_SMART_WHY="cert-manager's \"$DASH_SMART_ISSUER\" already issues certificates for ${addr#*.}" return 0 fi if [ "$DASH_WAY" = ingress ] && dash_upstream_tls_found "$DASH_CTYPE"; then DASH_SMART_CERT=upstream DASH_SMART_WHY="a load balancer in front of the ingress adds https" return 0 fi return 1 } dash_smart_apply() { DASH_CERT="$DASH_SMART_CERT"; DASH_TLS_SECRET="" case "$DASH_SMART_CERT" in own) DASH_TLS_SECRET="$DASH_SMART_SECRET" ;; auto) DASH_ISSUER="$DASH_SMART_ISSUER"; DASH_ISSUER_KIND="$DASH_SMART_ISSUER_KIND" DASH_ISSUER_TYPE="$DASH_SMART_ISSUER_TYPE"; DASH_TLS_SECRET=$(dash_tls_name) ;; resolver) DASH_RESOLVER="$DASH_SMART_ISSUER" ;; esac DASH_CERT_WHY="$DASH_SMART_WHY" [ "$DASH_CERT" != upstream ] || dash_upstream_note } # The menu line for the certificate the checks found. dash_smart_label() { case "$DASH_SMART_CERT" in own) printf 'Use the Secret "%s"' "$DASH_SMART_SECRET" ;; default) printf 'Already covered by the ingress certificate' ;; upstream) printf 'Already handled by the load balancer in front of the ingress' ;; auto) printf 'Automatic, from cert-manager (%s)' "$DASH_SMART_ISSUER" ;; resolver) printf "Automatic, from Traefik's \"%s\" resolver" "$DASH_SMART_ISSUER" ;; esac } # True when https to the name, sent to the given load balancer, comes back # with a certificate a browser trusts for that name. Nothing for the name # exists yet, so whatever answers is what the dashboard would get. DASH_TLS_PROBED="" # name|lb|rc of the last check, so it runs once dash_tls_probe() { local name="$1" lb="$2" rc=0 command -v curl >/dev/null 2>&1 || return 1 if [ "${DASH_TLS_PROBED%|*}" = "$name|$lb" ]; then return "${DASH_TLS_PROBED##*|}"; fi ui_busy "Checking https for $name" curl -s -o /dev/null --connect-timeout 4 --max-time 8 \ --connect-to "$name:443:$lb:443" "https://$name/" 2>/dev/null || rc=$? ui_busy_done [ "$rc" -eq 0 ] || rc=1 DASH_TLS_PROBED="$name|$lb|$rc" return "$rc" } # PEM of a TLS Secret in the namespace. dash_secret_crt() { kubectl -n "$NAMESPACE" get secret "$1" -o 'jsonpath={.data.tls\.crt}' 2>/dev/null | dash_b64d || true } # True when a PEM certificate covers the name and is valid for 30 more days. dash_cert_covers() { local crt="$1" name="$2" names n command -v openssl >/dev/null 2>&1 || return 1 printf '%s\n' "$crt" | openssl x509 -noout -checkend 2592000 >/dev/null 2>&1 || return 1 names=$(printf '%s\n' "$crt" | openssl x509 -noout -text 2>/dev/null | grep -A1 'Subject Alternative Name' | tail -1 | sed 's/DNS://g; s/,//g; s/IP Address://g') for n in $names; do [ "$n" = "$name" ] && return 0 case "$n" in '*.'*) [ "${name#*.}" = "${n#\*.}" ] && return 0 ;; esac done return 1 } # A TLS Secret in the namespace that already covers the name (a wildcard # copied in by reflector or kubed, or one from an earlier setup). dash_tls_secret_covering() { local addr="$1" name command -v openssl >/dev/null 2>&1 || return 0 while IFS= read -r name; do [ -n "$name" ] || continue # Leave out the ones this installer makes: they come and go with it. [ "$name" != "$(dash_tls_name)" ] && [ "$name" != "$(dash_backend_tls_name)" ] || continue if dash_cert_covers "$(dash_secret_crt "$name")" "$addr"; then printf '%s' "$name"; return 0; fi done < <(dash_tls_secrets) return 0 } # name|kind|type of the Ready issuer that issued the most Ready certificates # for this name's domain (*.domain, the domain, or names under it). dash_issuer_for_domain() { local addr="$1" domain best name kind [ "$DASH_D_CERTMGR" = true ] && [ -n "$DASH_D_ISSUERS" ] || return 0 domain="${addr#*.}" best=$(kubectl get certificates.cert-manager.io -A -o \ 'jsonpath={range .items[*]}{.spec.issuerRef.name}{"|"}{.spec.issuerRef.kind}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"|"}{.spec.dnsNames[*]}{"\n"}{end}' \ 2>/dev/null | awk -F'|' -v d="$domain" ' $3 == "True" { k = ($2 == "" ? "Issuer" : $2); n = split($4, names, " ") for (i = 1; i <= n; i++) { h = names[i]; sub(/^\*\./, "", h) if (h == d || substr(h, length(h) - length(d)) == "." d) { c[$1 "|" k]++; break } } } END { for (x in c) print c[x] "|" x }' | sort -t'|' -k1,1nr | cut -d'|' -f2-) while IFS='|' read -r name kind; do [ -n "$name" ] || continue printf '%s\n' "$DASH_D_ISSUERS" | awk -F'|' -v n="$name" -v k="$kind" '$1 == n && $2 == k' | sed -n 1p | grep . && return 0 done <<<"$best" return 0 } # Starts the answers from the class the other apps use. dash_peer_begin() { local controller group scheme DASH_PEER=true; DASH_PEER_ANN="" if [ "$DASH_PEER_KIND" = gateway ]; then DASH_WAY="${DASH_PEER_GW_KIND:-gateway}" IFS='|' read -r DASH_GW_NS DASH_GW_NAME _ _ _ _ <<<"$DASH_PEER_GW" DASH_CERT=handled return 0 fi DASH_WAY=ingress DASH_CLASS="$DASH_PEER_CLASS" controller=$(dash_class_lines | awk -F'|' -v c="$DASH_CLASS" '$1 == c { print $2; exit }') DASH_CTYPE=$(dash_controller_type "$controller") if [ "$DASH_CTYPE" = alb ]; then group=$(dash_peer_field "$DASH_CLASS" 9) scheme=$(dash_peer_field "$DASH_CLASS" 10) if [ "$scheme" = internet-facing ]; then DASH_ALB_INTERNAL=false; else DASH_ALB_INTERNAL=true; fi # Joining the group reuses the ALB the other apps share instead of # paying for a new one. # The group's listeners are shared, so use the ports the other apps use # rather than adding new ones to their load balancer. if [ -n "$group" ]; then DASH_PEER_ANN="alb.ingress.kubernetes.io/group.name=$group" DASH_ALB_PORTS=$(dash_peer_field "$DASH_CLASS" 12) fi fi } # Load balancers that end https themselves and pass plain http to an ingress # controller, as the controller's app.kubernetes.io/name, one per line. The # cloud's certificate annotation on the controller's LoadBalancer Service # says so: AWS (ACM), DigitalOcean, Hetzner, Oracle Cloud and Scaleway. # (GKE and AKS load balancers for Services pass TCP through.) dash_detect_upstream_tls() { # shellcheck disable=SC2016 # the go-template variables are not shell kubectl get svc -A -o \ 'go-template={{range .items}}{{if eq .spec.type "LoadBalancer"}}{{$n := ""}}{{with .spec.selector}}{{with index . "app.kubernetes.io/name"}}{{$n = .}}{{else}}{{with index . "app"}}{{$n = .}}{{end}}{{end}}{{end}}{{with .metadata.annotations}}{{if or (index . "service.beta.kubernetes.io/aws-load-balancer-ssl-cert") (index . "service.beta.kubernetes.io/do-loadbalancer-certificate-id") (index . "load-balancer.hetzner.cloud/http-certificates") (index . "service.beta.kubernetes.io/oci-load-balancer-ssl-ports") (index . "service.beta.kubernetes.io/scw-loadbalancer-certificate-ids")}}{{$n}}{{"\n"}}{{end}}{{end}}{{end}}{{end}}' \ 2>/dev/null | sed '/^$/d' | sort -u || true } # The app.kubernetes.io/name label of a controller type's pods. dash_controller_label() { case "$1" in nginx) printf 'ingress-nginx' ;; traefik) printf 'traefik' ;; haproxy) printf 'haproxy-ingress' ;; haproxytech) printf 'kubernetes-ingress' ;; contour) printf 'contour' ;; kong) printf 'kong' ;; *) return 1 ;; esac } # True when a load balancer in front of this controller type already ends https. dash_upstream_tls_found() { local label label=$(dash_controller_label "$1") || return 1 printf '%s\n' "$DASH_D_UPSTREAM" | grep -qx "$label" } # True when an IngressClass name says it serves a private network only. dash_class_private() { case "$1" in *internal*|*private*|*intranet*|*vpn*) return 0 ;; esac return 1 } # Controller types where the installer can leave https to a load balancer in # front of the controller. dash_upstream_capable() { case "$1" in nginx|traefik|haproxy|haproxytech|contour|kong|other) return 0 ;; esac return 1 } # Gateways (Gateway API) or Istio Gateways: kind|ns|name|usable|label dash_detect_gateways() { local kind="$1" out ns name protos froms hosts usable label if [ "$kind" = gateway ]; then out=$(kubectl get gateway.gateway.networking.k8s.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{range .spec.listeners[*]}{.protocol}{","}{end}{"|"}{range .spec.listeners[*]}{.allowedRoutes.namespaces.from}{","}{end}{"|"}{range .spec.listeners[*]}{.hostname}{","}{end}{"\n"}{end}' \ 2>/dev/null || true) else out=$(kubectl get gateways.networking.istio.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{range .spec.servers[*]}{.port.protocol}{","}{end}{"|"}{"All,"}{"|"}{range .spec.servers[*]}{range .hosts[*]}{@}{","}{end}{end}{"\n"}{end}' \ 2>/dev/null || true) fi while IFS='|' read -r ns name protos froms hosts; do [ -n "$name" ] || continue usable=true case ",$protos" in *,HTTPS,*|*,TLS,*) ;; *) usable=false ;; esac if [ "$kind" = gateway ] && [ "$usable" = true ]; then case ",$froms" in *,All,*|*,Selector,*) ;; *) [ "$ns" = "$NAMESPACE" ] || usable=false ;; esac fi if [ "$usable" = false ]; then DASH_D_GW_HIDDEN=$((DASH_D_GW_HIDDEN + 1)) continue fi hosts="${hosts%,}" label="namespace $ns" if [ -n "$hosts" ]; then label="$label · https for ${hosts//,/, }"; fi DASH_D_GATEWAYS="${DASH_D_GATEWAYS:+$DASH_D_GATEWAYS }$kind|$ns|$name|true|$label" done <<<"$out" return 0 } # name|kind|type for each usable issuer (SelfSigned ones are ignored). dash_detect_issuers() { local out name kind acme ca ss ready dns type result="" fields # An issuer that is not Ready never issues, so it is not offered. fields='{.spec.acme.server}{"|"}{.spec.ca.secretName}{"|"}{.spec.selfSigned}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"|"}{.spec.acme.solvers[*].dns01}{"\n"}{end}' out=$(kubectl get clusterissuers.cert-manager.io -o \ "jsonpath={range .items[*]}{.metadata.name}{\"|ClusterIssuer|\"}$fields" \ 2>/dev/null || true) out="$out $(kubectl -n "$NAMESPACE" get issuers.cert-manager.io -o \ "jsonpath={range .items[*]}{.metadata.name}{\"|Issuer|\"}$fields" \ 2>/dev/null || true)" while IFS='|' read -r name kind acme ca ss ready dns; do [ -n "$name" ] || continue if [ -n "$acme" ] && [ -n "$dns" ]; then type="ACME DNS" elif [ -n "$acme" ]; then type=ACME elif [ -n "$ca" ]; then type=CA elif [ -n "$ss" ]; then continue else type=other fi # Kept, marked, so the caller can say how many were left out. [ "$ready" = True ] || type=not-ready result="${result:+$result }$name|$kind|$type" done <<<"$out" printf '%s' "$result" } # Returns 0 when decided, 1 when none, 2 when the user has to choose. dash_pick_issuer() { local count first DASH_ISSUER=""; DASH_ISSUER_KIND=""; DASH_ISSUER_TYPE="" [ -n "$DASH_D_ISSUERS" ] || return 1 count=$(printf '%s\n' "$DASH_D_ISSUERS" | sed '/^$/d' | wc -l | tr -d ' ') first=$(printf '%s\n' "$DASH_D_ISSUERS" | grep -m1 '^letsencrypt-prod|' || true) if [ -z "$first" ] && [ "$count" -eq 1 ]; then first=$(printf '%s\n' "$DASH_D_ISSUERS" | sed -n 1p) fi [ -n "$first" ] || return 2 DASH_ISSUER="${first%%|*}" first="${first#*|}" DASH_ISSUER_KIND="${first%%|*}" DASH_ISSUER_TYPE="${first#*|}" return 0 } # ---- options (7c.2) -------------------------------------------------------- DASH_OPTIONS="" DASH_RECOMMENDED="" dash_class_count() { [ -n "$DASH_D_CLASSES" ] || { printf '0'; return 0; } printf '%s\n' "$DASH_D_CLASSES" | sed '/^$/d' | wc -l | tr -d ' ' } dash_has_default_class() { printf '%s\n' "$DASH_D_CLASSES" | grep -q '|true$' } dash_options() { local opts="1" classes classes=$(dash_class_count) if [ "$classes" -gt 0 ]; then opts="$opts 2"; fi if [ "$DASH_D_GWAPI" = true ] || [ "$DASH_D_ISTIO" = true ]; then opts="$opts 3"; fi if [ "$DASH_D_OPENSHIFT" = true ]; then opts="$opts 4"; fi if [ "$DASH_D_LB" = true ]; then opts="$opts 5"; fi if [ "$DASH_D_NODEIP" = true ]; then case "$DASH_D_CLOUD" in aws|gce|azure) ;; *) opts="$opts 6" ;; esac fi opts="$opts 7 8" if [ -n "$DASH_PEER_KIND" ]; then opts="9 $opts"; fi DASH_OPTIONS="$opts" DASH_RECOMMENDED="" if [ -n "$DASH_PEER_KIND" ]; then DASH_RECOMMENDED=9 elif [ "$DASH_D_OPENSHIFT" = true ]; then DASH_RECOMMENDED=4 elif [ "$classes" -gt 0 ] && { dash_has_default_class || [ "$classes" -eq 1 ]; }; then DASH_RECOMMENDED=2 elif [ "$DASH_D_GWAPI" = true ]; then DASH_RECOMMENDED=3 elif [ "$DASH_D_LB" = true ]; then DASH_RECOMMENDED=5 fi return 0 } dash_option_way() { case "$1" in 1) printf 'local' ;; 2) printf 'ingress' ;; 3) if [ "$DASH_D_GWAPI" = true ]; then printf 'gateway'; else printf 'istio'; fi ;; 4) printf 'route' ;; 5) printf 'lb' ;; 6) printf 'nodeport' ;; 7) printf 'proxy' ;; 8) printf 'service' ;; 9) printf 'peer' ;; esac } dash_found_types() { local controller out="" label while IFS='|' read -r _ controller _; do [ -n "$controller" ] || continue label=$(dash_type_label "$(dash_controller_type "$controller")") case ", $out," in *", $label,"*) continue ;; esac out="${out:+$out, }$label" done <<<"$DASH_D_CLASSES" printf '%s' "$out" } # ---- steps per option (7c.5) ---------------------------------------------- dash_steps() { local steps steps=$(dash_steps_base) if dash_proxies_needed; then steps="$steps proxies"; fi printf '%s' "${steps# }" } # Whether kwatch sits behind a proxy that ends https and sets X-Forwarded-* # (an ingress controller, gateway, route or outside proxy). Then kwatch needs # the proxy's addresses. A load balancer, NodePort or Service reaches kwatch # directly and kwatch serves https itself; so does an ALB, GKE or AGIC ingress. dash_proxies_needed() { case "$DASH_WAY" in ingress) case "$DASH_CTYPE" in alb|gke|agic) return 1 ;; esac return 0 ;; gateway|istio|route|proxy) return 0 ;; esac return 1 } dash_steps_base() { if [ "$DASH_PEER" = true ]; then case "$DASH_WAY" in gateway|istio) printf 'addr' ;; *) printf 'addr peer_tls' ;; esac return 0 fi case "$DASH_WAY" in ingress) case "$DASH_CTYPE" in alb) printf 'class addr alb_scheme alb_arn ips' ;; gke) printf 'class addr gke_notice gke_ip' ;; agic) printf 'class addr agic_pub tls' ;; tailscale) printf 'class ts_name' ;; ngrok) printf 'class ngrok_domain' ;; contour|kong|other) printf 'class addr tls' ;; *) printf 'class addr tls ips' ;; esac ;; gateway|istio) printf 'gw_pick addr' ;; route) printf 'addr tls ips' ;; lb) printf 'lb_who lb_pool lb_ip addr tls ips' ;; nodeport) printf 'np_port addr tls' ;; proxy) printf 'addr proxy_gate' ;; service) printf 'addr tls' ;; esac } # Whether the platform can enforce an IP limit (enforcement table). dash_ips_enforced() { case "$DASH_WAY" in route|lb) return 0 ;; ingress) case "$DASH_CTYPE" in nginx|traefik|haproxy|haproxytech|alb) return 0 ;; esac ;; esac return 1 } # Pod side values the chart would take: "trustedProxies|tlsSecret". Sharing is # implied by a proxy list or by kwatch's own TLS; neither means local. dash_values() { case "$DASH_WAY" in local) printf '|' ;; ingress) case "$DASH_CTYPE" in alb|gke|agic) printf '|%s' "$(dash_backend_tls_name)" ;; *) printf '%s|' "$DASH_PROXIES" ;; esac ;; gateway|istio|route|proxy) printf '%s|' "$DASH_PROXIES" ;; lb|nodeport|service) printf '|%s' "$DASH_TLS_SECRET" ;; *) printf '|' ;; esac } # ---- objects the installer creates ----------------------------------------- # Resource word kubectl understands for a short kind. dash_kind() { case "$1" in httproute) printf 'httproute.gateway.networking.k8s.io' ;; virtualservice) printf 'virtualservice.networking.istio.io' ;; route) printf 'route.route.openshift.io' ;; certificate) printf 'certificate.cert-manager.io' ;; managedcertificate) printf 'managedcertificate.networking.gke.io' ;; frontendconfig) printf 'frontendconfig.networking.gke.io' ;; middleware) printf 'middleware.traefik.io' ;; *) printf '%s' "$1" ;; esac } DASH_NETPOL=false # Whether the setup needs an installer-made front certificate object. dash_front_cert_needed() { case "$DASH_WAY" in ingress) case "$DASH_CTYPE" in alb|gke|tailscale|ngrok) return 1 ;; esac ;; lb|nodeport|service) ;; *) return 1 ;; esac return 0 } # What will be created, one "resource|name|hidden" per line, in order. Used by # the confirm screen, the apply step and the cleanup, so they cannot disagree. dash_plan_objects() { local svc tls svc=$(dash_name) tls=$(dash_tls_name) case "$DASH_WAY" in ingress) printf 'ingress|%s|0\n' "$svc" case "$DASH_CTYPE" in gke) printf 'managedcertificate|%s|0\n' "$svc" printf 'frontendconfig|%s|0\n' "$svc" ;; traefik) if [ "$DASH_CERT" = upstream ]; then printf 'middleware|%s-proto|0\n' "$svc"; fi if [ -n "$DASH_IPS" ]; then printf 'middleware|%s-allow|0\n' "$svc"; fi ;; esac case "$DASH_CTYPE" in alb|gke|agic) printf 'secret|%s|1\n' "$(dash_backend_tls_name)" ;; esac ;; gateway) printf 'httproute|%s|0\n' "$svc" ;; istio) printf 'virtualservice|%s|0\n' "$svc" ;; route) printf 'route|%s|0\n' "$svc" ;; lb) printf 'service|%s-lb|0\n' "$svc" ;; nodeport) printf 'service|%s-nodeport|0\n' "$svc" ;; esac if dash_front_cert_needed || { [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = agic ]; }; then case "$DASH_CERT" in auto) printf 'certificate|%s|0\n' "$tls" ;; self) printf 'secret|%s|0\n' "$tls" ;; esac fi if [ "$DASH_NETPOL" = true ]; then case "$DASH_WAY" in ingress|gateway|istio|route|lb|nodeport) printf 'networkpolicy|%s-allow|1\n' "$svc" ;; esac fi return 0 } dash_objects_csv() { dash_plan_objects | awk -F'|' 'NF { printf "%s%s/%s", sep, $1, $2; sep="," }' } # "an Ingress named kwatch-dashboard" dash_object_phrase() { local resource="$1" name="$2" case "$resource" in ingress) printf 'an Ingress named %s' "$name" ;; httproute) printf 'an HTTPRoute named %s' "$name" ;; virtualservice) printf 'a VirtualService named %s' "$name" ;; route) printf 'a Route named %s' "$name" ;; service) case "$DASH_WAY" in lb) printf 'a Service of type LoadBalancer named %s' "$name" ;; nodeport) printf 'a Service of type NodePort named %s' "$name" ;; *) printf 'a Service named %s' "$name" ;; esac ;; certificate) printf 'a Certificate named %s' "$name" ;; managedcertificate) printf 'a ManagedCertificate named %s' "$name" ;; frontendconfig) printf 'a FrontendConfig named %s' "$name" ;; middleware) printf 'a Middleware named %s' "$name" ;; secret) printf 'a Secret named %s (a self-signed certificate)' "$name" ;; networkpolicy) printf 'a NetworkPolicy named %s' "$name" ;; *) printf '%s %s' "$resource" "$name" ;; esac } # ---- manifest generation --------------------------------------------------- DASH_ANN="" dash_ann() { DASH_ANN="$DASH_ANN $1: $(dash_yaml_quote "$2") "; } dash_ann_single() { DASH_ANN="$DASH_ANN $1: '$2' "; } dash_meta() { local name="$1" printf 'metadata:\n name: %s\n namespace: %s\n labels:\n %s: %s\n app.kubernetes.io/instance: %s\n' \ "$name" "$NAMESPACE" "$DASH_LABEL_KEY" "$DASH_LABEL_VALUE" "$RELEASE" if [ -n "$DASH_ANN" ]; then printf ' annotations:\n%s' "$DASH_ANN" fi } # key=value lines of the Deployment's pod selector. dash_selector_pairs() { local deployment pairs deployment=$(deployment_name 2>/dev/null || true) pairs=$(kubectl -n "$NAMESPACE" get deployment "$deployment" -o \ 'go-template={{range $k, $v := .spec.selector.matchLabels}}{{$k}}={{$v}}{{"\n"}}{{end}}' \ 2>/dev/null || true) [ -n "$pairs" ] || pairs="app=kwatch" printf '%s\n' "$pairs" } dash_selector_yaml() { local indent="$1" pair while IFS= read -r pair; do [ -n "$pair" ] || continue printf '%s%s: %s\n' "$indent" "${pair%%=*}" "$(dash_yaml_quote "${pair#*=}")" done < <(dash_selector_pairs) } dash_gen_base_service() { DASH_ANN="" if [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = gke ]; then dash_ann_single "cloud.google.com/app-protocols" '{"dashboard":"HTTPS"}' dash_ann_single "cloud.google.com/neg" '{"ingress":true}' fi printf -- '---\napiVersion: v1\nkind: Service\n' dash_meta "$(dash_name)" printf 'spec:\n type: ClusterIP\n selector:\n' dash_selector_yaml ' ' printf ' ports:\n - name: dashboard\n port: %s\n targetPort: dashboard\n protocol: TCP\n' "$DASH_PORT" } dash_ingress_annotations() { # With https ended by a load balancer in front of the controller, the # controller only sees plain http: a redirect to https would loop, and a # router bound to the https entrypoint would never match. local upstream=false middlewares="" [ "$DASH_CERT" = upstream ] && upstream=true case "$DASH_CTYPE" in nginx) if [ "$upstream" = false ]; then dash_ann "nginx.ingress.kubernetes.io/ssl-redirect" "true" dash_ann "nginx.ingress.kubernetes.io/force-ssl-redirect" "true" elif [ "$DASH_NGINX_SNIPPETS" = true ]; then # The template sets $pass_access_scheme from the plain http # connection; the snippet comes after it, so X-Forwarded-Proto says https. dash_ann "nginx.ingress.kubernetes.io/configuration-snippet" 'set $pass_access_scheme https;' fi if [ -n "$DASH_IPS" ]; then dash_ann "nginx.ingress.kubernetes.io/whitelist-source-range" "$DASH_IPS"; fi ;; traefik) if [ "$upstream" = false ]; then dash_ann "traefik.ingress.kubernetes.io/router.entrypoints" "websecure" dash_ann "traefik.ingress.kubernetes.io/router.tls" "true" if [ "$DASH_CERT" = resolver ]; then dash_ann "traefik.ingress.kubernetes.io/router.tls.certresolver" "$DASH_RESOLVER" fi else middlewares="${middlewares:+$middlewares,}${NAMESPACE}-$(dash_name)-proto@kubernetescrd" fi if [ -n "$DASH_IPS" ]; then middlewares="${middlewares:+$middlewares,}${NAMESPACE}-$(dash_name)-allow@kubernetescrd" fi if [ -n "$middlewares" ]; then dash_ann "traefik.ingress.kubernetes.io/router.middlewares" "$middlewares" fi ;; haproxy) [ "$upstream" = true ] || dash_ann "haproxy-ingress.github.io/ssl-redirect" "true" if [ -n "$DASH_IPS" ]; then dash_ann "haproxy-ingress.github.io/allowlist-source-range" "$DASH_IPS"; fi ;; haproxytech) [ "$upstream" = true ] || dash_ann "haproxy.org/ssl-redirect" "true" if [ -n "$DASH_IPS" ]; then dash_ann "haproxy.org/allow-list" "$DASH_IPS"; fi ;; contour) [ "$upstream" = true ] || dash_ann "ingress.kubernetes.io/force-ssl-redirect" "true" ;; kong) if [ "$upstream" = false ]; then dash_ann "konghq.com/protocols" "https" dash_ann "konghq.com/https-redirect-status-code" "301" fi ;; alb) if [ "$DASH_ALB_INTERNAL" = true ]; then dash_ann "alb.ingress.kubernetes.io/scheme" "internal" else dash_ann "alb.ingress.kubernetes.io/scheme" "internet-facing" fi dash_ann "alb.ingress.kubernetes.io/target-type" "ip" if [ -n "$DASH_ALB_PORTS" ]; then dash_ann_single "alb.ingress.kubernetes.io/listen-ports" "$DASH_ALB_PORTS" case "$DASH_ALB_PORTS" in *'"HTTP"'*) dash_ann "alb.ingress.kubernetes.io/ssl-redirect" "443" ;; esac else dash_ann_single "alb.ingress.kubernetes.io/listen-ports" '[{"HTTP":80},{"HTTPS":443}]' dash_ann "alb.ingress.kubernetes.io/ssl-redirect" "443" fi dash_ann "alb.ingress.kubernetes.io/backend-protocol" "HTTPS" if [ -n "$DASH_ACM_ARN" ]; then dash_ann "alb.ingress.kubernetes.io/certificate-arn" "$DASH_ACM_ARN" fi if [ -n "$DASH_IPS" ]; then dash_ann "alb.ingress.kubernetes.io/inbound-cidrs" "$DASH_IPS"; fi ;; gke) dash_ann "networking.gke.io/managed-certificates" "$(dash_name)" dash_ann "networking.gke.io/v1beta1.FrontendConfig" "$(dash_name)" if [ -n "$DASH_GKE_IP" ]; then dash_ann "kubernetes.io/ingress.global-static-ip-name" "$DASH_GKE_IP"; fi ;; agic) dash_ann "appgw.ingress.kubernetes.io/ssl-redirect" "true" dash_ann "appgw.ingress.kubernetes.io/backend-protocol" "https" if [ "$DASH_AGIC_PRIVATE" = true ]; then dash_ann "appgw.ingress.kubernetes.io/use-private-ip" "true" else dash_ann "appgw.ingress.kubernetes.io/use-private-ip" "false" fi ;; esac return 0 } dash_gen_ingress() { local svc svc=$(dash_name) DASH_ANN="" dash_ingress_annotations dash_extra_annotations printf -- '---\napiVersion: networking.k8s.io/v1\nkind: Ingress\n' dash_meta "$svc" printf 'spec:\n ingressClassName: %s\n' "$DASH_CLASS" if [ "$DASH_CTYPE" = tailscale ]; then printf ' defaultBackend:\n service:\n name: %s\n port:\n name: dashboard\n' "$svc" printf ' tls:\n - hosts:\n - %s\n' "$DASH_TS_NAME" return 0 fi case "$DASH_CTYPE" in nginx|traefik|haproxy|haproxytech|contour|kong|other|agic) if [ "$DASH_CERT" = default ] || [ "$DASH_CERT" = resolver ]; then # No secretName: the controller serves its default certificate. printf ' tls:\n - hosts:\n - %s\n' "$DASH_ADDR" elif [ "$DASH_CERT" != upstream ]; then printf ' tls:\n - hosts:\n - %s\n secretName: %s\n' "$DASH_ADDR" "$DASH_TLS_SECRET" fi ;; esac printf ' rules:\n - host: %s\n http:\n paths:\n - path: /\n pathType: Prefix\n backend:\n service:\n name: %s\n port:\n name: dashboard\n' \ "$DASH_ADDR" "$svc" } dash_gen_middleware() { local name="${1:-$(dash_name)-allow}" item DASH_ANN="" printf -- '---\napiVersion: traefik.io/v1alpha1\nkind: Middleware\n' dash_meta "$name" case "$name" in *-proto) # The load balancer ended https, so tell kwatch the visit was https. printf 'spec:\n headers:\n customRequestHeaders:\n X-Forwarded-Proto: https\n' ;; *) printf 'spec:\n ipAllowList:\n sourceRange:\n' for item in ${DASH_IPS//,/ }; do printf ' - %s\n' "$item"; done ;; esac } dash_gen_certificate() { DASH_ANN="" printf -- '---\napiVersion: cert-manager.io/v1\nkind: Certificate\n' dash_meta "$(dash_tls_name)" printf 'spec:\n secretName: %s\n dnsNames:\n - %s\n issuerRef:\n name: %s\n kind: %s\n' \ "$(dash_tls_name)" "$DASH_ADDR" "$DASH_ISSUER" "$DASH_ISSUER_KIND" } dash_gen_managed_certificate() { DASH_ANN="" printf -- '---\napiVersion: networking.gke.io/v1\nkind: ManagedCertificate\n' dash_meta "$(dash_name)" printf 'spec:\n domains:\n - %s\n' "$DASH_ADDR" } dash_gen_frontend_config() { DASH_ANN="" printf -- '---\napiVersion: networking.gke.io/v1beta1\nkind: FrontendConfig\n' dash_meta "$(dash_name)" printf 'spec:\n redirectToHttps:\n enabled: true\n' } dash_gen_httproute() { DASH_ANN="" printf -- '---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\n' dash_meta "$(dash_name)" printf 'spec:\n parentRefs:\n - name: %s\n namespace: %s\n hostnames:\n - %s\n rules:\n - backendRefs:\n - name: %s\n port: %s\n' \ "$DASH_GW_NAME" "$DASH_GW_NS" "$DASH_ADDR" "$(dash_name)" "$DASH_PORT" } dash_gen_virtualservice() { DASH_ANN="" printf -- '---\napiVersion: networking.istio.io/v1beta1\nkind: VirtualService\n' dash_meta "$(dash_name)" printf 'spec:\n hosts:\n - %s\n gateways:\n - %s/%s\n http:\n - route:\n - destination:\n host: %s.%s.svc.cluster.local\n port:\n number: %s\n' \ "$DASH_ADDR" "$DASH_GW_NS" "$DASH_GW_NAME" "$(dash_name)" "$NAMESPACE" "$DASH_PORT" } dash_b64d() { base64 -d 2>/dev/null || base64 -D 2>/dev/null; } # PEM from a TLS Secret, indented for a YAML block scalar. dash_secret_pem() { local secret="$1" key="$2" indent="$3" data data=$(kubectl -n "$NAMESPACE" get secret "$secret" \ -o "jsonpath={.data.${key//./\\.}}" 2>/dev/null | dash_b64d || true) printf '%s\n' "$data" | sed "s/^/$indent/" } dash_gen_route() { DASH_ANN="" if [ -n "$DASH_IPS" ]; then dash_ann "haproxy.router.openshift.io/ip_whitelist" "${DASH_IPS//,/ }"; fi dash_extra_annotations printf -- '---\napiVersion: route.openshift.io/v1\nkind: Route\n' dash_meta "$(dash_name)" printf 'spec:\n host: %s\n to:\n kind: Service\n name: %s\n weight: 100\n port:\n targetPort: dashboard\n tls:\n termination: edge\n insecureEdgeTerminationPolicy: Redirect\n' \ "$DASH_ADDR" "$(dash_name)" if [ "$DASH_CERT" = own ]; then printf ' certificate: |\n' dash_secret_pem "$DASH_TLS_SECRET" tls.crt ' ' printf ' key: |\n' dash_secret_pem "$DASH_TLS_SECRET" tls.key ' ' fi } dash_lb_annotations() { if [ "$DASH_LB_INTERNAL" = true ]; then case "$DASH_D_CLOUD" in aws) dash_ann "service.beta.kubernetes.io/aws-load-balancer-internal" "true" ;; gce) dash_ann "networking.gke.io/load-balancer-type" "Internal" ;; azure) dash_ann "service.beta.kubernetes.io/azure-load-balancer-internal" "true" ;; esac fi if [ "$DASH_D_LB_KIND" = metallb ]; then if [ -n "$DASH_LB_POOL" ]; then dash_ann "metallb.universe.tf/address-pool" "$DASH_LB_POOL"; fi if [ -n "$DASH_LB_IP" ]; then dash_ann "metallb.universe.tf/loadBalancerIPs" "$DASH_LB_IP"; fi fi # external-dns reads hostnames from Ingresses itself; a Service needs the hint. if [ "$DASH_D_EXTDNS" = true ] && [ -n "$DASH_ADDR" ] && [ "$DASH_ADDR_IP" != true ]; then dash_ann "external-dns.alpha.kubernetes.io/hostname" "$DASH_ADDR" fi dash_extra_annotations return 0 } # KWATCH_DASHBOARD_ANNOTATIONS: extra annotations for the object people reach # (the Ingress, Route or load balancer Service), as key=value pairs separated # by newlines or ";". Use it for what the installer does not know about, such # as a company-specific load balancer or controller setting. DASH_EXTRA_ANNOTATIONS="${KWATCH_DASHBOARD_ANNOTATIONS:-}" dash_extra_annotations() { local pair key [ -n "$DASH_EXTRA_ANNOTATIONS$DASH_PEER_ANN" ] || return 0 while IFS= read -r pair; do pair=$(dash_trim "$pair") [ -n "$pair" ] || continue key=$(dash_trim "${pair%%=*}") [ "$key" != "$pair" ] && [ -n "$key" ] || continue dash_ann "$key" "$(dash_trim "${pair#*=}")" done < <({ printf '%s\n' "$DASH_PEER_ANN"; printf '%s\n' "$DASH_EXTRA_ANNOTATIONS" | tr ';' '\n'; }) return 0 } # Checks KWATCH_DASHBOARD_ANNOTATIONS before anything is created. dash_check_extra_annotations() { local pair key [ -n "$DASH_EXTRA_ANNOTATIONS" ] || return 0 while IFS= read -r pair; do pair=$(dash_trim "$pair") [ -n "$pair" ] || continue key=$(dash_trim "${pair%%=*}") if [ "$key" = "$pair" ] || ! [[ "$key" =~ ^([a-z0-9]([-a-z0-9.]*[a-z0-9])?/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$ ]]; then ui_error "KWATCH_DASHBOARD_ANNOTATIONS: \"$pair\" isn't a key=value annotation." return 1 fi done < <(printf '%s\n' "$DASH_EXTRA_ANNOTATIONS" | tr ';' '\n') return 0 } dash_gen_lb_service() { local item DASH_ANN="" dash_lb_annotations printf -- '---\napiVersion: v1\nkind: Service\n' dash_meta "$(dash_name)-lb" printf 'spec:\n type: LoadBalancer\n' if [ "$DASH_D_LB_KIND" = kube-vip ] && [ -n "$DASH_LB_IP" ]; then printf ' loadBalancerIP: %s\n' "$DASH_LB_IP" fi if [ -n "$DASH_IPS" ]; then printf ' loadBalancerSourceRanges:\n' for item in ${DASH_IPS//,/ }; do printf ' - %s\n' "$item"; done fi printf ' selector:\n' dash_selector_yaml ' ' printf ' ports:\n - name: https\n port: 443\n targetPort: dashboard\n protocol: TCP\n' } dash_gen_nodeport_service() { DASH_ANN="" printf -- '---\napiVersion: v1\nkind: Service\n' dash_meta "$(dash_name)-nodeport" printf 'spec:\n type: NodePort\n selector:\n' dash_selector_yaml ' ' printf ' ports:\n - name: dashboard\n port: %s\n targetPort: dashboard\n protocol: TCP\n' "$DASH_PORT" if [ -n "$DASH_NODEPORT" ]; then printf ' nodePort: %s\n' "$DASH_NODEPORT"; fi return 0 } # Namespace the ingress controller or gateway runs in, for the NetworkPolicy. dash_controller_ns() { local label="" case "$DASH_WAY" in istio) printf 'istio-system'; return 0 ;; gateway) printf '%s' "$DASH_GW_NS"; return 0 ;; route) printf 'openshift-ingress'; return 0 ;; esac case "$DASH_CTYPE" in nginx) label='app.kubernetes.io/name=ingress-nginx' ;; traefik) label='app.kubernetes.io/name=traefik' ;; haproxy) label='app.kubernetes.io/name=haproxy-ingress' ;; haproxytech) label='app.kubernetes.io/name=kubernetes-ingress' ;; contour) label='app.kubernetes.io/name=contour' ;; kong) label='app.kubernetes.io/name=kong' ;; *) return 1 ;; esac kubectl get pods -A -l "$label" \ -o 'jsonpath={.items[0].metadata.namespace}' 2>/dev/null | head -1 } # Lets the controller, or the load balancer's clients, reach the dashboard port # when a NetworkPolicy already limits the kwatch Pod (the chart's # dashboard.networkPolicyFrom peers). dash_gen_networkpolicy() { local ns="" item DASH_ANN="" printf -- '---\napiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\n' dash_meta "$(dash_name)-allow" printf 'spec:\n podSelector:\n matchLabels:\n' dash_selector_yaml ' ' printf ' policyTypes:\n - Ingress\n ingress:\n - from:\n' case "$DASH_WAY" in gateway|istio|route|ingress) ns=$(dash_controller_ns || true) ;; esac if [ -n "$ns" ]; then printf ' - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: %s\n' "$ns" elif [ -n "$DASH_IPS" ]; then for item in ${DASH_IPS//,/ }; do case "$item" in */*) ;; *:*) item="$item/128" ;; *) item="$item/32" ;; esac printf ' - ipBlock:\n cidr: %s\n' "$item" done else printf ' - ipBlock:\n cidr: 0.0.0.0/0\n' fi printf ' ports:\n - port: %s\n protocol: TCP\n' "$DASH_PORT" } # Everything the chosen setup needs, as one multi-document YAML stream. dash_manifests() { local resource name hidden dash_gen_base_service while IFS='|' read -r resource name hidden; do [ -n "$resource" ] || continue case "$resource" in ingress) dash_gen_ingress ;; middleware) dash_gen_middleware "$name" ;; certificate) dash_gen_certificate ;; managedcertificate) dash_gen_managed_certificate ;; frontendconfig) dash_gen_frontend_config ;; httproute) dash_gen_httproute ;; virtualservice) dash_gen_virtualservice ;; route) dash_gen_route ;; service) if [ "$DASH_WAY" = lb ]; then dash_gen_lb_service; else dash_gen_nodeport_service; fi ;; networkpolicy) dash_gen_networkpolicy ;; esac done < <(dash_plan_objects) return 0 } # ---- secrets and certificates ---------------------------------------------- dash_random() { local len="$1" set="$2" out out=$(head -c 8192 /dev/urandom | LC_ALL=C tr -dc "$set" | head -c "$len" || true) [ "${#out}" -eq "$len" ] || return 1 printf '%s' "$out" } dash_new_password() { local raw raw=$(dash_random 16 "$DASH_ALPHABET") || return 1 printf '%s-%s-%s-%s' "${raw:0:4}" "${raw:4:4}" "${raw:8:4}" "${raw:12:4}" } dash_label_object() { kubectl -n "$NAMESPACE" label "$1" "$2" \ "$DASH_LABEL_KEY=$DASH_LABEL_VALUE" app.kubernetes.io/instance="$RELEASE" \ --overwrite >/dev/null 2>&1 || true } dash_secret_exists() { kubectl -n "$NAMESPACE" get secret "$(dash_name)" >/dev/null 2>&1 } # Creates the password and token Secret unless it is there; never rewrites it. # Values go through files, not the command line. dash_ensure_secret() { local dir password token name name=$(dash_name) if dash_secret_exists; then return 0; fi password=$(dash_new_password) || return 1 token=$(dash_random 32 'A-Za-z0-9') || return 1 dir=$(umask 077 && mktemp -d) || return 1 printf '%s' "$password" >"$dir/password" printf '%s' "$token" >"$dir/token" if ! kubectl -n "$NAMESPACE" create secret generic "$name" \ --from-file=password="$dir/password" --from-file=token="$dir/token" >/dev/null; then rm -rf "$dir" return 1 fi rm -rf "$dir" dash_label_object secret "$name" return 0 } dash_read_password() { kubectl -n "$NAMESPACE" get secret "$(dash_name)" \ -o 'jsonpath={.data.password}' 2>/dev/null | dash_b64d } # Reads the sign-in password into DASH_PASSWORD_READ, waiting for kwatch to # write it: the chart ships the Secret empty and kwatch fills it when it starts, # so on a fresh install the first read can be empty. 0 found, 1 not yet. DASH_PASSWORD_READ="" dash_wait_password() { local tries=0 interval="$DASH_POLL_INTERVAL" max case "$interval" in ""|*[!0-9]*) interval=5 ;; esac max=$((DASH_POLL_MAX / (interval > 0 ? interval : 1))) [ "$max" -ge 1 ] || max=1 DASH_PASSWORD_READ="" while true; do DASH_PASSWORD_READ=$(dash_read_password || true) if [ -n "$DASH_PASSWORD_READ" ]; then ui_busy_done; return 0; fi tries=$((tries + 1)) if [ "$tries" -ge "$max" ]; then ui_busy_done; return 1; fi ui_busy "Waiting for kwatch to create the password" sleep "$interval" done } dash_set_password() { local password="$1" dir enc rc=0 dir=$(umask 077 && mktemp -d) || return 1 enc=$(printf '%s' "$password" | base64 | tr -d '\n') printf '{"data":{"password":"%s"}}' "$enc" >"$dir/patch.json" kubectl -n "$NAMESPACE" patch secret "$(dash_name)" --type=merge \ --patch-file "$dir/patch.json" >/dev/null || rc=1 rm -rf "$dir" return "$rc" } # dash_make_selfsigned ... dash_make_selfsigned() { local secret="$1" dir san="" item cn rc=0 shift cn="$1" command -v openssl >/dev/null 2>&1 || return 1 for item in "$@"; do if dash_is_ip "$item"; then san="${san:+$san,}IP:$item"; else san="${san:+$san,}DNS:$item"; fi done dir=$(umask 077 && mktemp -d) || return 1 printf '[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n[dn]\nCN=%s\n[v3]\nsubjectAltName=%s\nbasicConstraints=CA:FALSE\n' \ "$cn" "$san" >"$dir/openssl.cnf" if openssl req -x509 -newkey rsa:2048 -nodes -days 825 \ -keyout "$dir/tls.key" -out "$dir/tls.crt" -config "$dir/openssl.cnf" \ >/dev/null 2>&1; then dash_delete_if_ours secret "$secret" || true kubectl -n "$NAMESPACE" create secret tls "$secret" \ --cert="$dir/tls.crt" --key="$dir/tls.key" >/dev/null || rc=1 if [ "$rc" -eq 0 ]; then dash_label_object secret "$secret"; fi else rc=1 fi rm -rf "$dir" return "$rc" } # Deletes an object only when it carries the installer's label. dash_delete_if_ours() { local resource="$1" name="$2" kind owner kind=$(dash_kind "$resource") owner=$(kubectl -n "$NAMESPACE" get "$kind" "$name" \ -o "jsonpath={.metadata.labels.app\\.kwatch\\.dev/managed-by}" 2>/dev/null || true) [ "$owner" = "$DASH_LABEL_VALUE" ] || return 0 kubectl -n "$NAMESPACE" delete "$kind" "$name" --ignore-not-found >/dev/null 2>&1 } # ---- the Deployment (what the chart's template sets) ------------------------- # Strategic-merge patch for the kwatch container. state "off" removes it all; # "on" turns the dashboard on with a comma separated proxy list (empty = local) # and the Secret kwatch serves https from (empty = none). The two old variables # KWATCH_DASHBOARD_MODE and KWATCH_DASHBOARD_TRUST_PROXY are always removed: # kwatch no longer reads them. dash_deployment_patch() { local container="$1" state="$2" proxies="$3" tls="$4" name first=true var name=$(dash_name) printf '{"spec":{"template":{"spec":{' printf '"containers":[{"name":"%s",' "$container" if [ "$state" = off ]; then printf '"env":[' for var in KWATCH_DASHBOARD_ENABLED KWATCH_DASHBOARD_PORT KWATCH_DASHBOARD_TRUSTED_PROXIES \ KWATCH_DASHBOARD_MODE KWATCH_DASHBOARD_TRUST_PROXY \ KWATCH_DASHBOARD_TLS_CERT KWATCH_DASHBOARD_TLS_KEY \ KWATCH_DEPLOYMENT_NAME KWATCH_DASHBOARD_TOKEN KWATCH_DASHBOARD_PASSWORD; do if [ "$first" = true ]; then first=false; else printf ','; fi # shellcheck disable=SC2016 # "$patch" is the literal merge directive printf '{"name":"%s","$patch":"delete"}' "$var" done # shellcheck disable=SC2016 printf '],"ports":[{"containerPort":%s,"$patch":"delete"}],' "$DASH_PORT" # shellcheck disable=SC2016 printf '"volumeMounts":[{"mountPath":"/etc/kwatch/dashboard-tls","$patch":"delete"}]}],' # shellcheck disable=SC2016 printf '"volumes":[{"name":"dashboard-tls","$patch":"delete"}]' else printf '"env":[' printf '{"name":"KWATCH_DASHBOARD_ENABLED","value":"true"},' printf '{"name":"KWATCH_DASHBOARD_PORT","value":"%s"},' "$DASH_PORT" printf '{"name":"KWATCH_DASHBOARD_TRUSTED_PROXIES","value":"%s"},' "$proxies" printf '{"name":"KWATCH_DEPLOYMENT_NAME","value":"%s"},' "$(deployment_name 2>/dev/null || printf '%s' "$RELEASE")" # shellcheck disable=SC2016 printf '{"name":"KWATCH_DASHBOARD_MODE","$patch":"delete"},{"name":"KWATCH_DASHBOARD_TRUST_PROXY","$patch":"delete"},' if [ -n "$tls" ]; then printf '{"name":"KWATCH_DASHBOARD_TLS_CERT","value":"/etc/kwatch/dashboard-tls/tls.crt"},' printf '{"name":"KWATCH_DASHBOARD_TLS_KEY","value":"/etc/kwatch/dashboard-tls/tls.key"},' else # shellcheck disable=SC2016 printf '{"name":"KWATCH_DASHBOARD_TLS_CERT","$patch":"delete"},{"name":"KWATCH_DASHBOARD_TLS_KEY","$patch":"delete"},' fi printf '{"name":"KWATCH_DASHBOARD_TOKEN","value":null,"valueFrom":{"secretKeyRef":{"name":"%s","key":"token","optional":true}}},' "$name" printf '{"name":"KWATCH_DASHBOARD_PASSWORD","value":null,"valueFrom":{"secretKeyRef":{"name":"%s","key":"password","optional":true}}}' "$name" printf '],"ports":[{"name":"dashboard","containerPort":%s}]' "$DASH_PORT" if [ -n "$tls" ]; then printf ',"volumeMounts":[{"name":"dashboard-tls","mountPath":"/etc/kwatch/dashboard-tls","readOnly":true}]}],' printf '"volumes":[{"name":"dashboard-tls","secret":{"secretName":"%s"}}]' "$tls" else # shellcheck disable=SC2016 printf ',"volumeMounts":[{"mountPath":"/etc/kwatch/dashboard-tls","$patch":"delete"}]}],' # shellcheck disable=SC2016 printf '"volumes":[{"name":"dashboard-tls","$patch":"delete"}]' fi fi printf '}}}}' } # Current pod side values: "enabled|trustedProxies|tlscert". dash_env_value() { kubectl -n "$NAMESPACE" get deployment "$1" \ -o "jsonpath={.spec.template.spec.containers[*].env[?(@.name==\"$2\")].value}" \ 2>/dev/null || true } dash_current_values() { local deployment="$1" printf '%s|%s|%s' "$(dash_env_value "$deployment" KWATCH_DASHBOARD_ENABLED)" \ "$(dash_env_value "$deployment" KWATCH_DASHBOARD_TRUSTED_PROXIES)" \ "$(dash_env_value "$deployment" KWATCH_DASHBOARD_TLS_CERT)" } # dash_apply_values . 0 ok, 1 failed, 2 rollout slow. dash_apply_values() { local state="$1" proxies="$2" tls="$3" deployment container deployment=$(deployment_name) || return 1 container=$(kwatch_container_name "$deployment") || container=kwatch kubectl -n "$NAMESPACE" patch deployment "$deployment" --type=strategic \ -p "$(dash_deployment_patch "$container" "$state" "$proxies" "$tls")" >/dev/null || return 1 if ! kubectl -n "$NAMESPACE" rollout status "deployment/$deployment" \ --timeout=90s >/dev/null 2>&1; then return 2 fi return 0 } # ---- recorded setup and state ---------------------------------------------- DASH_STATE="unknown" # off local shared unknown DASH_REC_WAY=""; DASH_REC_CTYPE=""; DASH_REC_ADDR=""; DASH_REC_CERT=""; DASH_REC_TLS="" DASH_REC_IPS=""; DASH_REC_OBJECTS=""; DASH_REC_LINK="" dash_record_clear() { DASH_REC_DNS="" DASH_REC_WAY=""; DASH_REC_CTYPE=""; DASH_REC_ADDR=""; DASH_REC_CERT=""; DASH_REC_TLS="" DASH_REC_IPS=""; DASH_REC_OBJECTS=""; DASH_REC_LINK="" } # Reads what the installer recorded on the dashboard Service. dash_record_load() { local out line key value dash_record_clear # shellcheck disable=SC2016 # the go-template variables are not shell out=$(kubectl -n "$NAMESPACE" get service "$(dash_name)" -o \ 'go-template={{range $k, $v := .metadata.annotations}}{{$k}}={{$v}}{{"\n"}}{{end}}' \ 2>/dev/null || true) while IFS= read -r line; do case "$line" in app.kwatch.dev/*=*) ;; *) continue ;; esac key="${line%%=*}"; key="${key#app.kwatch.dev/}" value="${line#*=}" case "$key" in way) DASH_REC_WAY="$value" ;; ctype) DASH_REC_CTYPE="$value" ;; address) DASH_REC_ADDR="$value" ;; cert) DASH_REC_CERT="$value" ;; tls-secret) DASH_REC_TLS="$value" ;; ips) DASH_REC_IPS="$value" ;; objects) DASH_REC_OBJECTS="$value" ;; link) DASH_REC_LINK="$value" ;; dns) DASH_REC_DNS="$value" ;; esac done <<<"$out" return 0 } # Records the current answers on the Service. dash_record_write() { local link="$1" gw="" dns="$DASH_DNS_MADE" dns_name if [ -n "$DASH_GW_NAME" ]; then gw="$DASH_GW_NS/$DASH_GW_NAME"; fi # A record made by an earlier share for the same address is still ours. if [ -z "$dns" ] && [ -n "${DASH_PREV_DNS:-}" ]; then IFS='|' read -r _ _ _ dns_name _ _ <<<"$DASH_PREV_DNS" [ "$dns_name" != "$DASH_ADDR" ] || dns="$DASH_PREV_DNS" fi kubectl -n "$NAMESPACE" annotate service "$(dash_name)" --overwrite \ "app.kwatch.dev/way=$DASH_WAY" "app.kwatch.dev/ctype=$DASH_CTYPE" \ "app.kwatch.dev/class=$DASH_CLASS" "app.kwatch.dev/gateway=$gw" \ "app.kwatch.dev/address=$DASH_ADDR" "app.kwatch.dev/cert=$DASH_CERT" \ "app.kwatch.dev/issuer=$DASH_ISSUER" "app.kwatch.dev/tls-secret=$DASH_TLS_SECRET" \ "app.kwatch.dev/ips=$DASH_IPS" "app.kwatch.dev/objects=$(dash_objects_csv)" \ "app.kwatch.dev/link=$link" "app.kwatch.dev/dns=$dns" >/dev/null 2>&1 || true return 0 } DASH_STATE_ADDR="" # Sets DASH_STATE and DASH_STATE_ADDR from the Deployment and the record. dash_state() { local deployment values enabled proxies tlscert DASH_STATE="unknown" DASH_STATE_ADDR="" deployment=$(deployment_name 2>/dev/null || true) [ -n "$deployment" ] || return 0 kubectl -n "$NAMESPACE" get deployment "$deployment" -o name >/dev/null 2>&1 || return 0 values=$(dash_current_values "$deployment") IFS='|' read -r enabled proxies tlscert <<<"$values" if [ "$enabled" != true ]; then DASH_STATE=off return 0 fi # Shared is implied by a proxy list or by kwatch's own TLS. if [ -n "$proxies" ] || [ -n "$tlscert" ]; then DASH_STATE=shared dash_record_load DASH_STATE_ADDR="$DASH_REC_LINK" [ -n "$DASH_STATE_ADDR" ] || DASH_STATE_ADDR="$DASH_REC_ADDR" return 0 fi DASH_STATE=local return 0 } dash_state_text() { case "$DASH_STATE" in off) printf 'off' ;; local) printf 'on, local only' ;; shared) case "$DASH_STATE_ADDR" in "") printf 'shared at your own address' ;; http*) printf 'shared at %s' "$DASH_STATE_ADDR" ;; *) printf 'shared at https://%s' "$DASH_STATE_ADDR" ;; esac ;; *) printf 'unknown' ;; esac } # ---- preflight and common output ------------------------------------------- DASH_MIN_VERSION="v1.2.0-rc.0" # True when the installed kwatch has the dashboard (v1.2.0-rc.N and newer). # An unknown or unparseable version counts as too old. dash_version_ok() { local version="${INSTALL_VERSION:-}" valid_release_version "$version" || return 1 [ "$(compare_release_versions "$version" "$DASH_MIN_VERSION")" != "-1" ] } # Prints the one-line notice and returns 1 when kwatch is too old. dash_version_gate() { dash_version_ok && return 0 ui_info "This version of kwatch doesn't have the dashboard yet. Upgrade kwatch from the main menu first (it needs v1.2 or newer)." return 1 } # Prints the failure copy (3.2, 3.8) and returns 1 when the dashboard cannot be # used right now. Silent and 0 when fine. dash_preflight() { local deployment dash_version_gate || return 1 LICENSE_COMMUNITY_CACHE="" if license_is_community; then ui_info "The dashboard is part of Pro, which is free during early access." ui_info "Add your key first: choose \"License / Pro\" in the main menu." ui_info "Get a free key at https://app.kwatch.dev" return 1 fi if ! kubectl cluster-info >/dev/null 2>&1; then ui_error "I can't reach your cluster." ui_detail "Check that kubectl works: kubectl get nodes" return 1 fi deployment=$(deployment_name 2>/dev/null || true) if [ -z "$deployment" ] || ! deployment_is_running "$deployment"; then ui_error "kwatch isn't running yet." ui_detail "Install or repair it from the main menu, then try again." return 1 fi return 0 } # The block printed once (3.7). dash_print_block() { local link="$1" password="$2" { printf '\nYour dashboard is ready.\n\n' printf ' Link %s\n' "$link" printf ' Password %s\n\n' "$password" printf 'Save the password now, in your password manager.\n' printf 'You can show it again from Dashboard > Show my access link and password.\n' } >&2 if dash_tty_out; then ui_detail "Clear your terminal when you've saved it." fi return 0 } dash_open_url() { local url="$1" if [ "$(uname -s)" = Darwin ] && command -v open >/dev/null 2>&1; then open "$url" >/dev/null 2>&1 && return 0 fi if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 && return 0 fi if command -v wslview >/dev/null 2>&1; then wslview "$url" >/dev/null 2>&1 && return 0 fi if command -v cmd.exe >/dev/null 2>&1; then cmd.exe /c start "$url" >/dev/null 2>&1 && return 0 fi ui_info "Open this link in your browser:" printf '%s\n' "$url" >&2 return 0 } dash_port_busy() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null } # First free port from 8080 to 8090. dash_free_port() { local port for port in 8080 8081 8082 8083 8084 8085 8086 8087 8088 8089 8090; do if ! dash_port_busy "$port"; then printf '%s' "$port" return 0 fi done return 1 } DASH_PF_PID="" # Foreground port-forward; opens the browser; returns when closed. dash_port_forward() { local lport="$1" i previous kubectl -n "$NAMESPACE" port-forward "svc/$(dash_name)" "$lport:$DASH_PORT" \ >/dev/null 2>&1 & DASH_PF_PID=$! previous=$(trap -p INT) trap 'kill "$DASH_PF_PID" 2>/dev/null || true' INT for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do dash_port_busy "$lport" && break kill -0 "$DASH_PF_PID" 2>/dev/null || break sleep 0.5 done if kill -0 "$DASH_PF_PID" 2>/dev/null; then ui_info "Opening the dashboard in your browser. Keep this window open. Press Ctrl-C to close the connection." dash_open_url "http://localhost:$lport" fi wait "$DASH_PF_PID" 2>/dev/null || true if kill -0 "$DASH_PF_PID" 2>/dev/null; then kill "$DASH_PF_PID" 2>/dev/null || true wait "$DASH_PF_PID" 2>/dev/null || true ui_info "Connection closed. The dashboard is still on. Choose \"Open the dashboard\" to connect again." else ui_warn "The connection to the cluster dropped. Choose \"Open the dashboard\" to connect again." fi if [ -n "$previous" ]; then eval "$previous"; else trap - INT; fi DASH_PF_PID="" return 0 } # Failure text for a failed kubectl: first line, then the right hint. dash_explain_error() { local err="$1" kinds="$2" first first=$(printf '%s\n' "$err" | sed -n '1p') case "$err" in *"admission webhook"*|*denied\ the\ request*) ui_error "Your cluster refused the Ingress: $first" ui_detail "Some clusters have rules for which names or annotations are allowed. Ask whoever runs the cluster, then try again." ;; *[Ff]orbidden*|*"cannot create"*|*"cannot patch"*) ui_error "Kubernetes said no: $first" ui_detail "Your kubectl user needs permission to create $kinds in the \"$NAMESPACE\" namespace." ;; *) ui_error "I couldn't finish: ${first:-kubectl returned an error}." ;; esac } # ---- prompts --------------------------------------------------------------- dash_q() { ui_busy_clear printf '%s%s%s%s\n' "$UI_BOLD" "$UI_BRAND" "$1" "$UI_RESET" >&2 } # dash_choose ; labels in DASH_M_LABELS, captions in DASH_M_HINTS. DASH_M_LABELS=() DASH_M_HINTS=() dash_choose() { if [ "${#DASH_M_HINTS[@]}" -ne "${#DASH_M_LABELS[@]}" ]; then # Captions are all-or-nothing in ui_select_captioned. ui_select "$1" "${DASH_M_LABELS[@]}" return fi ui_select_captioned "$1" "${#DASH_M_LABELS[@]}" \ "${DASH_M_LABELS[@]}" "${DASH_M_HINTS[@]}" } # Enter continues (0), Esc goes back (2), n cancels (1). dash_confirm_key() { local prompt="$1" key rest answer if ui_interactive; then ui_detail "$prompt" while true; do IFS= read -rsn1 key || input_ended case "$key" in '') return 0 ;; n|N) return 1 ;; $'\x1b') IFS= read -rsn2 -t "$UI_ESC_TIMEOUT" rest || rest="" if [ -z "$rest" ]; then return 2; fi ;; esac done fi while true; do answer=$(ask "Continue? (y to continue, n to cancel, back to go back)" "y") || exit_expected case "$answer" in y|Y|yes|Yes) return 0 ;; n|N|no|No) return 1 ;; *) if is_back_choice "$answer"; then return 2; fi ;; esac done } # ---- the questions (7c.4 to 7c.6) ------------------------------------------ # Each returns 0 (answered), 2 (Esc: go back) or 3 (nothing to ask). dash_class_lines() { printf '%s\n' "$DASH_D_CLASSES" | sed '/^$/d'; } dash_q_class() { local name controller def count i=0 default=0 label idx hint count=$(dash_class_count) # One class: use it, no question. if [ "$count" -eq 1 ]; then IFS='|' read -r name controller def <<<"$(dash_class_lines | sed -n 1p)" DASH_CLASS="$name"; DASH_CTYPE=$(dash_controller_type "$controller") return 3 fi DASH_M_LABELS=(); DASH_M_HINTS=() while IFS='|' read -r name controller def; do [ -n "$name" ] || continue label="$(dash_type_label "$(dash_controller_type "$controller")") (class \"$name\"" if [ "$def" = true ]; then label="$label, default)" default=$i else label="$label)" fi hint="" if [ -n "$(dash_peer_field "$name" 2)" ]; then hint="Used by $(dash_peer_summary "$name")." [ "$name" = "$DASH_PEER_CLASS" ] && default=$i elif dash_class_private "$name"; then hint="Its name says it serves your private network only."; fi if dash_upstream_tls_found "$(dash_controller_type "$controller")"; then hint="${hint:+$hint }A load balancer in front of it already adds https." fi DASH_M_LABELS+=("$label") DASH_M_HINTS+=("$hint") i=$((i + 1)) done < <(dash_class_lines) dash_q "Which ingress should carry the dashboard?" ui_detail "Your cluster has more than one. Each is a separate front door. For a private link, pick the one your internal tools use." idx=$(dash_choose "$default") || return 2 IFS='|' read -r name controller def <<<"$(dash_class_lines | sed -n "$((idx + 1))p")" DASH_CLASS="$name"; DASH_CTYPE=$(dash_controller_type "$controller") return 0 } dash_q_gw_pick() { local idx kind lines count hidden="$DASH_D_GW_HIDDEN" ns name text if [ "$DASH_D_GWAPI" = true ] && [ "$DASH_D_ISTIO" = true ] && printf '%s\n' "$DASH_D_GATEWAYS" | grep -q '^istio|'; then DASH_M_LABELS=("Gateway API (an HTTPRoute)" "Istio (a VirtualService)") DASH_M_HINTS=("Pick this if your Gateway is a Kubernetes Gateway." "Pick this if you route traffic with Istio's own Gateway.") dash_q "Which should I use?" idx=$(dash_choose 0) || return 2 if [ "$idx" = 0 ]; then DASH_WAY=gateway; else DASH_WAY=istio; fi elif [ "$DASH_D_GWAPI" = true ]; then DASH_WAY=gateway else DASH_WAY=istio fi kind="$DASH_WAY" lines=$(printf '%s\n' "$DASH_D_GATEWAYS" | grep "^$kind|" || true) count=$(printf '%s' "$lines" | sed '/^$/d' | wc -l | tr -d ' ') if [ "$count" -eq 0 ]; then ui_error "That Gateway doesn't serve https." ui_detail "Add an https listener with a certificate to it, or pick another." return 2 fi if [ "$count" -gt 1 ]; then DASH_M_LABELS=(); DASH_M_HINTS=() while IFS='|' read -r _ ns name _ text; do DASH_M_LABELS+=("$name $text") done <<<"$lines" dash_q "Which Gateway should carry the dashboard?" ui_detail "A Gateway is a front door set up once for the whole cluster." if [ "$hidden" -gt 0 ]; then ui_detail "$hidden more Gateways don't allow kwatch to use them."; fi idx=$(dash_choose 0) || return 2 lines=$(printf '%s\n' "$lines" | sed -n "$((idx + 1))p") fi IFS='|' read -r _ DASH_GW_NS DASH_GW_NAME _ <<<"$lines" return 0 } DASH_PREV_ADDR="" DASH_PREV_IPS="" dash_address_hint() { case "$DASH_WAY" in lb|nodeport) printf 'A name like kwatch.example.com, or press Enter to use the IP address (then your browser will warn about the certificate).' ;; route) printf 'OpenShift gives every app an address like this and already has a certificate for it.' ;; proxy) printf 'The https address people will open, the one your proxy shows. kwatch uses it to check that everything works.' ;; service) printf 'The name you will point at kwatch. kwatch uses it for its certificate.' ;; *) printf "For example kwatch.example.com. You need to control this name at your DNS provider. (DNS is the phone book that turns a name into a machine's address.)" ;; esac } DASH_TAKEN_BY="" # Name already used by another object that publishes hosts. dash_address_taken() { local addr="$1" out ns name hosts case "$DASH_WAY" in ingress) out=$(kubectl get ingress -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{range .spec.rules[*]}{.host}{" "}{end}{"\n"}{end}' 2>/dev/null || true) ;; gateway) out=$(kubectl get httproute.gateway.networking.k8s.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{range .spec.hostnames[*]}{@}{" "}{end}{"\n"}{end}' 2>/dev/null || true) ;; istio) out=$(kubectl get virtualservice.networking.istio.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{range .spec.hosts[*]}{@}{" "}{end}{"\n"}{end}' 2>/dev/null || true) ;; route) out=$(kubectl get route.route.openshift.io -A -o \ 'jsonpath={range .items[*]}{.metadata.namespace}{"|"}{.metadata.name}{"|"}{.spec.host}{"\n"}{end}' 2>/dev/null || true) ;; *) return 1 ;; esac while IFS='|' read -r ns name hosts; do [ -n "$name" ] || continue if [ "$ns" = "$NAMESPACE" ] && [ "$name" = "$(dash_name)" ]; then continue; fi case " $hosts " in *" $addr "*) DASH_TAKEN_BY="(namespace $ns)"; return 0 ;; esac done <<<"$out" return 1 } dash_taken_kind() { case "$DASH_WAY" in gateway) printf 'HTTPRoute' ;; istio) printf 'VirtualService' ;; route) printf 'Route' ;; *) printf 'Ingress' ;; esac } dash_q_addr() { local default="" answer rc dash_wildcard_checked="" dash_wildcard_note="" if [ "$DASH_WAY" = route ] && [ -n "$DASH_D_APPS_DOMAIN" ]; then default="$(dash_name)-$NAMESPACE.$DASH_D_APPS_DOMAIN" fi if [ -z "$default" ] && [ -n "$DASH_PREV_ADDR" ]; then default="$DASH_PREV_ADDR"; fi if [ -z "$default" ]; then default=$(dash_peer_address); fi while true; do dash_q "What address should your team use?" if [ -n "$default" ] && [ -z "$DASH_PREV_ADDR" ]; then ui_detail "Suggested from the domain your other apps use. Press Enter to keep it." if [ "${dash_wildcard_checked:-}" != "$default" ]; then dash_wildcard_checked="$default" dash_wildcard_note="" if dash_peer_wildcard_covers "$default"; then dash_wildcard_note="*.${default#*.} already points at that ingress, so DNS needs no change." fi fi [ -z "$dash_wildcard_note" ] || ui_detail "$dash_wildcard_note" fi ui_detail "$(dash_address_hint)" rc=0 answer=$(ask_or_back "Address" "$default") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -z "$answer" ]; then case "$DASH_WAY" in lb|nodeport) DASH_ADDR=""; DASH_ADDR_IP=true; return 0 ;; service) DASH_ADDR=""; return 0 ;; *) return 2 ;; esac fi if ! dash_normalize_address "$answer"; then ui_warn "$DASH_ADDR_ERR" continue fi if dash_address_taken "$DASH_ADDR_CLEAN"; then ui_error "Another $(dash_taken_kind) already uses $DASH_ADDR_CLEAN $DASH_TAKEN_BY." ui_detail "Pick a different address, or remove that one first." continue fi DASH_ADDR="$DASH_ADDR_CLEAN" DASH_ADDR_IP=false return 0 done } dash_tls_secrets() { kubectl -n "$NAMESPACE" get secret --field-selector type=kubernetes.io/tls \ -o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null | sed '/^$/d' || true } # Checks a TLS Secret: exists, TLS type, covers the address, not expired. dash_check_tls_secret() { local name="$1" type crt text names date covered=false n if ! type=$(kubectl -n "$NAMESPACE" get secret "$name" -o 'jsonpath={.type}' 2>/dev/null); then ui_error "The Secret \"$name\" isn't in the \"$NAMESPACE\" namespace." ui_detail "Create it there, or pick another." return 1 fi crt=$(kubectl -n "$NAMESPACE" get secret "$name" -o 'jsonpath={.data.tls\.crt}' 2>/dev/null | dash_b64d || true) if [ "$type" != kubernetes.io/tls ] || [ -z "$crt" ] || [ -z "$(kubectl -n "$NAMESPACE" get secret "$name" -o 'jsonpath={.data.tls\.key}' 2>/dev/null)" ]; then ui_error "That Secret isn't a TLS Secret." ui_detail "It needs the keys tls.crt and tls.key." return 1 fi command -v openssl >/dev/null 2>&1 || return 0 if ! printf '%s\n' "$crt" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then date=$(printf '%s\n' "$crt" | openssl x509 -noout -enddate 2>/dev/null | sed 's/^notAfter=//') ui_error "That certificate expired on ${date:-an earlier date}." ui_detail "Renew it, or pick another." return 1 fi if [ -n "$DASH_ADDR" ]; then text=$(printf '%s\n' "$crt" | openssl x509 -noout -text 2>/dev/null || true) names=$(printf '%s\n' "$text" | grep -A1 'Subject Alternative Name' | tail -1 | sed 's/DNS://g; s/,//g; s/IP Address://g') for n in $names; do if [ "$n" = "$DASH_ADDR" ]; then covered=true; fi case "$n" in '*.'*) if [ "${DASH_ADDR#*.}" = "${n#\*.}" ]; then covered=true; fi ;; esac done if [ "$covered" = false ]; then ui_error "That certificate doesn't cover $DASH_ADDR." ui_detail "It covers: ${names:-no names}. Pick another Secret or change the address." return 1 fi fi return 0 } dash_q_issuer() { local idx name kind type DASH_M_LABELS=(); DASH_M_HINTS=() while IFS='|' read -r name kind type; do [ -n "$name" ] || continue DASH_M_LABELS+=("$name ($kind, $type)") done <<<"$DASH_D_ISSUERS" dash_q "Which certificate issuer should I use?" ui_detail "An issuer is the thing in your cluster that gets certificates for you (cert-manager)." idx=$(dash_choose 0) || return 2 IFS='|' read -r DASH_ISSUER DASH_ISSUER_KIND DASH_ISSUER_TYPE <<<"$(printf '%s\n' "$DASH_D_ISSUERS" | sed -n "$((idx + 1))p")" return 0 } dash_q_tls() { local issuer_rc=0 can_auto=false only_own=false only_self=false idx name secrets count rc local upstream=false upstream_found=false hint local -a acts=() DASH_CERT=""; DASH_TLS_SECRET="" if [ "$DASH_WAY" = route ]; then if [ -n "$DASH_D_APPS_DOMAIN" ] && [[ "$DASH_ADDR" == *".$DASH_D_APPS_DOMAIN" ]]; then DASH_CERT=handled return 3 fi only_own=true fi if [ "$DASH_ADDR_IP" = true ]; then only_self=true; fi if [ "$DASH_D_CERTMGR" = true ] && [ -n "$DASH_ADDR" ] && [ "$only_own" = false ]; then dash_pick_issuer || issuer_rc=$? if [ "$issuer_rc" -eq 2 ]; then dash_q_issuer || return 2 issuer_rc=0 fi if [ "$issuer_rc" -eq 0 ]; then can_auto=true; fi fi DASH_M_LABELS=(); DASH_M_HINTS=() if [ "$DASH_WAY" = ingress ] && dash_upstream_capable "$DASH_CTYPE"; then upstream=true if dash_upstream_tls_found "$DASH_CTYPE"; then upstream_found=true; fi fi if [ "$DASH_WAY" != route ] && dash_tls_smart; then DASH_M_LABELS+=("$(dash_e ✨ "$(dash_smart_label)")") DASH_M_HINTS+=("Recommended: $DASH_SMART_WHY.") acts+=(smart) # The same answer isn't offered twice. [ "$DASH_SMART_CERT" != upstream ] || { upstream_found=false; upstream=false; } [ "$DASH_SMART_CERT" != auto ] || [ "$DASH_SMART_ISSUER" != "$DASH_ISSUER" ] || can_auto=false fi if [ "$upstream_found" = true ]; then DASH_M_LABELS+=("$(dash_e '🛡️ ' "Already handled by the load balancer in front of the ingress")") DASH_M_HINTS+=("I found a load balancer that adds https before traffic reaches this ingress. Nothing to set up here.") acts+=(upstream) fi if [ "$can_auto" = true ]; then hint="Free, trusted by browsers, renewed for you. Needs your address to be reachable so the issuer can check it." if [ "$DASH_ISSUER_TYPE" = ACME ]; then hint="Free, trusted by browsers, renewed for you. The issuer checks the address over the internet, so this fails for a private address." fi DASH_M_LABELS+=("$(dash_e 🪄 "Automatic, from cert-manager ($DASH_ISSUER)")") DASH_M_HINTS+=("$hint") acts+=(auto) fi if [ "$only_self" = false ]; then DASH_M_LABELS+=("$(dash_e 🔑 "A certificate I already have")") DASH_M_HINTS+=("Use a TLS Secret that is already in the cluster. Pick this if your company issues certificates.") acts+=(own) fi if [ "$only_own" = false ]; then DASH_M_LABELS+=("$(dash_e '⚠️ ' "Make one myself (self-signed)")") DASH_M_HINTS+=("Works right away, but every browser warns until it is told to trust it. Fine for a test, not for a team.") acts+=(self) fi if [ "$upstream" = true ] && [ "$upstream_found" = false ]; then DASH_M_LABELS+=("$(dash_e '🛡️ ' "Added before the ingress (load balancer or CDN)")") DASH_M_HINTS+=("Pick this if something in front of the ingress already ends https, like an AWS NLB or ALB with a certificate, and passes plain http on.") acts+=(upstream) fi dash_q "How should kwatch get its https certificate?" ui_detail "A certificate is what lets browsers show the lock and trust the address." if [ "$DASH_D_CERTMGR" = true ] && [ "$DASH_D_ISSUERS_SKIPPED" -gt 0 ]; then ui_detail "I left out $DASH_D_ISSUERS_SKIPPED cert-manager issuer(s) that aren't Ready." fi if [ "$can_auto" = false ] && [ "$only_own" = false ] && [ "$only_self" = false ]; then ui_detail "I didn't find cert-manager, so automatic certificates aren't available." fi if [ "$only_own" = true ]; then ui_detail "The Secret's certificate and key are copied into the Route." fi if [ "${#acts[@]}" -eq 1 ] && [ "${acts[0]}" = self ]; then idx=0 else idx=$(dash_choose 0) || return 2 fi case "${acts[$idx]}" in smart) dash_smart_apply ;; upstream) DASH_CERT=upstream; DASH_TLS_SECRET="" dash_upstream_note ;; auto) DASH_CERT=auto; DASH_TLS_SECRET=$(dash_tls_name) ;; self) ui_warn "Browsers will warn \"not private\" each time until you trust the certificate. Anyone you share the link with will see that." if [ "$(ask_yes_no "Use a self-signed certificate?" "y")" != true ]; then return 2 fi DASH_CERT=self; DASH_TLS_SECRET=$(dash_tls_name) ;; own) secrets=$(dash_tls_secrets) DASH_M_LABELS=(); DASH_M_HINTS=() count=0 while IFS= read -r name; do [ -n "$name" ] || continue DASH_M_LABELS+=("$name"); count=$((count + 1)) done <<<"$secrets" DASH_M_LABELS+=("Type a name") dash_q "Which Secret holds the certificate?" idx=$(dash_choose 0) || return 2 if [ "$idx" -ge "$count" ]; then rc=0 name=$(ask_or_back "Secret name" "") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi else name=$(printf '%s\n' "$secrets" | sed -n "$((idx + 1))p") fi dash_check_tls_secret "$name" || { ui_pause; return 2; } DASH_CERT=own; DASH_TLS_SECRET="$name" ;; esac return 0 } # https the way the other apps get it; asks only when that isn't clear. dash_q_peer_tls() { if dash_peer_tls; then ui_info "https: $(dash_cert_line), because $DASH_CERT_WHY." return 3 fi dash_q_tls } dash_q_ips() { local answer rc seen_all=false DASH_IPS="" if ! dash_ips_enforced; then return 3; fi while true; do dash_q "Limit it to certain IP addresses?" ui_detail "An IP address is the number a computer connects from. Leave empty to allow everyone with the password." rc=0 answer=$(ask_or_back "Example: 203.0.113.0/24, 198.51.100.7 [everyone]" "$DASH_PREV_IPS") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -z "$answer" ]; then DASH_IPS=""; return 0; fi rc=0 dash_parse_ips "$answer" || rc=$? if [ "$rc" -eq 3 ]; then if [ "$seen_all" = true ]; then DASH_IPS=""; return 0; fi seen_all=true ui_warn "$DASH_IPS_ERR" continue fi if [ "$rc" -ne 0 ]; then ui_warn "$DASH_IPS_ERR"; continue; fi DASH_IPS="$DASH_IPS_CLEAN" if [ "$DASH_CERT" = upstream ]; then ui_warn "Behind a load balancer, the ingress may see the load balancer's address instead of the visitor's. Include it, or check that the load balancer keeps client addresses." fi if [ "$DASH_IPS_ALL_PRIVATE" = true ]; then ui_warn "Private addresses (like 10.0.0.5) only work if people connect from inside your network." fi ui_detail "The password is still needed." return 0 done } # Starting point for the proxy range: the address range the cluster gives its # pods, which is where an ingress controller or gateway running inside the # cluster connects from. Read from the control plane when it is visible, else # guessed from kwatch's own pod address. Prints nothing when neither works. dash_default_proxies() { local cidr="" pod ip cidr=$(kubectl -n kube-system get pods -l component=kube-controller-manager \ -o 'jsonpath={.items[0].spec.containers[0].command}' 2>/dev/null | grep -o -- '--cluster-cidr=[^",]*' | head -n 1 | cut -d= -f2) || cidr="" if [ -n "$cidr" ] && dash_valid_cidr "$cidr"; then printf '%s' "$cidr" return 0 fi pod=$(kwatch_pod_name 2>/dev/null) || return 0 ip=$(kubectl -n "$NAMESPACE" get pod "$pod" -o 'jsonpath={.status.podIP}' 2>/dev/null || true) if dash_is_ipv4 "$ip"; then printf '%s.0.0/16' "${ip%.*.*}"; fi return 0 } # Parses "a, b c" into DASH_PROXIES_CLEAN. Returns 1 on a bad or empty list. DASH_PROXIES_CLEAN="" DASH_PROXIES_ERR="" dash_parse_proxies() { local raw="$1" item list="" DASH_PROXIES_CLEAN="" DASH_PROXIES_ERR="" raw="${raw//,/ }" for item in $raw; do case "$item" in */0) DASH_PROXIES_ERR="$item means every address. Give the range your ingress or gateway connects from." return 1 ;; esac if ! dash_valid_cidr "$item"; then DASH_PROXIES_ERR="\"$item\" isn't an IP address or range. Try 10.244.0.0/16." return 1 fi list="${list:+$list,}$item" done if [ -z "$list" ]; then DASH_PROXIES_ERR="Give at least one address or range." return 1 fi DASH_PROXIES_CLEAN="$list" return 0 } dash_q_proxies() { local answer rc suggested prev DASH_PROXIES="" dash_proxies_needed || return 3 IFS='|' read -r _ prev _ <<<"${DASH_PREV_VALUES:-}" suggested=$(dash_default_proxies) while true; do dash_q "Which addresses will your ingress, gateway or proxy connect to kwatch from?" ui_detail "It sits in front of kwatch and adds https. kwatch believes the visitor's address and the https it reports only when they come from these addresses." if [ -n "$suggested" ]; then ui_detail "Pods in this cluster use $suggested. That is a good answer when it runs inside the cluster; use a smaller range if you know it." else ui_detail "Example: 10.244.0.0/16. If you don't know the range, ask whoever runs the cluster for the pod address range." fi rc=0 answer=$(ask_or_back "Example: 10.244.0.0/16, 192.0.2.10" "${prev:-$suggested}") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") rc=0 dash_parse_proxies "$answer" || rc=$? if [ "$rc" -ne 0 ]; then ui_warn "$DASH_PROXIES_ERR"; continue; fi DASH_PROXIES="$DASH_PROXIES_CLEAN" return 0 done } dash_q_alb_scheme() { local idx DASH_M_LABELS=("$(dash_e 🌍 "Anyone on the internet (internet-facing)")" "$(dash_e 🏢 "Only inside our network (internal)")") DASH_M_HINTS=("The sign-in page is visible to everyone. Pick this for people outside your network." "Reachable only from your private network or VPN. Pick this for a team that works through a VPN.") dash_q "Who should be able to reach the dashboard?" ui_detail "An ALB is Amazon's own load balancer for web traffic." idx=$(dash_choose 1) || return 2 if [ "$idx" = 1 ]; then DASH_ALB_INTERNAL=true; else DASH_ALB_INTERNAL=false; fi return 0 } dash_q_alb_arn() { local answer rc region while true; do dash_q "What is the ARN of the certificate in AWS Certificate Manager (ACM)?" ui_detail "An ARN is the long ID AWS gives it. Find it in AWS: Certificate Manager > your certificate." ui_detail "Example: arn:aws:acm:eu-west-1:123456789012:certificate/abcd-..." rc=0 answer=$(ask_or_back "ARN" "$DASH_ACM_ARN") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if ! dash_valid_arn "$answer"; then ui_error "${DASH_ARN_ERR%% *}" ui_detail "${DASH_ARN_ERR#* }" continue fi region=$(printf '%s' "$answer" | cut -d: -f4) if [ -n "$DASH_D_REGION" ] && [ "$region" != "$DASH_D_REGION" ]; then ui_error "That certificate is in $region but your cluster is in $DASH_D_REGION." ui_detail "An ALB can only use certificates from its own region." continue fi DASH_ACM_ARN="$answer" DASH_CERT=acm return 0 done } dash_q_gke_notice() { DASH_CERT=google ui_info "Google will create and renew the certificate for ${DASH_ADDR}." ui_info "It starts working after your DNS record exists, and can take a while." return 0 } dash_q_gke_ip() { local answer rc=0 dash_q "Do you have a reserved static IP in Google Cloud?" ui_detail "Without one, the address can change if the load balancer is recreated. Type its name, or press Enter to skip." answer=$(ask_or_back "Static IP name [skip]" "") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -n "$answer" ] && command -v gcloud >/dev/null 2>&1; then if ! gcloud compute addresses describe "$answer" --global >/dev/null 2>&1; then ui_error "I can't find a global static IP named \"$answer\" in your Google Cloud project." ui_detail "Check the name, or press Enter to skip." return 2 fi fi DASH_GKE_IP="$answer" return 0 } dash_q_agic_pub() { local idx DASH_M_LABELS=("$(dash_e 🌍 "Public address")" "$(dash_e 🏢 "Private address")") DASH_M_HINTS=("Reachable from the internet." "Reachable only from your Azure network or VPN.") dash_q "Should the gateway use its public or its private address?" ui_detail "Application Gateway is Microsoft's load balancer for web traffic." idx=$(dash_choose 1) || return 2 if [ "$idx" = 1 ]; then DASH_AGIC_PRIVATE=true; else DASH_AGIC_PRIVATE=false; fi return 0 } dash_q_ts_name() { local answer rc while true; do dash_q "What should the dashboard be called on your Tailscale network?" ui_detail "People on your Tailscale network will open https://..ts.net. Tailscale handles the certificate." rc=0 answer=$(ask_or_back "Name" "kwatch") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_lower "$(dash_trim "$answer")") if dash_valid_label "$answer"; then DASH_TS_NAME="$answer"; DASH_CERT=handled return 0 fi ui_warn "Use lowercase letters, digits and hyphens, up to 63 characters." done } dash_q_ngrok_domain() { local answer rc while true; do dash_q "Which ngrok domain should the dashboard use?" ui_detail "For example kwatch.ngrok.app, or your own domain set up in ngrok." rc=0 answer=$(ask_or_back "Domain" "") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -z "$answer" ]; then ui_warn "Type the domain from your ngrok dashboard." continue fi if ! dash_normalize_address "$answer"; then ui_warn "$DASH_ADDR_ERR"; continue; fi DASH_ADDR="$DASH_ADDR_CLEAN"; DASH_CERT=handled ui_warn "This makes the sign-in page reachable by anyone on the internet, and ngrok sees the traffic when it adds https. The password protects the dashboard." return 0 done } dash_q_lb_who() { local idx case "$DASH_D_CLOUD" in aws|gce|azure) ;; *) DASH_LB_INTERNAL=false; return 3 ;; esac DASH_M_LABELS=("$(dash_e 🌍 "Anyone on the internet")" "$(dash_e 🏢 "Only inside our network")") DASH_M_HINTS=("Pick this for people outside your network. The sign-in page is visible to everyone." "Pick this for a team that works through an office network or VPN.") dash_q "Who should be able to reach the dashboard?" idx=$(dash_choose 1) || return 2 if [ "$idx" = 1 ]; then DASH_LB_INTERNAL=true; else DASH_LB_INTERNAL=false; fi return 0 } dash_q_lb_pool() { local idx count name addrs DASH_LB_POOL="" if [ "$DASH_D_LB_KIND" != metallb ]; then return 3; fi count=$(printf '%s\n' "$DASH_D_POOLS" | sed '/^$/d' | wc -l | tr -d ' ') if [ "$count" -le 1 ]; then if [ "$count" -eq 1 ]; then DASH_LB_POOL="${DASH_D_POOLS%%|*}"; fi return 3 fi DASH_M_LABELS=(); DASH_M_HINTS=() while IFS='|' read -r name addrs; do DASH_M_LABELS+=("$name ${addrs//,/ }") done <<<"$DASH_D_POOLS" dash_q "Which address pool should the load balancer use?" ui_detail "A pool is the range of addresses MetalLB hands out." idx=$(dash_choose 0) || return 2 DASH_LB_POOL=$(printf '%s\n' "$DASH_D_POOLS" | sed -n "$((idx + 1))p") DASH_LB_POOL="${DASH_LB_POOL%%|*}" return 0 } dash_q_lb_ip() { local answer rc pool_addrs ok range DASH_LB_IP="" case "$DASH_D_LB_KIND" in metallb|kube-vip) ;; *) return 3 ;; esac while true; do dash_q "Want a fixed address from that pool?" rc=0 answer=$(ask_or_back "Address [no, take any free one]" "") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -z "$answer" ]; then return 0; fi if ! dash_is_ipv4 "$answer"; then ui_warn "\"$answer\" isn't an IP address."; continue; fi if [ "$DASH_D_LB_KIND" = metallb ] && [ -n "$DASH_LB_POOL" ]; then pool_addrs=$(printf '%s\n' "$DASH_D_POOLS" | grep "^$DASH_LB_POOL|" | head -1 | cut -d'|' -f2) ok=false for range in ${pool_addrs//,/ }; do if dash_ip_in_range "$answer" "$range"; then ok=true; fi done if [ "$ok" = false ]; then ui_warn "$answer is not inside the pool $DASH_LB_POOL (${pool_addrs//,/ })." continue fi fi DASH_LB_IP="$answer" return 0 done } dash_q_np_port() { local answer rc used while true; do dash_q "Which port should open on each machine?" ui_detail "Ports from 30000 to 32767. Press Enter to let the cluster choose." rc=0 answer=$(ask_or_back "Port [automatic]" "") || rc=$? if [ "$rc" -eq 2 ]; then return 2; fi answer=$(dash_trim "$answer") if [ -z "$answer" ]; then DASH_NODEPORT=""; return 0; fi if ! [[ "$answer" =~ ^[0-9]+$ ]] || [ "$answer" -lt 30000 ] || [ "$answer" -gt 32767 ]; then ui_warn "Pick a number from 30000 to 32767." continue fi used=$(kubectl get svc -A -o \ 'jsonpath={range .items[*]}{.metadata.name}{"|"}{.metadata.namespace}{"|"}{range .spec.ports[*]}{.nodePort}{" "}{end}{"\n"}{end}' \ 2>/dev/null | awk -F'|' -v p="$answer" -v me="$(dash_name)-nodeport" \ '$1 != me { n = split($3, a, " "); for (i = 1; i <= n; i++) if (a[i] == p) { print $1 " in " $2; exit } }' || true) if [ -n "$used" ]; then ui_warn "That port is already used by $used." continue fi DASH_NODEPORT="$answer" return 0 done } dash_q_proxy_gate() { local answer rc DASH_CERT=none ui_warn "Before you continue" cat >&2 </dev/null || true) [ -n "$pods" ] || return 1 line=$(printf '%s\n' "$pods" | grep -F -- "--ingress-class=$DASH_CLASS\"" | sed -n 1p) [ -n "$line" ] || line=$(printf '%s\n' "$pods" | sed -n 1p) IFS='|' read -r ns args image <<<"$line" cm=$(printf '%s' "$args" | tr ',' '\n' | sed -n 's/.*--configmap=\([^"]*\).*/\1/p' | sed -n 1p) [ -n "$cm" ] || return 1 cm="${cm//\$(POD_NAMESPACE)/$ns}" DASH_NGINX_CM="$cm" allow=$(kubectl -n "${cm%%/*}" get configmap "${cm#*/}" -o 'jsonpath={.data.allow-snippet-annotations}' 2>/dev/null || true) risk=$(kubectl -n "${cm%%/*}" get configmap "${cm#*/}" -o 'jsonpath={.data.annotations-risk-level}' 2>/dev/null || true) [ "$allow" = true ] || return 1 [ "$risk" = Critical ] && return 0 tag="${image##*:v}"; tag="${tag%%@*}" major="${tag%%.*}"; minor="${tag#*.}"; minor="${minor%%.*}" case "$major$minor" in *[!0-9]*|"") return 1 ;; esac # Before v1.12 there was no risk level: the allow flag is enough. [ "$major" -lt 1 ] || { [ "$major" -eq 1 ] && [ "$minor" -lt 12 ]; } } dash_who_line() { if [ -n "$DASH_IPS" ]; then printf 'Only %s, with the password' "${DASH_IPS//,/, }" else printf 'Anyone who reaches the address, with the password' fi } dash_confirm_text() { local title resource name hidden cols narrow=false row local -a rows=() cols=$(ui_columns 2>/dev/null || printf 80) if [ "$cols" -lt 80 ] 2>/dev/null; then narrow=true; fi if [ -n "$DASH_ADDR" ]; then title="Share the dashboard at https://$DASH_ADDR" elif [ "$DASH_CTYPE" = tailscale ]; then title="Share the dashboard on your Tailscale network" else title="Share the dashboard" fi printf '%s\n\n' "$title" rows+=("Way|$(dash_way_line)") if [ "$DASH_PEER" = true ]; then rows+=("Like|$(dash_peer_menu_text | sed 's/^[^,]*, like //')"); fi if [ -n "$DASH_CERT" ]; then rows+=("Certificate|$(dash_cert_line)"); fi if [ -n "$DASH_CERT_WHY" ]; then rows+=("|because $DASH_CERT_WHY"); fi rows+=("Who|$(dash_who_line)") if [ -n "$DASH_PROXIES" ]; then rows+=("Proxies|Believed only from ${DASH_PROXIES//,/, }"); fi rows+=("Sign-in|Password; plain http is refused") for row in "${rows[@]}"; do if [ "$narrow" = true ]; then printf '%s: %s\n' "${row%%|*}" "${row#*|}" else printf ' %-12s%s\n' "${row%%|*}" "${row#*|}" fi done if [ -n "$DASH_IPS" ]; then printf '\nOnly these addresses can open it: %s\n' "${DASH_IPS//,/, }" elif ! dash_ips_enforced; then printf '\nAnyone who reaches the address can see the sign-in page.\n' fi printf '\nI will create:\n' while IFS='|' read -r resource name hidden; do [ -n "$resource" ] || continue if [ "$hidden" = 1 ]; then continue; fi printf ' - %s\n' "$(dash_object_phrase "$resource" "$name")" done < <(dash_plan_objects) case "$DASH_WAY" in proxy|service) printf ' - a Service named %s (internal only)\n' "$(dash_name)" ;; esac return 0 } dash_object_title() { case "$1" in ingress) printf 'Ingress' ;; httproute) printf 'HTTPRoute' ;; virtualservice) printf 'VirtualService' ;; route) printf 'Route' ;; service) printf 'Service' ;; certificate) printf 'Certificate' ;; managedcertificate) printf 'ManagedCertificate' ;; frontendconfig) printf 'FrontendConfig' ;; middleware) printf 'Middleware' ;; secret) printf 'Secret' ;; networkpolicy) printf 'NetworkPolicy' ;; *) printf '%s' "$1" ;; esac } DASH_PREV_OBJECTS="" # 7c.10: what a switch removes afterwards, from the recorded setup. dash_removal_text() { local old resource name lines="" [ -n "$DASH_PREV_OBJECTS" ] || return 0 for old in ${DASH_PREV_OBJECTS//,/ }; do resource="${old%%/*}"; name="${old#*/}" if dash_plan_objects | grep -q "^$resource|$name|"; then continue; fi lines="$lines - $(dash_object_title "$resource") $name" done [ -n "$lines" ] || return 0 printf 'This replaces the current setup. After the new one works I will remove:%s' "$lines" } # Returns 0 continue, 1 cancel, 2 back. dash_q_confirm() { local rc=0 removing ui_screen "dashboard" dash_confirm_text >&2 removing=$(dash_removal_text) if [ -n "$removing" ]; then printf '\n%s\n' "$removing" >&2 fi printf '\n' >&2 dash_confirm_key "Enter to continue, Esc to go back, n to cancel" || rc=$? return "$rc" } # ---- create, wait, check (7c.9) -------------------------------------------- DASH_ADDR_FOUND="" # Waits for an external address on a kind/name; sets DASH_ADDR_FOUND. dash_wait_address() { local kind="$1" name="$2" waited=0 value DASH_ADDR_FOUND="" while true; do value=$(kubectl -n "$NAMESPACE" get "$kind" "$name" -o \ 'jsonpath={.status.loadBalancer.ingress[0].ip}{.status.loadBalancer.ingress[0].hostname}' \ 2>/dev/null || true) if [ -n "$value" ]; then DASH_ADDR_FOUND="$value"; return 0; fi [ "$waited" -ge "$DASH_ADDRESS_WAIT" ] && return 1 sleep 2 waited=$((waited + 2)) done } dash_print_dns() { local addr="$1" target="$2" type=A first [ -n "$addr" ] || return 0 if [ -z "$target" ]; then ui_info "Your cluster hasn't reported its address yet. Find it with:" printf ' %s\n' "$DASH_ADDR_CMD" >&2 return 0 fi dash_is_ip "$target" || type=CNAME if [ "$DASH_D_EXTDNS" = true ]; then ui_info "external-dns runs in this cluster. If it manages the zone for $addr, it adds this record for you:" printf ' Type %s\n Name %s\n Value %s\n' "$type" "$addr" "$target" >&2 return 0 fi { printf '\nNow point your address at the cluster. At your DNS provider, add:\n\n' printf ' Type %s\n Name %s\n Value %s\n\n' "$type" "$addr" "$target" printf "Until that exists, the link won't open. DNS changes can take a while to\nspread; the certificate is issued once they have.\n" } >&2 first="${addr%%.*}" if [ "$(printf '%s' "$addr" | tr -cd . | wc -c | tr -d ' ')" -ge 2 ]; then ui_detail "Some providers want just \"$first\" for this field." fi } # ---- adding the DNS record -------------------------------------------------- # The record people need is known once the cluster reports its address, and # the person running the installer usually has their DNS provider's CLI (or # an API token) at hand. So: find who hosts the zone from its public NS # records, and add the record with that provider's own tool, after asking. # Nothing is written when the name already points at the cluster (a wildcard # record, or a record made earlier), when external-dns will make it, or when # the provider's tool is missing or not signed in; then the record is shown # to add by hand, as before. # # KWATCH_DASHBOARD_DNS auto (default): add it when the name is free # overwrite: also replace a record that points # elsewhere (the menu asks instead) # off: only show the record DASH_DNS_MODE="${KWATCH_DASHBOARD_DNS:-auto}" DASH_DNS_ZONE="" # the zone the name belongs to, e.g. example.com DASH_DNS_NS="" # its name servers, space separated DASH_DNS_PROVIDER="" # route53 cloudflare clouddns azure digitalocean other DASH_DNS_WHY="" # why the record could not be added automatically DASH_DNS_MADE="" # provider|zone|aws-profile|name|type|value of a record this run added DASH_PREV_DNS="" # the same, recorded by an earlier share # Name servers of a name, space separated; empty when it has none. dash_dns_ns() { if command -v dig >/dev/null 2>&1; then dig +short +time=3 +tries=1 NS "$1" 2>/dev/null | grep -v '^;' | sed 's/\.$//' | tr '\n' ' ' | sed 's/ $//' elif command -v host >/dev/null 2>&1; then host -W 3 -t NS "$1" 2>/dev/null | awk '/name server/ { sub(/\.$/, "", $4); printf "%s ", $4 }' | sed 's/ $//' fi } # Every address a name resolves to, one per line, asked of one name server # when given. Asking the zone's own server keeps a "not found" answer out of # the local resolver's cache, which would otherwise hide the new record. dash_dns_lookup() { local name="$1" server="${2:-}" at="" if command -v dig >/dev/null 2>&1; then [ -z "$server" ] || at="@$server" # shellcheck disable=SC2086 # $at is empty or one word dig +short +time=3 +tries=1 $at "$name" 2>/dev/null | grep -v '^;' | sed 's/\.$//' elif command -v host >/dev/null 2>&1; then host -W 3 "$name" $server 2>/dev/null | awk '/has address/ { print $4 } /is an alias for/ { sub(/\.$/, "", $6); print $6 }' fi } # Finds the zone a name belongs to and who hosts it. dash_dns_find_zone() { local name="$1" d ns DASH_DNS_ZONE=""; DASH_DNS_NS=""; DASH_DNS_PROVIDER="" d="${name#*.}" while [[ "$d" == *.* ]]; do ns=$(dash_dns_ns "$d") if [ -n "$ns" ]; then DASH_DNS_ZONE="$d"; DASH_DNS_NS="$ns"; break; fi d="${d#*.}" done [ -n "$DASH_DNS_ZONE" ] || return 1 case " $DASH_DNS_NS" in *awsdns*) DASH_DNS_PROVIDER=route53 ;; *.ns.cloudflare.com*) DASH_DNS_PROVIDER=cloudflare ;; *googledomains.com*) DASH_DNS_PROVIDER=clouddns ;; *azure-dns.*) DASH_DNS_PROVIDER=azure ;; *digitalocean.com*) DASH_DNS_PROVIDER=digitalocean ;; *) DASH_DNS_PROVIDER=other ;; esac return 0 } dash_dns_provider_label() { case "$1" in route53) printf 'AWS Route 53' ;; cloudflare) printf 'Cloudflare' ;; clouddns) printf 'Google Cloud DNS' ;; azure) printf 'Azure DNS' ;; digitalocean) printf 'DigitalOcean' ;; *) printf 'your DNS provider' ;; esac } # The answer a wildcard record gives for names next to this one (*.parent), # one address per line, or nothing when there is no wildcard. A made-up name # that can't exist is looked up: only a wildcard can answer it. dash_dns_wildcard() { local name="$1" server="${2:-}" dash_dns_lookup "kwatch-no-such-name-$RANDOM$RANDOM.${name#*.}" "$server" | sort -u } # True when the name already reaches the same addresses as the target. dash_dns_points_at() { local name="$1" target="$2" server="${3:-}" have want ip have=$(dash_dns_lookup "$name" "$server") [ -n "$have" ] || return 1 if printf '%s\n' "$have" | grep -qxiF "$target"; then return 0; fi if dash_is_ip "$target"; then want="$target"; else want=$(dash_dns_lookup "$target"); fi for ip in $want; do dash_is_ip "$ip" || continue if printf '%s\n' "$have" | grep -qxF "$ip"; then return 0; fi done return 1 } # The account the provider's tool is signed in to, for the question. dash_dns_identity() { case "$DASH_DNS_PROVIDER" in route53) dash_aws sts get-caller-identity --query Account --output text 2>/dev/null | awk 'NF { printf "AWS account %s", $1 }' if [ -n "$DASH_DNS_AWS_PROFILE" ]; then printf ' (profile %s)' "$DASH_DNS_AWS_PROFILE" elif [ -n "${AWS_PROFILE:-}" ]; then printf ' (profile %s)' "$AWS_PROFILE" fi ;; clouddns) gcloud config get-value project 2>/dev/null | awk 'NF { printf "project %s", $1 }' ;; azure) az account show --query name -o tsv 2>/dev/null | awk 'NF { printf "subscription %s", $0 }' ;; esac } # Whether this machine can write to the zone; sets DASH_DNS_WHY when not. dash_dns_can_write() { DASH_DNS_WHY="" case "$DASH_DNS_PROVIDER" in route53) command -v aws >/dev/null 2>&1 || DASH_DNS_WHY="the aws CLI isn't installed" ;; cloudflare) [ -n "${CLOUDFLARE_API_TOKEN:-${CF_API_TOKEN:-}}" ] || DASH_DNS_WHY="set CLOUDFLARE_API_TOKEN (a token with Zone.DNS edit) to let me add it" command -v curl >/dev/null 2>&1 || DASH_DNS_WHY="curl isn't installed" ;; clouddns) command -v gcloud >/dev/null 2>&1 || DASH_DNS_WHY="the gcloud CLI isn't installed" ;; azure) command -v az >/dev/null 2>&1 || DASH_DNS_WHY="the az CLI isn't installed" ;; digitalocean) command -v doctl >/dev/null 2>&1 || DASH_DNS_WHY="the doctl CLI isn't installed" ;; *) DASH_DNS_WHY="I don't know how to add records at $(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }')" ;; esac [ -z "$DASH_DNS_WHY" ] } dash_dns_first_line() { printf '%s\n' "$1" | sed '/^[[:space:]]*$/d' | sed -n 1p; } # The aws CLI, with the profile that owns the zone once one is found. DASH_DNS_AWS_PROFILE="" DASH_DNS_R53_ID="" dash_aws() { if [ -n "$DASH_DNS_AWS_PROFILE" ]; then AWS_PROFILE="$DASH_DNS_AWS_PROFILE" aws "$@"; else aws "$@"; fi } # Sets DASH_DNS_R53_ID to the Route 53 zone that the internet really uses for # $DASH_DNS_ZONE (a global, not output: the profile it finds must outlive a # subshell). A zone name alone is not enough: an account can hold a copy of # the zone that was never delegated (an old or test copy), and a record added # there is never seen. So the zone's own name servers must be the ones the # public NS records name. The signed-in profile is tried first, then the # other local profiles, stopping at the first match. DASH_DNS_STRAY="" # "account (profile)" holding an undelegated copy, for the message dash_dns_route53_zone() { local zone="$DASH_DNS_ZONE" query ids id ns want profile local -a profiles=() query="HostedZones[?Name=='$zone.' && Config.PrivateZone==\`false\`].Id" want=$(printf '%s\n' $DASH_DNS_NS | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ' ') DASH_DNS_R53_ID=""; DASH_DNS_STRAY="" # A profile already known to hold the zone first, then the signed-in one, # then the rest. profiles=() [ -z "$DASH_DNS_AWS_PROFILE" ] || profiles+=("$DASH_DNS_AWS_PROFILE") profiles+=("") for profile in $(aws configure list-profiles 2>/dev/null); do [ "$profile" != "${AWS_PROFILE:-default}" ] && [ "$profile" != "$DASH_DNS_AWS_PROFILE" ] || continue profiles+=("$profile") done ui_busy "Looking for the $zone zone in your AWS accounts" for profile in "${profiles[@]}"; do DASH_DNS_AWS_PROFILE="$profile" ids=$(dash_aws route53 list-hosted-zones-by-name --dns-name "$zone" --max-items 10 \ --output text --query "$query" --cli-connect-timeout 5 2>/dev/null || true) [ -n "$ids" ] && [ "$ids" != None ] || continue for id in $ids; do id="${id##*/}" ns=$(dash_aws route53 get-hosted-zone --id "$id" --output text \ --query 'DelegationSet.NameServers' 2>/dev/null | tr '\t' '\n' | tr '[:upper:]' '[:lower:]' | sort -u | tr '\n' ' ') if [ -n "$ns" ] && [ "$ns" = "$want" ]; then DASH_DNS_R53_ID="$id" ui_busy_done return 0 fi DASH_DNS_STRAY="${DASH_DNS_STRAY:-account $(dash_aws sts get-caller-identity --query Account --output text 2>/dev/null)${profile:+ (profile $profile)}}" done done ui_busy_done DASH_DNS_AWS_PROFILE="" return 1 } # Adds (or replaces) one record. 0 done; 1 failed, with DASH_DNS_WHY. dash_dns_write() { local name="$1" type="$2" value="$3" zone="$DASH_DNS_ZONE" out id label token rg api body label="${name%."$zone"}" case "$DASH_DNS_PROVIDER" in route53) if dash_dns_route53_zone; then id="$DASH_DNS_R53_ID"; else if [ -n "$DASH_DNS_STRAY" ]; then DASH_DNS_WHY="$DASH_DNS_STRAY has a $zone zone, but not the one the internet uses (its name servers differ from $(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }')); none of your AWS profiles can see the real one" else DASH_DNS_WHY="none of your AWS profiles can see the zone $zone (or they aren't signed in)" fi return 1 fi if [ -n "$DASH_DNS_AWS_PROFILE" ]; then ui_detail "Using AWS profile \"$DASH_DNS_AWS_PROFILE\", which has the zone $zone." fi body=$(printf '{"Comment":"kwatch dashboard","Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"%s","Type":"%s","TTL":300,"ResourceRecords":[{"Value":"%s"}]}}]}' \ "$name" "$type" "$value") out=$(dash_aws route53 change-resource-record-sets --hosted-zone-id "$id" --change-batch "$body" 2>&1) || { DASH_DNS_WHY="aws: $(dash_dns_first_line "$out")"; return 1; } ;; cloudflare) token="${CLOUDFLARE_API_TOKEN:-${CF_API_TOKEN:-}}" api="https://api.cloudflare.com/client/v4" out=$(curl -fsS -H "Authorization: Bearer $token" "$api/zones?name=$zone" 2>&1) || { DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1; } id=$(printf '%s' "$out" | sed -n 's/.*"result":\[{"id":"\([0-9a-f]*\)".*/\1/p') [ -n "$id" ] || { DASH_DNS_WHY="Cloudflare: the token can't see the zone $zone"; return 1; } # Not proxied: a private address can't be reached through Cloudflare. body=$(printf '{"type":"%s","name":"%s","content":"%s","ttl":300,"proxied":false,"comment":"kwatch dashboard"}' "$type" "$name" "$value") out=$(curl -fsS -H "Authorization: Bearer $token" "$api/zones/$id/dns_records?name=$name" 2>/dev/null || true) rg=$(printf '%s' "$out" | sed -n 's/.*"result":\[{"id":"\([0-9a-f]*\)".*/\1/p') if [ -n "$rg" ]; then out=$(curl -fsS -X PUT -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ "$api/zones/$id/dns_records/$rg" --data "$body" 2>&1) || { DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1; } else out=$(curl -fsS -X POST -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ "$api/zones/$id/dns_records" --data "$body" 2>&1) || { DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1; } fi case "$out" in *'"success":true'*) ;; *) DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1 ;; esac ;; clouddns) id=$(gcloud dns managed-zones list --filter="dnsName=$zone. AND visibility=public" \ --format='value(name)' 2>&1 | sed -n 1p) || true if [ -z "$id" ] || [[ "$id" == *ERROR* ]]; then DASH_DNS_WHY="the zone $zone isn't in this Google Cloud project; set it with gcloud config set project and try again" return 1 fi [ "$type" = CNAME ] && value="$value." if [ -n "$(gcloud dns record-sets list --zone="$id" --name="$name." --type="$type" --format='value(name)' 2>/dev/null)" ]; then out=$(gcloud dns record-sets update "$name." --zone="$id" --type="$type" --ttl=300 --rrdatas="$value" 2>&1) else out=$(gcloud dns record-sets create "$name." --zone="$id" --type="$type" --ttl=300 --rrdatas="$value" 2>&1) fi || { DASH_DNS_WHY="gcloud: $(dash_dns_first_line "$out")"; return 1; } ;; azure) rg=$(az network dns zone list --query "[?name=='$zone'].resourceGroup | [0]" -o tsv 2>/dev/null || true) [ -n "$rg" ] || { DASH_DNS_WHY="the zone $zone isn't in this Azure subscription; switch with az account set and try again"; return 1; } if [ "$type" = CNAME ]; then out=$(az network dns record-set cname set-record -g "$rg" -z "$zone" -n "$label" -c "$value" --ttl 300 -o none 2>&1) else out=$(az network dns record-set a add-record -g "$rg" -z "$zone" -n "$label" -a "$value" --ttl 300 -o none 2>&1) fi || { DASH_DNS_WHY="az: $(dash_dns_first_line "$out")"; return 1; } ;; digitalocean) out=$(doctl compute domain get "$zone" 2>&1) || { DASH_DNS_WHY="doctl: $(dash_dns_first_line "$out")"; return 1; } [ "$type" = CNAME ] && value="$value." out=$(doctl compute domain records create "$zone" --record-type "$type" --record-name "$label" \ --record-data "$value" --record-ttl 300 2>&1) || { DASH_DNS_WHY="doctl: $(dash_dns_first_line "$out")"; return 1; } ;; *) return 1 ;; esac return 0 } # Deletes one record this installer added. 0 done; 1 failed, with DASH_DNS_WHY. dash_dns_delete() { local name="$1" type="$2" value="$3" zone="$DASH_DNS_ZONE" out id label token api rec rg label="${name%."$zone"}" case "$DASH_DNS_PROVIDER" in route53) dash_dns_route53_zone || { DASH_DNS_WHY="none of your AWS profiles can see the zone $zone"; return 1; } out=$(dash_aws route53 change-resource-record-sets --hosted-zone-id "$DASH_DNS_R53_ID" --change-batch \ "$(printf '{"Changes":[{"Action":"DELETE","ResourceRecordSet":{"Name":"%s","Type":"%s","TTL":300,"ResourceRecords":[{"Value":"%s"}]}}]}' "$name" "$type" "$value")" 2>&1) || { DASH_DNS_WHY="aws: $(dash_dns_first_line "$out")"; return 1; } ;; cloudflare) token="${CLOUDFLARE_API_TOKEN:-${CF_API_TOKEN:-}}"; api="https://api.cloudflare.com/client/v4" out=$(curl -fsS -H "Authorization: Bearer $token" "$api/zones?name=$zone" 2>&1) || { DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1; } id=$(printf '%s' "$out" | sed -n 's/.*"result":\[{"id":"\([0-9a-f]*\)".*/\1/p') out=$(curl -fsS -H "Authorization: Bearer $token" "$api/zones/$id/dns_records?name=$name&type=$type" 2>/dev/null || true) rec=$(printf '%s' "$out" | sed -n 's/.*"result":\[{"id":"\([0-9a-f]*\)".*/\1/p') [ -n "$rec" ] || return 0 out=$(curl -fsS -X DELETE -H "Authorization: Bearer $token" "$api/zones/$id/dns_records/$rec" 2>&1) || { DASH_DNS_WHY="Cloudflare: $(dash_dns_first_line "$out")"; return 1; } ;; clouddns) id=$(gcloud dns managed-zones list --filter="dnsName=$zone. AND visibility=public" --format='value(name)' 2>/dev/null | sed -n 1p) [ -n "$id" ] || { DASH_DNS_WHY="the zone $zone isn't in this Google Cloud project"; return 1; } out=$(gcloud dns record-sets delete "$name." --zone="$id" --type="$type" 2>&1) || { DASH_DNS_WHY="gcloud: $(dash_dns_first_line "$out")"; return 1; } ;; azure) rg=$(az network dns zone list --query "[?name=='$zone'].resourceGroup | [0]" -o tsv 2>/dev/null || true) [ -n "$rg" ] || { DASH_DNS_WHY="the zone $zone isn't in this Azure subscription"; return 1; } out=$(az network dns record-set "$(printf '%s' "$type" | tr '[:upper:]' '[:lower:]')" delete \ -g "$rg" -z "$zone" -n "$label" --yes -o none 2>&1) || { DASH_DNS_WHY="az: $(dash_dns_first_line "$out")"; return 1; } ;; digitalocean) rec=$(doctl compute domain records list "$zone" --format ID,Type,Name --no-header 2>/dev/null | awk -v t="$type" -v n="$label" '$2 == t && $3 == n { print $1; exit }') [ -n "$rec" ] || return 0 out=$(doctl compute domain records delete "$zone" "$rec" --force 2>&1) || { DASH_DNS_WHY="doctl: $(dash_dns_first_line "$out")"; return 1; } ;; *) return 1 ;; esac return 0 } # Removes a record an earlier share added (provider|zone|profile|name|type|value), # unless someone has pointed it elsewhere since. dash_dns_remove_record() { local rec="$1" provider zone profile name type value server where [ -n "$rec" ] || return 0 IFS='|' read -r provider zone profile name type value <<<"$rec" [ -n "$name" ] || return 0 dash_dns_find_zone "$name" || true DASH_DNS_PROVIDER="$provider"; DASH_DNS_ZONE="$zone"; DASH_DNS_AWS_PROFILE="$profile" server=$(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }') where="$(dash_dns_provider_label "$provider") (zone $zone)" if ! dash_dns_points_at "$name" "$value" "$server"; then ui_detail "I left the DNS record for $name alone: it no longer points where I set it." return 0 fi if ! dash_dns_can_write; then ui_detail "Remove the DNS record for $name at $where: I can't, because $DASH_DNS_WHY." return 0 fi if dash_dns_delete "$name" "$type" "$value"; then ui_detail "Removed the DNS record I added for $name in $where." else ui_warn "I couldn't remove the DNS record for $name: $DASH_DNS_WHY" ui_detail "Remove it at $where." fi return 0 } # Waits (up to a minute) until the zone's own name server serves the record. dash_dns_wait() { local addr="$1" target="$2" server="$3" waited=0 ui_busy "Waiting for $addr to appear in DNS" while [ "$waited" -lt 60 ]; do if dash_dns_points_at "$addr" "$target" "$server"; then ui_busy_done ui_detail "$addr now points at the cluster. Some networks may take a few minutes more." return 0 fi sleep 5 waited=$((waited + 5)) done ui_busy_done ui_warn "$(dash_dns_provider_label "$DASH_DNS_PROVIDER") accepted the record, but $server doesn't serve it yet." ui_detail "Route 53 and most providers publish within a minute. If it still isn't there, the record went to a zone the internet doesn't use; check it with: dig @$server $addr" return 0 } # Makes the name point at the cluster: checks, offers to add the record, or # shows it to add by hand. dash_dns_setup() { local addr="$1" target="$2" type=CNAME server existing who where wildcard [ -n "$addr" ] && [ -n "$target" ] || { dash_print_dns "$addr" "$target"; return 0; } dash_is_ip "$target" && type=A server="" if dash_dns_find_zone "$addr"; then server=$(printf '%s' "$DASH_DNS_NS" | awk '{ print $1 }'); fi wildcard=$(dash_dns_wildcard "$addr" "$server") if dash_dns_points_at "$addr" "$target" "$server"; then if [ -n "$wildcard" ] && [ "$(dash_dns_lookup "$addr" "$server" | sort -u)" = "$wildcard" ]; then ui_success "$addr is covered by the wildcard record *.${addr#*.}, which already points at the cluster. Nothing to add." else ui_success "$addr already points at the cluster. Nothing to add." fi return 0 fi if [ "$DASH_D_EXTDNS" = true ] || [ "$DASH_DNS_MODE" = off ] || [ -z "$DASH_DNS_ZONE" ]; then dash_print_dns "$addr" "$target" return 0 fi where="$(dash_dns_provider_label "$DASH_DNS_PROVIDER") (zone $DASH_DNS_ZONE)" if [ "$addr" = "$DASH_DNS_ZONE" ] && [ "$type" = CNAME ]; then # DNS forbids a CNAME at the top of a zone, beside its SOA and NS records. dash_print_dns "$addr" "$target" ui_detail "I can't add it for you: $addr is the top of its zone, where a CNAME isn't allowed. Use a name under it (like kwatch.$addr), or add an alias/ANAME record at $where." return 0 fi if ! dash_dns_can_write; then dash_print_dns "$addr" "$target" ui_detail "I can't add it for you: $DASH_DNS_WHY." return 0 fi existing=$(dash_dns_lookup "$addr" "$server" | sort -u) if [ -n "$existing" ] && [ "$existing" = "$wildcard" ]; then # Only the wildcard answers for this name. A record of its own takes # precedence for this one name and leaves the wildcard and every other # name under it as they are, so it is added like a free name. ui_info "*.${addr#*.} points elsewhere; I'll add a record just for $addr, which leaves the wildcard alone." existing="" fi existing=$(printf '%s' "$existing" | tr '\n' ' ' | sed 's/ $//') { printf '\nThe address needs this DNS record:\n\n' printf ' Type %s\n Name %s\n Value %s\n\n' "$type" "$addr" "$target" } >&2 if [ -n "$existing" ]; then ui_warn "$addr already points somewhere else: $existing" if [ "$DASH_HEADLESS" = true ]; then if [ "$DASH_DNS_MODE" != overwrite ]; then ui_detail "Left as it is. Set KWATCH_DASHBOARD_DNS=overwrite to replace it, or change it at $where." return 0 fi elif [ "$(ask_yes_no "Replace it in $where?" "n")" != true ]; then ui_detail "Left as it is. Change it at $where when you're ready." return 0 fi fi # A free name is only ever a new record, so it is added without a question # (KWATCH_DASHBOARD_DNS=off turns this off); replacing one asked above. ui_busy "Adding the DNS record in $where" if dash_dns_write "$addr" "$type" "$target"; then ui_busy_done DASH_DNS_MADE="$DASH_DNS_PROVIDER|$DASH_DNS_ZONE|$DASH_DNS_AWS_PROFILE|$addr|$type|$target" who=$(dash_dns_identity) ui_success "Added $addr in $where${who:+, $who}." dash_dns_wait "$addr" "$target" "$server" else ui_busy_done ui_warn "I couldn't add the record: $DASH_DNS_WHY" ui_detail "Add it yourself at $where. Until it exists, the link won't open." fi return 0 } DASH_ADDR_CMD="" DASH_DNS_TARGET="" # Finds the address the cluster reports for the chosen setup. dash_find_target() { DASH_DNS_TARGET="" DASH_ADDR_CMD="" case "$DASH_WAY" in ingress) case "$DASH_CTYPE" in tailscale|ngrok) return 0 ;; esac DASH_ADDR_CMD="kubectl -n $NAMESPACE get ingress $(dash_name)" dash_wait_address ingress "$(dash_name)" && DASH_DNS_TARGET="$DASH_ADDR_FOUND" ;; lb) DASH_ADDR_CMD="kubectl -n $NAMESPACE get svc $(dash_name)-lb" dash_wait_address service "$(dash_name)-lb" && DASH_DNS_TARGET="$DASH_ADDR_FOUND" ;; nodeport) DASH_DNS_TARGET=$(printf '%s\n' "$DASH_D_NODES" | head -3 | cut -d'|' -f2 | tr '\n' ' ' | sed 's/ $//') DASH_DNS_TARGET="${DASH_DNS_TARGET%% *}" ;; gateway) DASH_ADDR_CMD="kubectl -n $DASH_GW_NS get gateway $DASH_GW_NAME" DASH_DNS_TARGET=$(kubectl -n "$DASH_GW_NS" get gateway "$DASH_GW_NAME" \ -o 'jsonpath={.status.addresses[0].value}' 2>/dev/null || true) ;; istio) DASH_ADDR_CMD="kubectl -n istio-system get svc" ;; route) case "$DASH_ADDR" in *".$DASH_D_APPS_DOMAIN") return 0 ;; esac DASH_ADDR_CMD="kubectl -n $NAMESPACE get route $(dash_name)" DASH_DNS_TARGET=$(kubectl -n "$NAMESPACE" get route "$(dash_name)" \ -o 'jsonpath={.status.ingress[0].routerCanonicalHostname}' 2>/dev/null || true) ;; esac return 0 } # Reason for a failed https probe, tag then detail. DASH_PROBE_REASON="" dash_probe() { local url="$1" insecure="$2" code rc=0 err err=$(mktemp) code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \ ${insecure:+-k} "$url" 2>"$err") || rc=$? DASH_PROBE_REASON=$(sed -n '1p' "$err") rm -f "$err" if [ "$rc" -eq 0 ]; then case "$code" in 2??|3??) return 0 ;; *) DASH_PROBE_TAG="http:$code"; return 1 ;; esac fi case "$rc" in 6) DASH_PROBE_TAG=dns ;; 7|28) DASH_PROBE_TAG=unreachable ;; 35|51|58|60|77|83) DASH_PROBE_TAG=untrusted ;; *) DASH_PROBE_TAG=unreachable ;; esac return 1 } DASH_PROBE_TAG="" dash_resolve() { local name="$1" out="" if command -v getent >/dev/null 2>&1; then out=$(getent hosts "$name" 2>/dev/null | awk 'NR==1{print $1}') fi if [ -z "$out" ] && command -v dscacheutil >/dev/null 2>&1; then out=$(dscacheutil -q host -a name "$name" 2>/dev/null | awk '/^ip_address:/{print $2; exit}') fi if [ -z "$out" ] && command -v host >/dev/null 2>&1; then out=$(host "$name" 2>/dev/null | awk '/has address/{print $4; exit}') fi printf '%s' "$out" } # One-line reason (7c.9 table) after a failed probe; first line, optional second. dash_reason_text() { local host="$1" resolved cert_status case "$DASH_CERT" in auto) cert_status=$(kubectl -n "$NAMESPACE" get certificate "$(dash_tls_name)" -o \ 'jsonpath={.status.conditions[?(@.type=="Ready")].status}|{.status.conditions[?(@.type=="Ready")].message}' \ 2>/dev/null || true) case "$cert_status" in False*) printf 'The certificate request failed: %s\nCheck that your DNS record points to the address shown, then choose "Check again".' "${cert_status#*|}"; return 0 ;; esac ;; esac case "$DASH_PROBE_TAG" in dns) printf '%s doesn'"'"'t point anywhere yet. Add the DNS record shown above; it can take a while to spread.' "$host" ;; untrusted) printf 'The address answered, but its certificate isn'"'"'t trusted: %s.\nUse a trusted certificate, or choose self-signed and accept the warning.' "${DASH_PROBE_REASON:-unknown reason}" ;; http:404|http:502|http:503|http:504) printf "The front door answered, but kwatch isn't behind it yet (error %s).\nCheck kwatch with \"View status\" in the main menu." "${DASH_PROBE_TAG#http:}" ;; *) resolved=$(dash_resolve "$host") if [ -n "$resolved" ] && [ -n "$DASH_DNS_TARGET" ] && dash_is_ip "$DASH_DNS_TARGET" && [ "$resolved" != "$DASH_DNS_TARGET" ]; then printf "%s points to %s, but the cluster's address is %s. Update the DNS record." "$host" "$resolved" "$DASH_DNS_TARGET" elif [ "$DASH_CERT" = auto ]; then printf "The certificate isn't issued yet. It is issued after your DNS record exists. Check on it with \"Show my access link and password\"." elif [ "$DASH_CERT" = google ]; then printf 'Google is still preparing the certificate. This can take a while after your DNS record exists.' elif [ "$DASH_CERT" = acm ]; then printf "AWS can't use that certificate yet: %s." "${DASH_PROBE_REASON:-unknown reason}" else printf 'Nothing answered at https://%s.\nCheck that the address is open to you (firewall, security group) and try again.' "$host" fi ;; esac } # Polls the sign-in page. 0 passed, 1 not ready, 4 cannot be checked from here. # True when https answers at the load balancer itself, skipping this # computer's DNS: a record added a moment ago can take minutes to reach the # local resolver, which may also still hold the earlier "no such name". DASH_DNS_STALE=false dash_probe_direct() { local host="$1" insecure="$2" code [ -n "$DASH_DNS_TARGET" ] && [ "$DASH_ADDR_IP" != true ] || return 1 code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 ${insecure:+-k} \ --connect-to "${host%%:*}:443:$DASH_DNS_TARGET:443" "https://$host/signin" 2>/dev/null || true) case "$code" in 2??|3??) return 0 ;; esac return 1 } dash_wait_https() { local host="$1" url insecure="" waited=0 resolved url="https://$host/signin" [ "$DASH_CERT" = self ] && insecure=1 [ "$DASH_ADDR_IP" = true ] && insecure=1 DASH_DNS_STALE=false while true; do if dash_probe "$url" "$insecure"; then return 0; fi if [ "$DASH_PROBE_TAG" = dns ] && dash_probe_direct "$host" "$insecure"; then DASH_DNS_STALE=true return 0 fi if [ "$DASH_PROBE_TAG" = unreachable ]; then resolved=$(dash_resolve "${host%%:*}") [ -n "$resolved" ] || resolved="${host%%:*}" if dash_is_private_ip "$resolved" && [ "$DASH_WAY" != nodeport ]; then return 4 fi fi [ "$waited" -ge "$DASH_POLL_MAX" ] && return 1 case "$DASH_PROBE_TAG" in dns) ui_busy "waiting for DNS" ;; untrusted) ui_busy "waiting for a trusted certificate" ;; http:*) ui_busy "the cluster answered ${DASH_PROBE_TAG#http:}" ;; *) ui_busy "waiting for the certificate" ;; esac sleep "$DASH_POLL_INTERVAL" waited=$((waited + DASH_POLL_INTERVAL)) done } # Makes sure the TLS Secret kwatch serves from exists, then wires the pod. # 0 wired, 1 failed, 5 certificate not there yet. dash_wire_pod() { local proxies tls rc=0 waited=0 IFS='|' read -r proxies tls <<<"$(dash_values)" if [ -n "$tls" ] && ! kubectl -n "$NAMESPACE" get secret "$tls" >/dev/null 2>&1; then case "$DASH_CERT" in self|acm|google) local sans="$DASH_ADDR" [ -n "$sans" ] || sans="$(dash_name).$NAMESPACE.svc.cluster.local" if [ "$tls" = "$(dash_backend_tls_name)" ]; then sans="$(dash_name).$NAMESPACE.svc.cluster.local" fi # shellcheck disable=SC2086 # one argument per name dash_make_selfsigned "$tls" $sans || return 1 ;; *) while ! kubectl -n "$NAMESPACE" get secret "$tls" >/dev/null 2>&1; do [ "$waited" -ge "$DASH_POLL_MAX" ] && return 5 ui_busy "waiting for the certificate" sleep "$DASH_POLL_INTERVAL" waited=$((waited + DASH_POLL_INTERVAL)) done ;; esac fi dash_apply_values on "$proxies" "$tls" || rc=$? if [ "$rc" -eq 2 ]; then ui_warn "kwatch is taking longer than usual to restart." ui_detail "Check it with \"View status\" in the main menu, then try again." return 1 fi return "$rc" } # Removes what this run created (labelled only) and puts the pod values back. dash_rollback() { local resource name hidden while IFS='|' read -r resource name hidden; do [ -n "$resource" ] || continue dash_delete_if_ours "$resource" "$name" || true done < <(dash_plan_objects) if [ -n "${DASH_PREV_VALUES:-}" ]; then local enabled proxies tlscert IFS='|' read -r enabled proxies tlscert <<<"$DASH_PREV_VALUES" if [ "$enabled" = true ]; then dash_apply_values on "$proxies" "${DASH_PREV_TLS:-}" >/dev/null 2>&1 || true else dash_apply_values off "" "" >/dev/null 2>&1 || true fi fi return 0 } # Removes the objects of the previous setup that the new one does not use. dash_remove_old() { local old resource name [ -n "${DASH_PREV_OBJECTS:-}" ] || return 0 for old in ${DASH_PREV_OBJECTS//,/ }; do resource="${old%%/*}"; name="${old#*/}" if dash_plan_objects | grep -q "^$resource|$name|"; then continue; fi dash_delete_if_ours "$resource" "$name" || true done } dash_link() { case "$DASH_WAY" in local) printf 'http://localhost' ;; service) printf 'Service: %s in "%s", port %s (https)' "$(dash_name)" "$NAMESPACE" "$DASH_PORT" ;; nodeport) printf 'https://%s:%s' "${DASH_ADDR:-$DASH_LINK_HOST}" "${DASH_NODEPORT:-$DASH_LINK_PORT}" ;; *) printf 'https://%s' "${DASH_ADDR:-$DASH_LINK_HOST}" ;; esac } DASH_LINK_HOST="" DASH_LINK_PORT="" DASH_PREV_VALUES="" DASH_PREV_TLS="" DASH_PREV_OBJECTS="" DASH_PREV_ADDR="" DASH_PREV_IPS="" # The whole create/wait/check sequence. Returns 0 when finished (ready or left # as is) and 1 when it was undone or failed. dash_execute() { local err rc=0 password link kinds="Ingresses, Services and Certificates" choice target_wait=0 DASH_PREV_VALUES=$(dash_current_values "$(deployment_name)") DASH_PREV_TLS=$(kubectl -n "$NAMESPACE" get deployment "$(deployment_name)" -o \ 'jsonpath={.spec.template.spec.volumes[?(@.name=="dashboard-tls")].secret.secretName}' 2>/dev/null || true) if [ -n "$DASH_PREV_OBJECTS" ]; then ui_detail "The link will stop for a short while while I switch." fi ui_busy "Setting up the dashboard" if ! dash_ensure_secret; then ui_error "I couldn't finish: the password could not be created." return 1 fi err=$(dash_manifests | kubectl -n "$NAMESPACE" apply -f - 2>&1 >/dev/null) || rc=$? if [ "$rc" -ne 0 ]; then dash_explain_error "$err" "$kinds" dash_rollback ui_detail "I removed what I had created, so nothing changed." return 1 fi dash_label_object service "$(dash_name)" case "$DASH_WAY" in ingress) ui_busy "Creating the Ingress" ;; gateway) ui_busy "Creating the route" ;; istio) ui_busy "Creating the route" ;; route) ui_busy "Creating the route" ;; lb) ui_busy "Creating the load balancer" ;; nodeport) ui_busy "Creating the NodePort" ;; *) ui_busy "Creating the Service" ;; esac rc=0 dash_wire_pod || rc=$? if [ "$rc" -eq 1 ]; then ui_error "I couldn't finish: kwatch could not be set up for sharing." dash_rollback ui_detail "I removed what I had created, so nothing changed." return 1 fi [ "$rc" -eq 5 ] && target_wait=1 ui_busy "Waiting for an address" dash_find_target ui_busy_done DASH_LINK_HOST="" if [ "$DASH_ADDR_IP" = true ] || [ -z "$DASH_ADDR" ]; then DASH_LINK_HOST="$DASH_DNS_TARGET" if [ "$DASH_WAY" = lb ] && [ -n "$DASH_DNS_TARGET" ]; then DASH_ADDR="$DASH_DNS_TARGET"; fi if [ "$DASH_WAY" = nodeport ] && [ -n "$DASH_DNS_TARGET" ]; then DASH_ADDR="$DASH_DNS_TARGET"; fi fi if [ "$DASH_WAY" = nodeport ] && [ -z "$DASH_NODEPORT" ]; then DASH_NODEPORT=$(kubectl -n "$NAMESPACE" get svc "$(dash_name)-nodeport" \ -o 'jsonpath={.spec.ports[0].nodePort}' 2>/dev/null || true) fi if [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = tailscale ]; then DASH_ADDR=$(kubectl -n "$NAMESPACE" get ingress "$(dash_name)" \ -o 'jsonpath={.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true) fi if [ -n "$DASH_ADDR" ] && [ "$DASH_ADDR_IP" != true ] && [ "$DASH_CTYPE" != tailscale ] && [ "$DASH_CTYPE" != ngrok ] && [ "$DASH_WAY" != proxy ] && [ "$DASH_WAY" != service ] && [ "$DASH_CERT" != handled ]; then dash_dns_setup "$DASH_ADDR" "$DASH_DNS_TARGET" fi if [ "$target_wait" = 1 ]; then rc=0 dash_wire_pod || rc=$? if [ "$rc" -ne 0 ] && [ "$rc" -ne 5 ]; then dash_rollback return 1 fi fi dash_wait_password || ui_warn "kwatch hasn't written the password yet. Give it a minute, then choose Show my access link and password." password="$DASH_PASSWORD_READ" link=$(dash_link) if [ "$DASH_WAY" = service ]; then dash_record_write "$link" dash_print_service_only dash_print_block "$link" "${password:-}" dash_remove_old return 0 fi if [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = gke ] && false; then :; fi if [ "$DASH_WAY" = proxy ]; then { printf '\nPoint your proxy at this address inside the cluster:\n' printf ' http://%s.%s.svc.cluster.local:%s\n' "$(dash_name)" "$NAMESPACE" "$DASH_PORT" printf 'For Cloudflare Tunnel, set that as the service URL of your public hostname.\n' } >&2 fi local host="$DASH_ADDR" [ "$DASH_WAY" = nodeport ] && host="$DASH_ADDR:$DASH_NODEPORT" if [ -z "$host" ]; then ui_warn "The dashboard is set up, but the cluster has not given it an address yet." ui_detail "Look at it with: $DASH_ADDR_CMD" dash_record_write "$link" dash_print_block "$link" "${password:-}" return 0 fi while true; do rc=0 ui_busy "Waiting for https at https://$host" dash_wait_https "$host" || rc=$? ui_busy_done case "$rc" in 0) ui_success "✅ Ready at $link" if [ "$DASH_DNS_STALE" = true ]; then ui_info "It answers at the load balancer, but this computer's DNS hasn't caught up with the new record yet. The link opens once it does, usually within a few minutes." fi if [ "$DASH_CERT" = self ] || [ "$DASH_ADDR_IP" = true ]; then ui_info "Your browser will warn about the certificate. That is expected: kwatch made it itself." fi dash_finish_ok "$link" "$password" return 0 ;; 4) ui_info "Set up at $link" ui_info "I can't open that address from this computer, because it is on a private network. Try it from a computer on that network or VPN." dash_finish_ok "$link" "$password" return 0 ;; esac ui_warn "The dashboard is set up, but https isn't answering yet." printf '\n' >&2 dash_reason_text "$host" | while IFS= read -r line; do ui_detail "$line"; done if [ "$DASH_HEADLESS" = true ]; then ui_info "Left as it is. Run the installer again and choose \"Show my access link and password\" to check on it." dash_finish_ok "$link" "$password" return 0 fi DASH_M_LABELS=("Check again" "Set up DNS" "Leave it as it is" "Undo and go back") DASH_M_HINTS=() choice=$(dash_choose 0) || choice=3 case "$choice" in 0) continue ;; 1) dash_dns_setup "$DASH_ADDR" "$DASH_DNS_TARGET" ;; 2) ui_info "Left as it is. Check later with \"Show my access link and password\"." dash_finish_ok "$link" "$password" return 0 ;; 3) dash_rollback return 1 ;; esac done } dash_finish_ok() { local link="$1" password="$2" dash_record_write "$link" if [ -n "$DASH_PREV_OBJECTS" ]; then dash_remove_old ui_success "Switched. The old setup is removed." if [ -n "$DASH_PREV_ADDR" ] && [ "$DASH_PREV_ADDR" != "$DASH_ADDR" ]; then if [ -n "$DASH_PREV_DNS" ]; then dash_dns_remove_record "$DASH_PREV_DNS" else ui_detail "You can remove the DNS record for $DASH_PREV_ADDR whenever you like." fi fi fi case "$DASH_WAY" in nodeport) ui_info "Anyone who can reach those machines on that port can open the sign-in page. If a machine goes away the link to it stops, so point your address at several machines." ;; esac dash_print_block "$link" "${password:-}" } dash_print_service_only() { { printf '\nService created. kwatch serves https itself, with the certificate you chose.\n\n' printf ' Service %s\n Namespace %s\n Port %s (https)\n Address %s.%s.svc.cluster.local\n\n' \ "$(dash_name)" "$NAMESPACE" "$DASH_PORT" "$(dash_name)" "$NAMESPACE" printf 'Point your own ingress, gateway or proxy at that Service, and make sure it talks https to it.\n' printf 'To try it from this computer:\n kubectl -n %s port-forward svc/%s %s:%s\n' \ "$NAMESPACE" "$(dash_name)" "$DASH_PORT" "$DASH_PORT" } >&2 } # ---- flows ----------------------------------------------------------------- dash_need_terminal() { if [ -t 0 ]; then return 0; fi ui_error "Sharing needs a terminal to ask you a few questions. Run the installer in a terminal." return 1 } dash_way_menu() { local opt idx label hint found cloud default=0 i=0 n local -a opts=() dash_options DASH_M_LABELS=(); DASH_M_HINTS=() for opt in $DASH_OPTIONS; do opts+=("$opt") hint="" case "$opt" in 1) label="$(dash_e 💻 "Local only (default)")" hint="Opens in your browser while this window stays open. Pick this to try it, or if only you need it." ;; 2) label="$(dash_e 🌐 Ingress)" hint="Your cluster's front door for web traffic, with a normal https address. Pick this if other apps there already have one." found=$(dash_found_types) [ -n "$found" ] && hint="$hint Found: $found." ;; 3) if [ "$DASH_D_GWAPI" = true ]; then label="$(dash_e 🚪 "Gateway API / Istio")" hint="The newer kind of front door, or Istio's. Pick this if your cluster uses Gateways or Istio." [ "$DASH_D_ISTIO" = true ] || { label="$(dash_e 🚪 "Gateway API")"; } else label="$(dash_e 🚪 Istio)" hint="Istio's own front door (a VirtualService). Pick this if your cluster runs Istio." fi ;; 4) label="$(dash_e '🛰️ ' "OpenShift Route")" hint="OpenShift's own way to publish an app. Pick this on OpenShift." ;; 5) label="$(dash_e '⚖️ ' "Load balancer")" hint="Your cloud (or MetalLB) gives kwatch its own address, and kwatch handles https itself. Pick this if you have no ingress." case "$DASH_D_LB_KIND" in cloud) hint="$hint Found: $(dash_cloud_label "$DASH_D_CLOUD")." ;; metallb) hint="$hint Found: MetalLB." ;; kube-vip) hint="$hint Found: kube-vip." ;; esac ;; 6) label="$(dash_e 🔌 NodePort)" hint="Opens one port on each machine in the cluster, and kwatch handles https itself. Pick this for a small cluster without a load balancer." ;; 7) label="$(dash_e '☁️ ' "Outside proxy (Cloudflare Tunnel or similar)")" hint="Something outside the cluster adds https for you. Pick this only if you already run one." ;; 8) label="$(dash_e 🧰 "Service only")" hint="kwatch creates its internal address and shows it. Pick this if you will set up access yourself." ;; 9) label="$(dash_e ✨ "Same as your other apps")" hint="$(dash_peer_menu_text). I fill in the rest; you only confirm the address." ;; esac [ "$opt" = "$DASH_RECOMMENDED" ] && label="$label · recommended" DASH_M_LABELS+=("$label") DASH_M_HINTS+=("$hint") done ui_screen "dashboard" ui_heading "Share the dashboard with your team" if [ "$DASH_OPTIONS" = "1 7 8" ]; then ui_detail "I couldn't find an ingress, Gateway, load balancer or OpenShift route here, so the shared choices below are the ones that need a bit of your own setup." fi ui_info "I looked at your cluster and show only the ways that will work here." dash_q "How should your team open the dashboard?" DASH_M_LABELS+=("$(dash_e '↩️ ' Back)"); DASH_M_HINTS+=("") # The cursor starts on the first choice: "Same as your other apps" when the # cluster shows how it shares apps, else Local only (D12). idx=$(dash_choose 0) || return 1 n=${#opts[@]} [ "$idx" -ge "$n" ] && return 1 DASH_OPT_CHOSEN="${opts[$idx]}" return 0 } DASH_OPT_CHOSEN="" dash_netpol_present() { [ -n "$(kubectl -n "$NAMESPACE" get networkpolicy -o name 2>/dev/null || true)" ] } dash_load_previous() { DASH_PREV_OBJECTS=""; DASH_PREV_ADDR=""; DASH_PREV_IPS=""; DASH_PREV_DNS="" if [ "$DASH_STATE" = shared ]; then dash_record_load DASH_PREV_DNS="$DASH_REC_DNS" DASH_PREV_OBJECTS="$DASH_REC_OBJECTS" DASH_PREV_ADDR="$DASH_REC_ADDR" DASH_PREV_IPS="$DASH_REC_IPS" fi } # ---- sharing without questions ---------------------------------------------- # KWATCH_DASHBOARD_EXPOSE (with the variables below) shares the dashboard the # same way the menu does, without a terminal: for scripts, CI and runbooks. # # KWATCH_DASHBOARD_EXPOSE auto | ingress | gateway | lb | route | proxy | # service; auto shares it the way the # cluster's other apps are shared # KWATCH_DASHBOARD_HOST the address people open (kwatch.example.com) # KWATCH_DASHBOARD_INGRESS_CLASS IngressClass to use (default: the cluster's # default class, or the only one) # KWATCH_DASHBOARD_TLS upstream | cert-manager[:] | # secret: | self | acm: # (default: upstream when a load balancer in # front of the ingress already ends https, # else the cert-manager issuer the menu # would pick) # KWATCH_DASHBOARD_PRIVATE true (default) | false: an internal ALB or # cloud load balancer, or a public one # KWATCH_DASHBOARD_ALLOW_IPS addresses or CIDRs allowed in, comma separated # KWATCH_DASHBOARD_PROXIES addresses or CIDRs the ingress, gateway or proxy # connects to kwatch from, comma separated (needed # with ingress, gateway, route and proxy) # KWATCH_DASHBOARD_ANNOTATIONS extra annotations, key=value;key=value # KWATCH_DASHBOARD_DNS auto (default) | overwrite | off: add the # DNS record with the provider's CLI DASH_HEADLESS=false # Fails with a message for a headless setting that cannot work. dash_headless_fail() { ui_error "$1" [ -z "${2:-}" ] || ui_detail "$2" return 1 } # Picks the IngressClass from KWATCH_DASHBOARD_INGRESS_CLASS or the cluster. dash_headless_class() { local want="${KWATCH_DASHBOARD_INGRESS_CLASS:-}" line name controller def count count=$(dash_class_count) [ "$count" -gt 0 ] || dash_headless_fail "There is no IngressClass in this cluster." \ "Use KWATCH_DASHBOARD_EXPOSE=lb, proxy or service instead." || return 1 if [ -z "$want" ]; then if [ "$count" -eq 1 ]; then line=$(dash_class_lines | sed -n 1p) else line=$(dash_class_lines | awk -F'|' '$3 == "true"' | sed -n 1p) fi # No default class: use the one the cluster's other apps use. if [ -z "$line" ] && [ -n "$DASH_PEER_CLASS" ]; then line=$(dash_class_lines | awk -F'|' -v c="$DASH_PEER_CLASS" '$1 == c' | sed -n 1p) [ -z "$line" ] || ui_info "Using IngressClass \"$DASH_PEER_CLASS\", like $(dash_peer_summary "$DASH_PEER_CLASS")." fi [ -n "$line" ] || dash_headless_fail "This cluster has $count IngressClasses and none is the default." \ "Name one with KWATCH_DASHBOARD_INGRESS_CLASS: $(dash_class_lines | cut -d'|' -f1 | tr '\n' ' ')" || return 1 else line=$(dash_class_lines | awk -F'|' -v n="$want" '$1 == n' | sed -n 1p) [ -n "$line" ] || dash_headless_fail "There is no IngressClass \"$want\"." \ "Classes here: $(dash_class_lines | cut -d'|' -f1 | tr '\n' ' ')" || return 1 fi IFS='|' read -r name controller def <<<"$line" DASH_CLASS="$name"; DASH_CTYPE=$(dash_controller_type "$controller") case "$DASH_CTYPE" in gke|agic|tailscale|ngrok) dash_headless_fail "A $(dash_type_label "$DASH_CTYPE") ingress needs the menu." \ "Run the installer in a terminal and choose Dashboard > Share with my team." return 1 ;; esac return 0 } # Sets DASH_ADDR from KWATCH_DASHBOARD_HOST. dash_headless_addr() { local host="${KWATCH_DASHBOARD_HOST:-}" if [ -z "$host" ]; then host=$(dash_peer_address) [ -z "$host" ] || ui_info "Using the address $host, on the domain your other apps use." if [ -n "$host" ] && dash_peer_wildcard_covers "$host"; then ui_info "*.${host#*.} already points at that ingress, so DNS needs no change." fi fi if [ -z "$host" ]; then case "$DASH_WAY" in lb) DASH_ADDR=""; DASH_ADDR_IP=true; return 0 ;; service) DASH_ADDR=""; return 0 ;; route) if [ -n "$DASH_D_APPS_DOMAIN" ]; then host="$(dash_name)-$NAMESPACE.$DASH_D_APPS_DOMAIN" fi ;; esac fi [ -n "$host" ] || dash_headless_fail "Set KWATCH_DASHBOARD_HOST to the address people will open." || return 1 dash_normalize_address "$host" || dash_headless_fail "KWATCH_DASHBOARD_HOST: $DASH_ADDR_ERR" || return 1 if dash_address_taken "$DASH_ADDR_CLEAN"; then dash_headless_fail "Another $(dash_taken_kind) already uses $DASH_ADDR_CLEAN $DASH_TAKEN_BY." return 1 fi DASH_ADDR="$DASH_ADDR_CLEAN" DASH_ADDR_IP=false if dash_is_ip "$DASH_ADDR"; then DASH_ADDR_IP=true; fi return 0 } # Sets DASH_CERT (and the issuer, Secret or ARN) from KWATCH_DASHBOARD_TLS. dash_headless_tls() { local want="${KWATCH_DASHBOARD_TLS:-}" arg line rc region if [ "$DASH_WAY" = proxy ]; then DASH_CERT=none; return 0; fi if [ "$DASH_WAY" = route ] && [ -n "$DASH_D_APPS_DOMAIN" ] && [[ "$DASH_ADDR" == *".$DASH_D_APPS_DOMAIN" ]] && [ -z "$want" ]; then DASH_CERT=handled; return 0 fi if { [ "$DASH_WAY" = gateway ] || [ "$DASH_WAY" = istio ]; } && [ -z "$want" ]; then DASH_CERT=handled; return 0; fi if [ -z "$want" ] && [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = alb ] && [ "$DASH_PEER" = true ] && dash_peer_tls; then ui_info "https: $(dash_cert_line), because $DASH_CERT_WHY." return 0 fi if [ -z "$want" ] && dash_tls_smart; then dash_smart_apply ui_info "https: $(dash_cert_line), because $DASH_CERT_WHY." return 0 fi if [ -z "$want" ]; then if [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = alb ]; then dash_headless_fail "An ALB needs its ACM certificate: set KWATCH_DASHBOARD_TLS=acm:." return 1 elif [ "$DASH_WAY" = ingress ] && dash_upstream_tls_found "$DASH_CTYPE"; then want=upstream elif [ "$DASH_D_CERTMGR" = true ] && [ -n "$DASH_ADDR" ] && [ "$DASH_ADDR_IP" != true ]; then want=cert-manager else dash_headless_fail "Choose how kwatch gets https with KWATCH_DASHBOARD_TLS." \ "upstream, cert-manager[:], secret:, self or acm:" return 1 fi fi arg="" case "$want" in *:*) arg="${want#*:}"; want="${want%%:*}" ;; esac case "$want" in resolver) [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = traefik ] && [ -n "$arg" ] || dash_headless_fail "KWATCH_DASHBOARD_TLS=resolver: is for a Traefik ingress." || return 1 DASH_CERT=resolver; DASH_RESOLVER="$arg"; DASH_TLS_SECRET="" ;; default) [ "$DASH_WAY" = ingress ] && dash_upstream_capable "$DASH_CTYPE" || dash_headless_fail "KWATCH_DASHBOARD_TLS=default works with an nginx, Traefik, HAProxy, Contour, Kong or other ingress." || return 1 DASH_CERT=default; DASH_TLS_SECRET="" ;; upstream) [ "$DASH_WAY" = ingress ] && dash_upstream_capable "$DASH_CTYPE" || dash_headless_fail "KWATCH_DASHBOARD_TLS=upstream works with an nginx, Traefik, HAProxy, Contour, Kong or other ingress." || return 1 DASH_CERT=upstream; DASH_TLS_SECRET="" dash_upstream_note ;; cert-manager) [ "$DASH_D_CERTMGR" = true ] || dash_headless_fail "cert-manager isn't installed in this cluster." || return 1 [ -n "$DASH_ADDR" ] && [ "$DASH_ADDR_IP" != true ] || dash_headless_fail "cert-manager needs a name in KWATCH_DASHBOARD_HOST, not an IP address." || return 1 if [ -n "$arg" ]; then line=$(printf '%s\n' "$DASH_D_ISSUERS" | awk -F'|' -v n="$arg" '$1 == n' | sed -n 1p) [ -n "$line" ] || dash_headless_fail "There is no Ready cert-manager issuer \"$arg\"." \ "Ready issuers: $(printf '%s\n' "$DASH_D_ISSUERS" | cut -d'|' -f1 | tr '\n' ' ')" || return 1 IFS='|' read -r DASH_ISSUER DASH_ISSUER_KIND DASH_ISSUER_TYPE <<<"$line" else rc=0 dash_pick_issuer || rc=$? case "$rc" in 1) dash_headless_fail "cert-manager has no Ready issuer." \ "$DASH_D_ISSUERS_SKIPPED issuer(s) aren't Ready. Fix one, or choose another KWATCH_DASHBOARD_TLS." return 1 ;; 2) dash_headless_fail "cert-manager has more than one issuer: name one with KWATCH_DASHBOARD_TLS=cert-manager:." \ "Ready issuers: $(printf '%s\n' "$DASH_D_ISSUERS" | cut -d'|' -f1 | tr '\n' ' ')" return 1 ;; esac fi if [ "$DASH_ISSUER_TYPE" = ACME ] && [ "${KWATCH_DASHBOARD_PRIVATE:-true}" = true ]; then ui_warn "\"$DASH_ISSUER\" checks the address over the internet; that fails for a private address." fi DASH_CERT=auto; DASH_TLS_SECRET=$(dash_tls_name) ;; secret) [ -n "$arg" ] || dash_headless_fail "Name the Secret: KWATCH_DASHBOARD_TLS=secret:." || return 1 dash_check_tls_secret "$arg" || return 1 DASH_CERT=own; DASH_TLS_SECRET="$arg" ;; self) [ "$DASH_WAY" != route ] || dash_headless_fail "An OpenShift Route needs secret: here." || return 1 ui_warn "Browsers will warn \"not private\" until people trust the self-signed certificate." DASH_CERT=self; DASH_TLS_SECRET=$(dash_tls_name) ;; acm) [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = alb ] || dash_headless_fail "KWATCH_DASHBOARD_TLS=acm: is for an AWS ALB ingress." || return 1 dash_valid_arn "$arg" || dash_headless_fail "${DASH_ARN_ERR%% *}" || return 1 region=$(printf '%s' "$arg" | cut -d: -f4) if [ -n "$DASH_D_REGION" ] && [ "$region" != "$DASH_D_REGION" ]; then dash_headless_fail "That certificate is in $region but your cluster is in $DASH_D_REGION." return 1 fi DASH_ACM_ARN="$arg"; DASH_CERT=acm ;; *) dash_headless_fail "KWATCH_DASHBOARD_TLS=$want isn't known." \ "Use upstream, cert-manager[:], secret:, self or acm:." return 1 ;; esac if [ "$DASH_WAY" = ingress ] && [ "$DASH_CTYPE" = alb ] && [ "$DASH_CERT" != acm ] && [ "$DASH_PEER" != true ]; then dash_headless_fail "An ALB needs its ACM certificate: set KWATCH_DASHBOARD_TLS=acm:." return 1 fi return 0 } # Sets DASH_IPS from KWATCH_DASHBOARD_ALLOW_IPS. dash_headless_ips() { local want="${KWATCH_DASHBOARD_ALLOW_IPS:-}" rc=0 DASH_IPS="" [ -n "$want" ] || return 0 if ! dash_ips_enforced; then dash_headless_fail "This way of sharing can't limit IP addresses; unset KWATCH_DASHBOARD_ALLOW_IPS." \ "Limits work with nginx, Traefik, HAProxy and ALB ingresses, OpenShift Routes and load balancers." return 1 fi dash_parse_ips "$want" || rc=$? [ "$rc" -eq 0 ] || dash_headless_fail "KWATCH_DASHBOARD_ALLOW_IPS: $DASH_IPS_ERR" || return 1 DASH_IPS="$DASH_IPS_CLEAN" if [ "$DASH_CERT" = upstream ]; then ui_warn "Behind a load balancer, the ingress may see the load balancer's address instead of the visitor's." fi return 0 } # Sets DASH_PROXIES from KWATCH_DASHBOARD_PROXIES. There is no silent default: # trusting the wrong range lets other pods pose as visitors. dash_headless_proxies() { local want="${KWATCH_DASHBOARD_PROXIES:-}" rc=0 suggested DASH_PROXIES="" if ! dash_proxies_needed; then [ -z "$want" ] || ui_detail "KWATCH_DASHBOARD_PROXIES is not used by this way of sharing." return 0 fi if [ -z "$want" ]; then suggested=$(dash_default_proxies) dash_headless_fail "Set KWATCH_DASHBOARD_PROXIES to the addresses your ingress, gateway or proxy connects to kwatch from." "${suggested:+The pod range here is $suggested, which fits when it runs inside the cluster. }Example: KWATCH_DASHBOARD_PROXIES=10.244.0.0/16" || return 1 fi dash_parse_proxies "$want" || rc=$? [ "$rc" -eq 0 ] || dash_headless_fail "KWATCH_DASHBOARD_PROXIES: $DASH_PROXIES_ERR" || return 1 DASH_PROXIES="$DASH_PROXIES_CLEAN" return 0 } dash_share_headless() { local private="${KWATCH_DASHBOARD_PRIVATE:-true}" DASH_HEADLESS=true case "$private" in true|false) ;; *) dash_headless_fail "KWATCH_DASHBOARD_PRIVATE must be true or false."; return 1 ;; esac dash_check_extra_annotations || return 1 dash_preflight || return 1 ui_busy "Looking at your cluster" dash_detect DASH_NETPOL=false dash_netpol_present && DASH_NETPOL=true ui_busy_done dash_state dash_load_previous dash_reset_answers DASH_WAY="${KWATCH_DASHBOARD_EXPOSE}" if [ "$DASH_WAY" = auto ]; then if [ -z "$DASH_PEER_KIND" ]; then dash_headless_fail "I couldn't find other apps shared through an Ingress or Gateway here." \ "Set KWATCH_DASHBOARD_EXPOSE to ingress, gateway, lb, route, proxy or service." return 1 fi dash_peer_begin ui_info "Sharing it like your other apps: $(dash_peer_menu_text)." fi case "$DASH_WAY" in ingress) if [ "$DASH_PEER" = true ] && [ -z "${KWATCH_DASHBOARD_INGRESS_CLASS:-}" ]; then case "$DASH_CTYPE" in gke|agic|tailscale|ngrok) dash_headless_fail "A $(dash_type_label "$DASH_CTYPE") ingress needs the menu."; return 1 ;; esac else DASH_PEER=false; DASH_PEER_ANN="" dash_headless_class || return 1 fi [ -n "${KWATCH_DASHBOARD_PRIVATE:-}" ] || [ "$DASH_PEER" != true ] || private="$DASH_ALB_INTERNAL" DASH_ALB_INTERNAL="$private" ;; gateway|istio) if [ "$DASH_PEER" != true ]; then [ -n "$DASH_PEER_GW" ] && [ "$DASH_PEER_GW_KIND" = "$DASH_WAY" ] || dash_headless_fail "I couldn't find a $DASH_WAY gateway with https that other apps use here." \ "Run the installer in a terminal to pick one." || return 1 IFS='|' read -r DASH_GW_NS DASH_GW_NAME _ _ _ _ <<<"$DASH_PEER_GW" fi ;; lb) [ "$DASH_D_LB" = true ] || dash_headless_fail "This cluster has no load balancer support." || return 1 case "$DASH_D_CLOUD" in aws|gce|azure) DASH_LB_INTERNAL="$private" ;; *) DASH_LB_INTERNAL=false ;; esac ;; route) [ "$DASH_D_OPENSHIFT" = true ] || dash_headless_fail "This isn't an OpenShift cluster." || return 1 ;; proxy|service) ;; nodeport) dash_headless_fail "Sharing through $DASH_WAY needs the menu." \ "Run the installer in a terminal and choose Dashboard > Share with my team." return 1 ;; *) dash_headless_fail "KWATCH_DASHBOARD_EXPOSE=$DASH_WAY isn't known." "Use auto, ingress, gateway, lb, route, proxy or service." return 1 ;; esac dash_headless_addr || return 1 dash_headless_tls || return 1 dash_headless_ips || return 1 dash_headless_proxies || return 1 if [ "$DASH_WAY" = lb ] && [ "$DASH_CERT" = auto ] && [ "$DASH_ADDR_IP" = true ]; then dash_headless_fail "cert-manager needs a name in KWATCH_DASHBOARD_HOST." return 1 fi dash_confirm_text >&2 printf '\n' >&2 dash_execute } # Share with my team / Change how it's shared. dash_share_flow() { local rc dash_need_terminal || { ui_pause; return 0; } dash_preflight || { ui_pause; return 0; } ui_busy "Looking at your cluster" dash_detect DASH_NETPOL=false dash_netpol_present && DASH_NETPOL=true ui_busy_done dash_load_previous while true; do dash_reset_answers dash_way_menu || return 0 DASH_WAY=$(dash_option_way "$DASH_OPT_CHOSEN") if [ "$DASH_WAY" = peer ]; then dash_peer_begin; fi if [ "$DASH_WAY" = local ]; then dash_local_flow return 0 fi rc=0 dash_run_steps || rc=$? [ "$rc" -eq 2 ] && continue [ "$rc" -ne 0 ] && return 0 rc=0 dash_q_confirm || rc=$? [ "$rc" -eq 2 ] && continue [ "$rc" -eq 1 ] && return 0 ui_screen "dashboard" dash_execute || true ui_pause return 0 done } # Turn on, local only (3.3), also used by Switch to local only. dash_local_flow() { local lport password rc=0 mode_prompt="${1:-ask}" empty DASH_WAY=local if [ "$mode_prompt" = ask ]; then dash_preflight || { ui_pause; return 0; } empty=$(kubectl -n "$NAMESPACE" get deployment "$(deployment_name)" -o \ 'jsonpath={.spec.template.spec.volumes[?(@.name=="data")].emptyDir}' 2>/dev/null || true) if [ -n "$empty" ]; then ui_warn "Turning this on restarts kwatch. Without a persistent volume it forgets the issues it was tracking and finds them again on its next check." fi dash_q "Turn on the dashboard on this computer only?" ui_detail "Nothing is exposed to the internet. The dashboard is reachable only while this window keeps the connection open." [ "$(ask_yes_no "Continue" "y")" = true ] || return 0 fi ui_busy "Turning on the dashboard" if ! dash_ensure_secret; then ui_error "I couldn't finish: the password could not be created." ui_pause; return 0 fi dash_base_service_apply || { ui_error "I couldn't finish: the dashboard Service could not be created."; ui_pause; return 0; } dash_apply_values on "" "" || rc=$? ui_busy_done if [ "$rc" -eq 2 ]; then ui_warn "kwatch is taking longer than usual to restart. Check it with \"View status\" in the main menu, then try again." ui_pause; return 0 elif [ "$rc" -ne 0 ]; then ui_error "I couldn't finish: kwatch could not be updated." ui_pause; return 0 fi lport=$(dash_free_port) || { ui_error "No free port between 8080 and 8090 on this computer."; ui_pause; return 0; } dash_wait_password || ui_warn "kwatch hasn't written the password yet. Give it a minute, then choose Show my access link and password." password="$DASH_PASSWORD_READ" ui_success "Dashboard is on. Local only, nothing leaves your computer." dash_print_block "http://localhost:$lport" "${password:-}" dash_port_forward "$lport" return 0 } dash_base_service_apply() { dash_reset_answers DASH_WAY=local dash_gen_base_service | kubectl -n "$NAMESPACE" apply -f - >/dev/null 2>&1 || return 1 dash_label_object service "$(dash_name)" return 0 } dash_open_flow() { local lport if [ "$DASH_STATE" = shared ]; then dash_record_load dash_open_url "${DASH_REC_LINK:-https://$DASH_REC_ADDR}" return 0 fi lport=$(dash_free_port) || { ui_error "No free port between 8080 and 8090 on this computer."; ui_pause; return 0; } ui_info "Dashboard link: http://localhost:$lport" dash_port_forward "$lport" } # 3.6 with the certificate line of 7c.10. dash_cert_status_line() { local status msg case "$DASH_REC_CERT" in auto) status=$(kubectl -n "$NAMESPACE" get certificate "$(dash_tls_name)" -o \ 'jsonpath={.status.conditions[?(@.type=="Ready")].status}|{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true) case "$status" in True*) printf 'Certificate: ready' ;; False*) msg="${status#*|}"; printf 'Certificate: failed: %s' "$msg" ;; *) printf 'Certificate: still being issued' ;; esac ;; own) printf 'Certificate: from your Secret "%s"' "$DASH_REC_TLS" ;; self) printf 'Certificate: self-signed (browsers warn)' ;; acm) printf 'Certificate: AWS ACM' ;; google) status=$(kubectl -n "$NAMESPACE" get managedcertificate.networking.gke.io "$(dash_name)" \ -o 'jsonpath={.status.certificateStatus}' 2>/dev/null || true) if [ "$status" = Active ]; then printf 'Certificate: Google managed (active)' else printf 'Certificate: Google managed (still being prepared)'; fi ;; handled) case "$DASH_REC_WAY" in route) printf 'Certificate: handled by OpenShift' ;; gateway|istio) printf 'Certificate: handled by the Gateway' ;; *) printf 'Certificate: handled by %s' "$(dash_type_label "$DASH_REC_CTYPE")" ;; esac ;; none) printf 'https: added by your proxy' ;; upstream) printf 'https: added by the load balancer in front of the ingress' ;; default) printf "Certificate: the ingress's default certificate" ;; resolver) printf 'Certificate: from Traefik' ;; esac } dash_show_flow() { local password rc=0 line if [ "$DASH_STATE" = shared ]; then dash_record_load if [ "$DASH_REC_WAY" = service ]; then ui_info "Service: $(dash_name) in \"$NAMESPACE\", port $DASH_PORT (https)" else ui_info "Dashboard link: ${DASH_REC_LINK:-https://$DASH_REC_ADDR}" fi line=$(dash_cert_status_line) [ -n "$line" ] && ui_detail "$line" else ui_info "Dashboard link: http://localhost:8080 (works while \"Open the dashboard\" is running)" fi if ui_interactive; then ui_detail "Press Enter to show the password, or Esc to go back" local key rest IFS= read -rsn1 key || input_ended if [ "$key" = $'\x1b' ]; then IFS= read -rsn2 -t "$UI_ESC_TIMEOUT" rest || rest="" return 0 fi fi ui_busy "Reading the password" dash_wait_password || rc=$? password="$DASH_PASSWORD_READ" if [ "$rc" -ne 0 ] || [ -z "$password" ]; then ui_error "I can't read the password from the cluster. If kwatch just started, try again in a minute. Otherwise choose \"Reset password\" to make a new one." return 0 fi printf 'Password: %s\n' "$password" >&2 ui_detail "Anyone with this link and password can read your issues. Share it only with people who should see them." return 0 } dash_reset_flow() { local password link dash_q "Make a new password? Everyone signed in will be signed out." [ "$(ask_yes_no "Continue" "y")" = true ] || return 0 ui_busy "Resetting the password" password=$(dash_new_password) || { ui_error "I couldn't reset the password: no random source. Nothing changed."; return 0; } if ! dash_secret_exists; then ui_error "I couldn't reset the password: the dashboard Secret is missing. Nothing changed." return 0 fi if ! dash_set_password "$password"; then ui_error "I couldn't reset the password: Kubernetes refused the change. Nothing changed." return 0 fi kubectl -n "$NAMESPACE" rollout restart "deployment/$(deployment_name)" >/dev/null 2>&1 || true kubectl -n "$NAMESPACE" rollout status "deployment/$(deployment_name)" --timeout=90s >/dev/null 2>&1 || true ui_busy_done if [ "$DASH_STATE" = shared ]; then dash_record_load link="${DASH_REC_LINK:-https://$DASH_REC_ADDR}" else link="http://localhost:8080" fi dash_print_block "$link" "$password" ui_success "Password reset. Old sessions are signed out." } # Deletes every labelled exposure object listed in the record. dash_remove_recorded() { local old resource name dash_record_load for old in ${DASH_REC_OBJECTS//,/ }; do resource="${old%%/*}"; name="${old#*/}" dash_delete_if_ours "$resource" "$name" || true done } dash_switch_local_flow() { dash_q "Switch to local only?" ui_detail "The shared link stops working and what I created for sharing is removed. Your password stays the same." [ "$(ask_yes_no "Continue" "y")" = true ] || return 0 ui_busy "Turning on the dashboard" dash_remove_recorded ui_busy_done dash_dns_remove_record "$DASH_REC_DNS" dash_local_flow noask } dash_off_flow() { local rc=0 extra="" dash_q "Turn off the dashboard?" if [ "$DASH_STATE" = shared ]; then dash_record_load extra=" The Ingress and its certificate are removed." if [ -n "$DASH_REC_DNS" ]; then extra="$extra So is the DNS record I added for ${DASH_REC_ADDR}."; fi case "$DASH_REC_CTYPE" in tailscale) extra="$extra The Tailscale device \"${DASH_REC_ADDR:-kwatch}\" is removed by the operator." ;; ngrok) extra="$extra The ngrok endpoint is removed by the operator." ;; esac fi ui_detail "The link stops working and the password is deleted. Your issues and alerts are not affected.$extra" [ "$(ask_yes_no "Continue" "y")" = true ] || return 0 ui_busy "Turning off the dashboard" if [ "$DASH_STATE" = shared ]; then dash_remove_recorded # cert-manager writes the issued Secret itself, so it has no label; it # exists only because of the Certificate removed above. if [ "$DASH_REC_CERT" = auto ]; then kubectl -n "$NAMESPACE" delete secret "$(dash_tls_name)" --ignore-not-found >/dev/null 2>&1 || true fi fi dash_apply_values off "" "" || rc=$? dash_delete_if_ours secret "$(dash_name)" || true dash_delete_if_ours secret "$(dash_tls_name)" || true dash_delete_if_ours secret "$(dash_backend_tls_name)" || true dash_delete_if_ours service "$(dash_name)" || true ui_busy_done if [ "$rc" -eq 1 ]; then ui_error "I couldn't turn it off: kwatch could not be updated." ui_detail "Run this to see what's left: kubectl -n $NAMESPACE get ingress,secret" return 0 fi ui_success "Dashboard is off." if [ "$DASH_STATE" = shared ] && [ -n "$DASH_REC_DNS" ]; then dash_dns_remove_record "$DASH_REC_DNS" elif [ "$DASH_STATE" = shared ] && [ -n "$DASH_REC_ADDR" ]; then ui_detail "You can remove the DNS record for $DASH_REC_ADDR whenever you like." [ "$DASH_REC_CERT" = acm ] && ui_detail "Your ACM certificate and your DNS record are not touched." fi return 0 } dash_change_flow() { local idx dash_record_load ui_heading "The dashboard is shared like this" printf '\n Link %s\n Way %s\n Who %s\n\n' \ "${DASH_REC_LINK:-https://$DASH_REC_ADDR}" "${DASH_REC_WAY:-your own setup}" \ "$([ -n "$DASH_REC_IPS" ] && printf 'Only %s, with the password' "${DASH_REC_IPS//,/, }" || printf 'Anyone who reaches the address, with the password')" >&2 DASH_M_LABELS=("Change it" "Keep it as it is"); DASH_M_HINTS=() idx=$(dash_choose 1) || return 0 [ "$idx" = 0 ] || return 0 dash_share_flow } dashboard_flow() { local choice label while true; do ui_screen "dashboard" if ! dash_version_gate; then ui_pause break fi LICENSE_COMMUNITY_CACHE="" if license_is_community; then ui_info "The dashboard is part of Pro, which is free during early access." ui_info "Add your key first: choose \"License / Pro\" in the main menu." ui_info "Get a free key at https://app.kwatch.dev" ui_pause break fi dash_state printf '\n Dashboard: %s\n\n' "$(dash_state_text)" >&2 local -a acts=() labels=() case "$DASH_STATE" in off) acts=(local share back); labels=("Turn on, local only" "Share with my team" "Back") ;; local) acts=(open share show reset off back) labels=("Open the dashboard" "Share with my team" "Show my access link and password" "Reset password" "Turn off" "Back") ;; shared) acts=(open show reset change tolocal off back) labels=("Open the dashboard" "Show my access link and password" "Reset password" "Change how it's shared" "Switch to local only" "Turn off" "Back") ;; *) ui_error "I can't reach your cluster." ui_detail "Check that kubectl works: kubectl get nodes" ui_pause; break ;; esac choice=$(ui_select 0 "${labels[@]}") || break label="${acts[$choice]}" case "$label" in local) dash_local_flow; ui_pause ;; share) dash_share_flow ;; open) dash_open_flow ;; show) dash_show_flow; ui_pause ;; reset) dash_reset_flow; ui_pause ;; change) dash_change_flow ;; tolocal) dash_switch_local_flow; ui_pause ;; off) dash_off_flow; ui_pause ;; *) break ;; esac DASH_FLOW_CHANGED=true done [ "${DASH_FLOW_CHANGED:-false}" = true ] || MENU_READ_ONLY=true return 0 } show_supported_menu() { ui_screen "ready" local choice local -a actions=() labels=() actions+=(status logs upgrade providers testalert settings resources capabilities license dashboard) labels+=( "📊 View status" "📜 View recent logs" "⬆️ Upgrade kwatch" "🔌 Edit notification providers" "🔔 Send test alert" "⚙️ Edit settings" "🧮 Edit Deployment resources and placement" "🧩 View capabilities" "🔑 License / Pro" "🖥️ Dashboard" ) # Restoring is meaningless with nothing to restore from. if [ -n "$(config_backups)" ]; then actions+=(restore) labels+=("♻️ Restore a previous configuration") fi actions+=(diagnostics uninstall exit) labels+=("🧰 Save diagnostics to file" "🧹 Uninstall kwatch" "🚪 Exit") # The Dashboard entry carries its state as a dim caption. local -a captions=() local idx dash_state for idx in "${!actions[@]}"; do if [ "${actions[$idx]}" = dashboard ]; then captions+=("$(dash_state_text)") else captions+=("") fi done if ! choice=$(ui_select_captioned 0 "${#labels[@]}" "${labels[@]}" "${captions[@]}"); then MENU_EXIT=true return fi case "${actions[$choice]}" in dashboard) dashboard_flow ;; status) status_flow; MENU_READ_ONLY=true ;; logs) logs_flow; MENU_READ_ONLY=true ;; testalert) testalert_flow; MENU_READ_ONLY=true ;; diagnostics) diagnostics_flow; MENU_READ_ONLY=true ;; upgrade) upgrade_flow ;; providers) require_catalog_for "provider editing" configure_alert_flow ;; settings) require_catalog_for "settings editing" configure_flow ;; resources) configure_deployment_resources_flow || true ;; capabilities) require_catalog_for "capabilities" features_flow MENU_READ_ONLY=true ;; license) license_flow ;; restore) restore_flow ;; uninstall) uninstall_flow ;; *) MENU_EXIT=true ;; esac } # True when a kwatch Pod is waiting for a node that does not exist. A cluster # that scales its nodes to zero out of hours puts the installation in exactly # this state, and nothing the manager can do -- upgrading, disabling a # component, granting a permission -- makes a Pod schedulable when there is # nowhere to run it. Worth naming, so the operator is not sent looking for a # fault in kwatch. workload_unschedulable() { local selector="$1" phases nodes phases=$(kubectl -n "$NAMESPACE" get pods -l "$selector" \ -o 'jsonpath={range .items[*]}{.status.phase}{" "}{end}' 2>/dev/null || true) case "$phases" in *Pending*) ;; *) return 1 ;; esac # "NotReady" does not contain " Ready", so this counts only usable nodes. nodes=$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready' || true) [ "${nodes:-0}" -eq 0 ] 2>/dev/null || return 1 return 0 } # True when a kwatch container was killed for exceeding its memory limit. That # is the one broken state whose repair is a resource change rather than a # rollout, so it is worth recognising by name. workload_was_oomkilled() { local selector="$1" reasons reasons=$(kubectl -n "$NAMESPACE" get pods -l "$selector" \ -o 'jsonpath={range .items[*]}{.status.containerStatuses[*].lastState.terminated.reason}{" "}{.status.containerStatuses[*].state.terminated.reason}{" "}{end}' \ 2>/dev/null || true) case "$reasons" in *OOMKilled*) return 0 ;; esac return 1 } show_broken_menu() { local choice selector oomkilled=false unschedulable=false local selfcheck_missing storage_pending=false claim local -a actions=() labels=() ui_screen "needs attention" ui_kv "📦" "Deployment" "${INSTALL_DEPLOYMENT:-unknown}" ui_kv "🏷️" "Version" "${INSTALL_VERSION:-unknown}" ui_kv "❗" "Reason" "$INSTALL_REASON" printf '\n' >&2 selector=$(workload_selector) # Five round trips before this menu can be drawn, which on a remote cluster is # several seconds of nothing. Do the silent probing first, under one notice, # then print -- show_unready_pods writes as it goes and would overwrite it. ui_busy "Diagnosing the installation" workload_unschedulable "$selector" && unschedulable=true workload_was_oomkilled "$selector" && oomkilled=true selfcheck_missing=false self_check_rbac_missing && selfcheck_missing=true claim=$(claim_phase) [ -n "$claim" ] && [ "$claim" != Bound ] && storage_pending=true ui_busy_done show_unready_pods "$selector" || true if [ "$unschedulable" = true ]; then ui_warn "⚠️ No node is available, so the Pod cannot be placed anywhere." ui_detail " This is not a kwatch fault and no repair here can change it." ui_detail " A cluster that scales its nodes to zero out of hours recovers" ui_detail " on its own once a node returns." fi # Offer the repair that matches the symptom first. Being killed for memory is # not fixed by another rollout of the same release; the limit has to change. if [ "$oomkilled" = true ]; then ui_warn "⚠️ A kwatch container was killed for exceeding its memory limit." ui_detail " Raising the memory limit is the repair; the value is kept across upgrades." actions+=(resources) labels+=("🧮 Raise the memory limit (the container was OOMKilled)") fi if [ "$storage_pending" = true ]; then ui_warn "⚠️ The state volume claim is $claim; the Pod cannot start without it." actions+=(storage) labels+=("💾 Fix the state volume (claim is $claim)") fi if [ "$selfcheck_missing" = true ]; then actions+=(selfcheck) labels+=("🔑 Grant the Lease permissions") fi actions+=(status logs upgrade settings providers) labels+=( "📊 View status" "📜 View recent logs" "🛠️ Repair by upgrading kwatch" "⚙️ Edit settings" "🔌 Edit notification providers" ) # Only worth listing twice when it is not already the first entry. if [ "$oomkilled" != true ]; then actions+=(resources) labels+=("🧮 Edit Deployment resources and placement") fi actions+=(uninstall exit) labels+=( "🧹 Uninstall kwatch" "🚪 Exit" ) # A detected repair is worth landing on; otherwise start on a read-only entry # rather than on something that changes the cluster. if ! choice=$(ui_select 0 "${labels[@]}"); then MENU_EXIT=true return fi case "${actions[$choice]}" in storage) repair_storage "${INSTALL_VERSION:-}" || true ;; selfcheck) repair_self_check_rbac || true ;; upgrade) if confirm_repair \ "repair kwatch by upgrading it" \ "This will let you choose a release, then update the Deployment and roll back if the rollout fails."; then upgrade_flow true else ui_info "↩️ Repair cancelled; no changes were made." fi ;; status) status_flow; MENU_READ_ONLY=true ;; logs) logs_flow; MENU_READ_ONLY=true ;; resources) configure_deployment_resources_flow || true ;; settings) require_catalog_for "settings editing" configure_flow ;; providers) require_catalog_for "provider editing" configure_alert_flow ;; uninstall) uninstall_flow ;; *) MENU_EXIT=true ;; esac } workload_selector() { local file="" selector [ -n "$SESSION_CACHE_DIR" ] && file="$SESSION_CACHE_DIR/workload-selector" if [ -n "$file" ] && [ -f "$file" ]; then cat "$file" return 0 fi selector=$(workload_selector_uncached) if [ -n "$file" ]; then printf '%s' "$selector" >"$file" 2>/dev/null || true fi printf '%s' "$selector" } workload_selector_uncached() { local deployment pod_instance deployment=$(deployment_name || true) if [ -n "$deployment" ]; then pod_instance=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.metadata.labels.app\.kubernetes\.io/instance}' \ 2>/dev/null || true) fi if [ -n "${pod_instance:-}" ]; then printf 'app.kubernetes.io/instance=%s' "$pod_instance" else printf 'app=kwatch' fi } # A Pod that is not ready is the usual reason someone opens the manager, so show # the container's own explanation instead of leaving them to run kubectl. show_unready_pods() { local selector="$1" pod ready waiting terminated scheduling logs line shown=false local phase probe pvc_phase while IFS=' ' read -r pod ready; do [ -n "$pod" ] || continue [ "$ready" = true ] && continue shown=true waiting=$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o 'jsonpath={.status.containerStatuses[0].state.waiting.reason}' \ 2>/dev/null || true) terminated=$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o 'jsonpath={.status.containerStatuses[0].lastState.terminated.reason}' \ 2>/dev/null || true) # A Pod that never started a container -- Pending on a full or scaling # cluster -- has no container state at all, so fall back to its phase and # the scheduler's own explanation. scheduling="" probe="" if [ -z "$waiting" ]; then phase=$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o 'jsonpath={.status.phase}' 2>/dev/null || true) if [ "$phase" = Running ]; then # "not ready: Running" says nothing. A container that started and stayed # up is failing its readiness probe, and the kubelet records why. waiting="running, but failing its readiness probe" probe=$(kubectl -n "$NAMESPACE" get events \ --field-selector=involvedObject.name="$pod",reason=Unhealthy \ -o 'jsonpath={.items[-1:].message}' 2>/dev/null || true) else waiting="$phase" scheduling=$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o 'jsonpath={.status.conditions[?(@.type=="PodScheduled")].message}' \ 2>/dev/null || true) # A Pending Pod whose state claim is not bound is waiting for storage, # not for a node; say so, since the scheduler's message only hints at it. if [ "$phase" = Pending ]; then pvc_phase=$(kubectl -n "$NAMESPACE" get pvc "${RELEASE}-data" \ -o 'jsonpath={.status.phase}' 2>/dev/null || true) if [ -n "$pvc_phase" ] && [ "$pvc_phase" != Bound ]; then scheduling="${scheduling:+$scheduling }State volume claim ${RELEASE}-data is $pvc_phase; the cluster may have no default StorageClass." fi fi fi fi ui_warn "$(ui_dot bad) Pod $pod is not ready: ${waiting:-unknown}${terminated:+ (last exit: $terminated)}" [ -n "$scheduling" ] && ui_detail " $(compact_reason "$scheduling")" [ -n "$probe" ] && ui_detail " $(compact_reason "$probe")" # A required component that stopped ends the process, and the supervisor # logs which one; show that rather than leaving the restart unexplained. while IFS= read -r line; do [ -n "$line" ] || continue ui_detail " ⚠️ $(compact_reason "$line")" done < <(component_stop_lines "$selector") logs=$(kubectl -n "$NAMESPACE" logs "$pod" --tail=3 --previous 2>/dev/null || kubectl -n "$NAMESPACE" logs "$pod" --tail=3 2>/dev/null || true) [ -n "$logs" ] || continue ui_detail " Last log lines:" while IFS= read -r line; do [ -n "$line" ] || continue printf ' %s\n' "$(compact_reason "$line")" done <<< "$logs" done < <(kubectl -n "$NAMESPACE" get pods -l "$selector" \ -o 'jsonpath={range .items[*]}{.metadata.name}{" "}{.status.containerStatuses[0].ready}{"\n"}{end}' \ 2>/dev/null || true) [ "$shown" = true ] && return 0 return 1 } status_flow() { local deployment version selector state phase state_version message ready desired ui_screen "status" ui_kv "📍" "Context" "$(context_label "$SELECTED_CONTEXT")" ui_kv "📦" "Namespace" "$NAMESPACE" version=$(installed_version || true) ui_kv "🏷️" "Version" "${version:-unknown}" if [ "$CATALOG_SOURCE" != unavailable ]; then ui_kv "📚" "Catalog" "$CATALOG_SOURCE" fi printf '\n' >&2 deployment=$(deployment_name || true) if [ -n "$deployment" ]; then ready=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true) desired=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.replicas}' 2>/dev/null || true) [[ "$ready" =~ ^[0-9]+$ ]] || ready=0 [[ "$desired" =~ ^[0-9]+$ ]] || desired=0 if [ "$ready" -gt 0 ] && [ "$ready" -eq "$desired" ]; then printf ' %s %sHealthy%s %s%s/%s replicas ready%s\n' "$(ui_dot ok)" \ "$UI_BOLD$UI_GREEN" "$UI_RESET" "$UI_DIM" "$ready" "$desired" "$UI_RESET" >&2 else printf ' %s %sUnhealthy%s %s%s/%s replicas ready%s\n' "$(ui_dot bad)" \ "$UI_BOLD$UI_RED" "$UI_RESET" "$UI_DIM" "$ready" "$desired" "$UI_RESET" >&2 fi ui_rule kubectl -n "$NAMESPACE" get deployment "$deployment" 2>/dev/null | sed 's/^/ /' || true selector=$(workload_selector) # -o wide names the node: with placement configurable, where the Pod landed # is part of the status rather than a detail to go digging for. kubectl -n "$NAMESPACE" get pods -l "$selector" -o wide 2>/dev/null | sed 's/^/ /' || true show_unready_pods "$selector" || true show_deployment_shape "$deployment" show_storage_status "$deployment" else ui_warn "⚠️ No kwatch Deployment was found." fi license_status_section state=$(kubectl -n "$NAMESPACE" get configmap "$STATE_CONFIGMAP_NAME" \ -o 'jsonpath={.data.phase}{"|"}{.data.version}{"|"}{.data.message}' \ 2>/dev/null || true) if [ -n "$state" ]; then IFS='|' read -r phase state_version message <<< "$state" ui_detail "🧭 Manager state: ${phase:-unknown}${state_version:+ ($state_version)}${message:+ — $message}" else ui_detail "🧭 Manager state: not available" fi ui_pause } # Resources and placement belong in the status now that they can be changed, # together with whether an upgrade will keep them. show_deployment_shape() { local deployment="$1" index requests_cpu requests_memory limits_cpu local limits_memory selector_text opt_outs index=$(kwatch_container_index "$deployment") || return 0 requests_cpu=$(live_resource_value "$deployment" "$index" requests cpu) requests_memory=$(live_resource_value "$deployment" "$index" requests memory) limits_cpu=$(live_resource_value "$deployment" "$index" limits cpu) limits_memory=$(live_resource_value "$deployment" "$index" limits memory) selector_text=$(live_node_selector "$deployment") printf '\n' >&2 if [ -n "$requests_cpu$limits_cpu" ]; then ui_kv "🧮" "Resources" \ "requests ${requests_cpu:-—}/${requests_memory:-—} · limits ${limits_cpu:-—}/${limits_memory:-—}" fi ui_kv "📍" "Placement" "${selector_text:-any node}" deployment_overrides_read if [ -n "$(overrides_without_license)" ]; then ui_detail " 🔒 Recorded; upgrades and reinstalls keep these values." else ui_detail " ℹ️ Release defaults; edit them from the menu to keep them across upgrades." fi # An opt-out is invisible until something goes wrong with it, so say it is on. opt_outs=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.metadata.labels}{.spec.template.metadata.annotations}' \ 2>/dev/null || true) case "$opt_outs" in *admission.datadoghq.com/enabled*|*sidecar.istio.io/inject*|\ *linkerd.io/inject*|*vault.hashicorp.com/agent-inject*|\ *kuma.io/sidecar-injection*|*instrumentation.opentelemetry.io*) ui_detail " 🔌 Excluded from at least one admission injector." ;; esac } # Logs can carry a webhook URL or a token echoed back by a provider, so they get # the same redaction as every other diagnostic the manager prints. redact_stream() { sed -E 's#https?://[^ ]+##g; s/((token|password|secret)[=:])[[:space:]]*[^[:space:]]+/\1/Ig' } # During a rollout there is more than one Pod, and the interesting one is # usually not the first. Ask rather than picking silently. select_workload_pod() { local selector="$1" choice index=0 local -a pods=() labels=() local name phase ready restarts line while IFS='|' read -r name phase ready restarts; do [ -n "$name" ] || continue pods+=("$name") line="$name ${UI_DIM}$phase" [ "$ready" = true ] || line="$line · not ready" [ "${restarts:-0}" = 0 ] || line="$line · ${restarts} restarts" labels+=("$line${UI_RESET}") done < <(kubectl -n "$NAMESPACE" get pods -l "$selector" \ -o 'jsonpath={range .items[*]}{.metadata.name}{"|"}{.status.phase}{"|"}{.status.containerStatuses[0].ready}{"|"}{.status.containerStatuses[0].restartCount}{"\n"}{end}' \ 2>/dev/null || true) [ "${#pods[@]}" -gt 0 ] || return 1 if [ "${#pods[@]}" -eq 1 ]; then printf '%s' "${pods[0]}" return 0 fi ui_info "🧊 More than one kwatch Pod is present." # 2 for "cancelled", not 1: the caller would otherwise report that no Pod # exists, when the truth is that the user chose not to pick one. choice=$(ui_select 0 "${labels[@]}") || return 2 printf '%s' "${pods[$choice]}" return 0 } logs_flow() { local selector pod choice lines=200 rc=0 previous ui_screen "logs" selector=$(workload_selector) ui_busy "Finding the kwatch Pod" pod=$(select_workload_pod "$selector") || rc="$?" ui_busy_done if [ "${rc:-0}" -eq 2 ]; then return 0 fi if [ -z "$pod" ]; then ui_warn "⚠️ No kwatch Pod was found in namespace $NAMESPACE." return 0 fi ui_detail "Pod: $NAMESPACE/$pod" choice=$(ui_select 0 \ "Last 50 lines" \ "Last 200 lines" \ "Last 1000 lines" \ "🔎 Errors and warnings only" \ "📡 Follow live" \ "⏮️ Previous container (before the last restart)" \ "↩️ Back") || return 0 case "$choice" in 0) lines=50 ;; 1) lines=200 ;; 2) lines=1000 ;; 3) # The interesting lines are a small fraction of the output, so search a # deeper window than the plain views show. ui_rule kubectl -n "$NAMESPACE" logs "$pod" --tail=2000 2>&1 | redact_stream | grep -Ei 'error|warn|fail|panic|fatal|refused|denied|timeout' || ui_detail "No errors or warnings in the last 2000 lines." ui_rule ui_pause return 0 ;; 4) follow_logs "$pod" return 0 ;; 5) # A Pod that has never restarted has no previous container, and kubectl # says so in a way worth replacing rather than printing alongside. ui_rule if previous=$(kubectl -n "$NAMESPACE" logs "$pod" --previous \ --tail=200 2>/dev/null); then printf '%s\n' "$previous" | redact_stream else ui_detail "No previous container: this Pod has not restarted." fi ui_rule ui_pause return 0 ;; *) return 0 ;; esac ui_rule kubectl -n "$NAMESPACE" logs "$pod" --tail="$lines" 2>&1 | redact_stream || true ui_rule ui_pause } # `kubectl logs -f` ends only when it is interrupted, which needs two things the # rest of the manager does not do. The kubectl wrapper buffers output to a file # and prints it when the command finishes, so following through it would show # nothing at all -- hence `command kubectl` here. And Ctrl-C reaches the whole # foreground process group, so the session's own INT trap would exit the manager # along with kubectl; absorb the signal for the duration instead, then restore # the real traps. follow_logs() { local pod="$1" ui_rule ui_detail "Following $pod — press Ctrl-C to stop" trap ':' INT command kubectl --context "$SELECTED_CONTEXT" -n "$NAMESPACE" logs "$pod" \ -f --tail=20 2>&1 | redact_stream || true install_signal_traps ui_rule ui_info "⏹️ Stopped following." ui_pause } # Listing backups is a round trip, and the ready menu does it on every redraw # just to decide whether to offer Restore -- which is why coming back from a # submenu paused. Cache it for the session and drop the cache whenever the set # of backups changes. config_backups() { local file="" result [ -n "$SESSION_CACHE_DIR" ] && file="$SESSION_CACHE_DIR/config-backups" if [ -n "$file" ] && [ -f "$file" ]; then cat "$file" return 0 fi result=$(config_backups_uncached) cache_list "$file" "$result" printf '%s' "$result" [ -n "$result" ] && printf '\n' return 0 } # One newline-terminated line per entry, or an empty file: `cat` then reproduces # exactly what the uncached path emits, so a cache hit and a miss are read the # same way. cache_list() { local file="$1" result="$2" [ -n "$file" ] || return 0 if [ -n "$result" ]; then printf '%s\n' "$result" >"$file" 2>/dev/null || true else : >"$file" 2>/dev/null || true fi return 0 } invalidate_config_backups() { [ -n "$SESSION_CACHE_DIR" ] && rm -f "$SESSION_CACHE_DIR/config-backups" 2>/dev/null return 0 } config_backups_detailed() { kubectl -n "$NAMESPACE" get secrets \ -o "jsonpath={range .items[?(@.metadata.labels.app\\.kubernetes\\.io/managed-by=='kwatch.sh')]}{.metadata.name}{\"|\"}{.metadata.creationTimestamp}{\"\n\"}{end}" \ 2>/dev/null | grep "^${RELEASE}-config-[0-9]" | sort -r || true } config_backups_uncached() { kubectl -n "$NAMESPACE" get secrets \ -o "jsonpath={range .items[?(@.metadata.labels.app\\.kubernetes\\.io/managed-by=='kwatch.sh')]}{.metadata.name}{\"\n\"}{end}" \ 2>/dev/null | grep "^${RELEASE}-config-[0-9]" | sort -r || true } # Every upgrade writes a backup Secret and nothing removed them, so a # long-lived installation accumulated one per upgrade for ever. prune_config_backups() { local keep="${KWATCH_BACKUP_KEEP:-5}" name index=0 kept="" local file="" pruned_all=true [[ "$keep" =~ ^[0-9]+$ ]] || keep=5 # Read past the cache: a backup was just written, and pruning the previous # list would keep one too many. invalidate_config_backups while IFS= read -r name; do [ -n "$name" ] || continue index=$((index + 1)) if [ "$index" -gt "$keep" ]; then # A delete that did not happen leaves a backup the seeded list would deny # exists, so fall back to clearing the cache rather than describing the # cluster wrongly. kubectl -n "$NAMESPACE" delete secret "$name" --ignore-not-found \ >/dev/null 2>&1 || pruned_all=false continue fi if [ -n "$kept" ]; then kept="$kept $name" else kept="$name" fi done < <(config_backups) # The survivors are already known, so seed the cache with them instead of # clearing it: the menu drawn next would otherwise re-list every Secret just # to decide whether to offer Restore. if [ "$pruned_all" != true ]; then invalidate_config_backups return 0 fi [ -n "$SESSION_CACHE_DIR" ] && file="$SESSION_CACHE_DIR/config-backups" cache_list "$file" "$kept" return 0 } # Restoring used to be reachable only as the automatic rollback after a failed # upgrade, so an unwanted but successful configuration change could not be # undone from the manager. restore_flow() { local name created choice local -a names=() labels=() ui_busy "Reading configuration backups" while IFS='|' read -r name created; do [ -n "$name" ] || continue names+=("$name") labels+=("$name ${UI_DIM}${created:-unknown}${UI_RESET}") done < <(config_backups_detailed) ui_busy_done if [ "${#names[@]}" -eq 0 ]; then ui_warn "⚠️ No configuration backups were found in namespace $NAMESPACE." ui_detail "Backups are created automatically before each upgrade." MENU_READ_ONLY=true return 0 fi ui_heading "♻️ Restore a previous configuration" ui_detail "Newest first. The current configuration is replaced, then kwatch restarts." labels+=("↩️ Back") choice=$(ui_select 0 "${labels[@]}") || choice="${#names[@]}" if [ "$choice" -ge "${#names[@]}" ]; then ui_info "↩️ Restore cancelled; nothing was changed." MENU_READ_ONLY=true return 0 fi confirm_repair \ "restore the configuration saved in ${names[$choice]}" \ "The current KwatchConfig is replaced by the saved one and kwatch is restarted. Notification Secrets are not changed." || { ui_info "↩️ Restore cancelled; nothing was changed." MENU_READ_ONLY=true return 0 } BACKUP_NAME="${names[$choice]}" restore_backup ui_success "✅ Configuration restored from ${names[$choice]}." } # Deleting is a sequence across namespaced and cluster-scoped resources. Check # every permission before the first deletion: `check_access` calls die, so a # missing one used to abort mid-way and leave orphaned cluster RBAC behind. preflight_uninstall_access() { local purge="${1:-false}" [ "$purge" = true ] && check_access delete persistentvolumeclaims namespace check_access delete deployments namespace check_access delete services namespace check_access delete serviceaccounts namespace check_access delete roles namespace check_access delete rolebindings namespace check_access delete secrets namespace check_access delete clusterrolebindings check_access delete clusterroles } # Everything the manager itself created, beyond the workload: the state marker, # the cached catalogs, the configuration resource, and the upgrade backups. purge_manager_data() { local name for name in "$STATE_CONFIGMAP_NAME" "$CATALOG_CACHE_NAME" \ "$FEATURE_CATALOG_CACHE_NAME" "$PROVIDER_CATALOG_CACHE_NAME" \ "$DEPLOYMENT_OVERRIDES_CONFIGMAP_NAME" \ "$LEGACY_OVERRIDES_CONFIGMAP_NAME"; do [ -n "$name" ] || continue kubectl -n "$NAMESPACE" delete configmap "$name" --ignore-not-found \ >/dev/null 2>&1 || true done while IFS= read -r name; do [ -n "$name" ] || continue kubectl -n "$NAMESPACE" delete secret "$name" --ignore-not-found \ >/dev/null 2>&1 || true done < <(config_backups) invalidate_config_backups kubectl -n "$NAMESPACE" delete kwatchconfig "$RELEASE" --ignore-not-found \ >/dev/null 2>&1 || true # The state volume: incident history, change timeline and the outbox. delete_owned namespace persistentvolumeclaim "${RELEASE}-data" } uninstall_flow() { local confirm secret_owner scope purge=false adopt_existing_config_secret ui_heading "🧹 Uninstall kwatch" scope=$(ui_select 0 \ "🧹 Remove the workload, keep configuration and backups" \ "🔥 Remove everything the manager created" \ "↩️ Cancel") || { ui_warn "↩️ Cancelled."; return; } case "$scope" in 0) ;; 1) purge=true ;; *) ui_warn "↩️ Cancelled."; return ;; esac if [ "$purge" = true ]; then ui_warn \ "⚠️ This removes the workload, the configuration resource, every configuration backup, the cached catalogs, and the state volume (incident history)." ui_detail "The namespace and the CRD are preserved." else ui_warn \ "⚠️ This removes the kwatch workload and its manager-owned configuration Secret." ui_detail "KwatchConfig, backups, the state volume, namespace, and CRD are preserved." fi confirm=$(ask "Type uninstall to remove kwatch" "") || exit_expected [ "$confirm" = uninstall ] || { ui_warn "↩️ Cancelled."; return; } ui_busy "Checking uninstall permissions" preflight_uninstall_access "$purge" ui_busy_done # The Pod is needed to release the key, so this comes before the workload goes. license_release_before_uninstall ui_info \ "🧹 Removing kwatch resources from namespace '$NAMESPACE'; other resources remain." remove_namespaced_workload secret_owner=$(kubectl -n "$NAMESPACE" get secret "$CONFIG_SECRET_NAME" \ -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}' \ 2>/dev/null || true) if [ "$secret_owner" = kwatch.sh ]; then kubectl -n "$NAMESPACE" delete secret "$CONFIG_SECRET_NAME" \ --ignore-not-found else ui_info "🔐 Preserving unowned Secret '$CONFIG_SECRET_NAME'." fi license_delete_after_uninstall if [ "$purge" = true ]; then with_loading "Removing manager data" purge_manager_data fi clear_managed_namespace_labels if [ "$purge" = true ]; then ui_success \ "✅ kwatch removed, along with its configuration, backups, cached catalogs, and state volume. The namespace and CRD remain." else ui_success \ "✅ kwatch workload removed. KwatchConfig, backups, the state volume, namespace, and CRD remain." fi } # --------------------------------------------------------------------------- # License / Pro # # The Community edition is free; Pro is unlocked by a license key (one key, one # cluster). The key lives in a Secret named after the release and reaches the # kwatch container as KWATCH_LICENSE_KEY through a secretKeyRef, so it is never # written into the Deployment itself. The wiring is recorded as a `license=1` # entry among the Deployment overrides, which is how an upgrade -- the manifest # is downloaded fresh every time -- keeps it. # --------------------------------------------------------------------------- LICENSE_SIGNUP_URL="https://kwatch.dev/signup?src=installer" LICENSE_ACCOUNT_URL="https://kwatch.dev/account" LICENSE_ENV_NAME="KWATCH_LICENSE_KEY" LICENSE_SECRET_KEY="license-key" # kwatch's own per-install record. It is not named after the release because # kwatch, not this script, owns it. Never backed up or restored: see # backup_config. LICENSE_INSTANCE_SECRET="kwatch-license-instance" LICENSE_HEALTH_PORT=8060 LICENSE_FLOW_CHANGED=false LICENSE_COMMUNITY_CACHE="" LICENSE_PAYLOAD="" LICENSE_PLAN="" LICENSE_EXP="" LICENSE_DAYS_LEFT="" license_secret_name() { printf '%s-license' "$RELEASE"; } # The badge features_flow draws next to Pro capabilities. pro_badge() { printf '%sPRO%s' "$UI_YELLOW" "$UI_RESET"; } license_now() { date +%s; } license_have_jq() { command -v jq >/dev/null 2>&1; } # The key is a credential: only the last six characters are ever shown. license_mask() { local key="$1" if [ "${#key}" -le 10 ]; then printf 'KW1-…' else printf 'KW1-…%s' "${key: -6}" fi } # base64url without padding -> bytes. Restores the alphabet and the padding # that the standard decoder insists on. base64url_decode() { local text="${1//-/+}" out text="${text//_//}" case $((${#text} % 4)) in 1) return 1 ;; 2) text="$text==" ;; 3) text="$text=" ;; esac out=$(printf '%s' "$text" | base64 -d 2>/dev/null) || out=$(printf '%s' "$text" | base64 --decode 2>/dev/null) || out=$(printf '%s' "$text" | base64 -D 2>/dev/null) || return 1 printf '%s' "$out" } # Prints the decoded payload JSON of KW1-.. The signature is # only checked for being present: verifying it is kwatch's job, not this # script's. license_payload_json() { local key="$1" body payload signature json case "$key" in KW1-*) ;; *) return 1 ;; esac body="${key#KW1-}" case "$body" in *.*.*|.*|*.|*[!A-Za-z0-9_.=-]*) return 1 ;; *.*) ;; *) return 1 ;; esac payload="${body%%.*}" signature="${body#*.}" [ -n "$payload" ] && [ -n "$signature" ] || return 1 json=$(base64url_decode "$payload") || return 1 case "$json" in \{*\}) ;; *) return 1 ;; esac printf '%s' "$json" } # One field of a flat JSON object: jq when it is installed, sed otherwise. license_json_field() { local json="$1" field="$2" if license_have_jq; then printf '%s' "$json" | jq -r --arg f "$field" '.[$f] // empty' 2>/dev/null else printf '%s' "$json" | sed -n \ "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" | head -1 fi } license_date() { date -u -r "$1" +%Y-%m-%d 2>/dev/null || date -u -d "@$1" +%Y-%m-%d 2>/dev/null || printf '%s' "$1" } # Decodes the key into LICENSE_PLAN, LICENSE_EXP and LICENSE_DAYS_LEFT. # Returns 0 when usable, 1 when it is not a key at all, 2 when it has expired. license_inspect() { local key="$1" now LICENSE_PAYLOAD="" LICENSE_PLAN="" LICENSE_EXP="" LICENSE_DAYS_LEFT="" LICENSE_PAYLOAD=$(license_payload_json "$key") || return 1 LICENSE_PLAN=$(license_json_field "$LICENSE_PAYLOAD" plan || true) # Every license, the legacy "trial" plan included, is Pro to the operator. case "$LICENSE_PLAN" in trial|pro) LICENSE_PLAN="Pro" ;; esac LICENSE_EXP=$(license_json_field "$LICENSE_PAYLOAD" exp || true) [[ "$LICENSE_EXP" =~ ^[0-9]+$ ]] || return 1 now=$(license_now) LICENSE_DAYS_LEFT=$(((LICENSE_EXP - now) / 86400)) [ "$LICENSE_EXP" -gt "$now" ] || return 2 return 0 } license_report_key() { local key="$1" rc=0 license_inspect "$key" || rc=$? case "$rc" in 0) ui_success "✅ Key accepted: plan ${LICENSE_PLAN:-unknown}, ${LICENSE_DAYS_LEFT} days left (expires $(license_date "$LICENSE_EXP"))." ;; 1) ui_error "❌ That is not a kwatch license key (expected KW1-.)." ;; 2) ui_warn "⚠️ This license key expired on $(license_date "$LICENSE_EXP")." ;; esac return "$rc" } license_secret_exists() { [ -n "$(kubectl -n "$NAMESPACE" get secret "$(license_secret_name)" \ --ignore-not-found -o name 2>/dev/null || true)" ] } # Prints the stored key, or nothing and a failure when there is none. license_current_key() { local encoded key encoded=$(secret_data_base64 "$(license_secret_name)" "$LICENSE_SECRET_KEY") [ -n "$encoded" ] || return 1 key=$(printf '%s' "$encoded" | base64 -d 2>/dev/null || printf '%s' "$encoded" | base64 --decode 2>/dev/null || printf '%s' "$encoded" | base64 -D 2>/dev/null) || return 1 [ -n "$key" ] || return 1 printf '%s' "$key" } # The first kwatch Pod, or nothing and a failure. kwatch_pod_name() { local pod pod=$(kubectl -n "$NAMESPACE" get pods -l "$(workload_selector)" \ -o 'jsonpath={.items[0].metadata.name}' 2>/dev/null || true) [ -n "$pod" ] || return 1 printf '%s' "$pod" } # The text kwatch serves on /status, read through the API server's Pod proxy so # nothing has to be installed in the (read-only, minimal) container. kwatch # serves /status only with healthCheck.statusEndpoint: true (default false). kwatch_status_proxy() { local pod pod=$(kwatch_pod_name) || return 1 kubectl get --raw \ "/api/v1/namespaces/$NAMESPACE/pods/$pod:$LICENSE_HEALTH_PORT/proxy/status" \ --request-timeout=10s 2>/dev/null } # Fallback when /status is off: `kwatch license status` run in the Pod. kwatch_status_exec() { local pod pod=$(kwatch_pod_name) || return 1 kubectl -n "$NAMESPACE" exec "$pod" -- kwatch license status 2>/dev/null } # Turns the output of `kwatch license status` (internal/app/license_cli.go) into # the Edition and License lines of /status. The command prints "Key:", "State:", # "Plan:", "License:" and "Expires:" lines, or one "No license key is # configured: kwatch runs the Community edition." line. State is a local check # of the key (valid key, expired or invalid), not the activation. kwatch_license_cli_to_status() { local text="$1" state plan if grep -qi '^No license key is configured' <<<"$text"; then printf 'Edition: Community\nLicense: community\n' return 0 fi state=$(printf '%s\n' "$text" | sed -n 's/^State:[[:space:]]*//p' | head -1) [ -n "$state" ] || return 1 plan=$(printf '%s\n' "$text" | sed -n 's/^Plan:[[:space:]]*//p' | head -1) case "$state" in valid*) printf 'Edition: %s\n' "${plan:-unknown}" ;; *) printf 'Edition: Community\n' ;; esac printf 'License: %s\n' "$state" } # The /status text, or the same lines rebuilt from `kwatch license status` # when /status is off. Fails when neither answers. kwatch_status_text() { local text text=$(kwatch_status_proxy || true) if [ -n "$text" ]; then printf '%s\n' "$text" return 0 fi text=$(kwatch_status_exec || true) [ -n "$text" ] || return 1 kwatch_license_cli_to_status "$text" } LICENSE_STATUS_UNAVAILABLE="license state unavailable (enable healthCheck.statusEndpoint for full status)" # The Edition and License lines of a /status text. license_status_lines() { printf '%s\n' "$1" | grep -iE '^[[:space:]]*(Edition|License)[^:]*:' || true } license_inactive_line() { printf '%s\n' "$1" | grep -i 'Pro features configured but inactive:' | head -1 || true } # community, invalid, pending, active, grace, expired, revoked or conflict. # The troublesome ones come first so "expired (was active)" reads as expired. license_state_word() { local lower word lower=$(printf ' %s ' "$1" | tr '[:upper:]' '[:lower:]') for word in conflict revoked expired invalid grace pending active community; do case "$lower" in *[!a-z]"$word"[!a-z]*) printf '%s' "$word"; return 0 ;; esac done return 1 } license_state_hint() { case "$1" in conflict) printf 'license is bound to another cluster — choose "Move license to this cluster" in License / Pro, or transfer it at %s' "$LICENSE_ACCOUNT_URL" ;; grace) printf 'kwatch could not verify the license recently; Pro keeps working for now — check egress or renew at %s' "$LICENSE_ACCOUNT_URL" ;; expired) printf 'the license has expired and Pro features are off — renew it at %s' "$LICENSE_ACCOUNT_URL" ;; revoked) printf 'the license was revoked — manage it at %s' "$LICENSE_ACCOUNT_URL" ;; invalid) printf 'kwatch rejected the key — replace it from License / Pro in the menu' ;; *) return 1 ;; esac } # Prints the edition and license lines of a /status text, and calls out the # states that need attention. license_print_status() { local text="$1" lines inactive word hint line lines=$(license_status_lines "$text") inactive=$(license_inactive_line "$text") [ -n "$lines$inactive" ] || return 1 word=$(license_state_word "$lines" || true) while IFS= read -r line; do [ -n "$line" ] || continue line="${line#"${line%%[![:space:]]*}"}" case "$word" in grace|expired|conflict|revoked|invalid) ui_warn " 🎫 $line" ;; *) ui_detail " 🎫 $line" ;; esac done <<<"$lines" if hint=$(license_state_hint "$word" 2>/dev/null); then ui_warn " ↳ $hint" fi if [ -n "$inactive" ]; then inactive="${inactive#"${inactive%%[![:space:]]*}"}" ui_warn " 🔒 $inactive" fi return 0 } license_status_section() { local text text=$(kwatch_status_text || true) if [ -z "$text" ] || ! license_print_status "$text"; then ui_detail " $LICENSE_STATUS_UNAVAILABLE" fi } # community | other | "" (unknown), from the Edition line of /status. license_edition() { local line line=$(kwatch_status_text 2>/dev/null | grep -iE '^[[:space:]]*Edition[^:]*:' | head -1 || true) [ -n "$line" ] || return 0 case "$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" in *community*) printf 'community' ;; *) printf 'other' ;; esac } # Community when /status says so; with no answer, when no key was ever added. license_is_community() { case "$LICENSE_COMMUNITY_CACHE" in yes) return 0 ;; no) return 1 ;; esac case "$(license_edition)" in community) LICENSE_COMMUNITY_CACHE=yes; return 0 ;; other) LICENSE_COMMUNITY_CACHE=no; return 1 ;; esac if license_secret_exists; then LICENSE_COMMUNITY_CACHE=no return 1 fi LICENSE_COMMUNITY_CACHE=yes return 0 } community_provider_cap_message() { printf 'Community sends to 1 provider — extra providers stay inactive until you add a Pro license (free during early access: %s)' \ "$LICENSE_SIGNUP_URL" } # Called when a provider is being added to a configuration that already has # one. Saving is still allowed: the extra providers simply stay inactive. warn_community_provider_cap() { license_is_community || return 0 ui_warn "⚠️ $(community_provider_cap_message)" return 0 } # Pro-only settings, matched on the path itself or anything beneath it. config_path_is_pro() { local top="${1%%.*}" case "$top" in routes|routing|templates|severityByReason|severityByOwnerKind|maintenance|\ runbooks|auditLog|heartbeatMonitor|activeProbeMonitor|namespaceSelector|\ reasons) return 0 ;; esac return 1 } # DEPLOYMENT_OVERRIDES without the license marker: what the sizing screens mean # by "recorded". overrides_without_license() { local entry kept="" local IFS=';' for entry in $DEPLOYMENT_OVERRIDES; do [ -n "$entry" ] || continue [ "$entry" = license=1 ] && continue kept="${kept:+$kept;}$entry" done printf '%s' "$kept" } # Adds (on) or drops (off) the license marker in DEPLOYMENT_OVERRIDES. license_overrides_set() { local state="$1" kept kept=$(overrides_without_license) if [ "$state" = on ]; then kept="${kept:+$kept;}license=1" fi DEPLOYMENT_OVERRIDES="$kept" } # The container's env var, as the Deployment should carry it. license_env_patch() { local container="$1" mode="${2:-set}" name name=$(license_secret_name) printf '{"spec":{"template":{"spec":{"containers":[{"name":"%s","env":[' "$container" if [ "$mode" = remove ]; then # shellcheck disable=SC2016 # "$patch" is the literal merge directive printf '{"name":"%s","$patch":"delete"}' "$LICENSE_ENV_NAME" else printf '{"name":"%s","value":null,"valueFrom":{"secretKeyRef":{"name":"%s","key":"%s","optional":true}}}' \ "$LICENSE_ENV_NAME" "$name" "$LICENSE_SECRET_KEY" fi printf ']}]}}}}' } kwatch_container_name() { local deployment="$1" names name names=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={range .spec.template.spec.containers[*]}{.name}{"\n"}{end}' \ 2>/dev/null || true) while IFS= read -r name; do if [ "$name" = "$RELEASE" ] || [ "$name" = kwatch ]; then printf '%s' "$name" return 0 fi done <<<"$names" return 1 } # True when the Deployment already reads the key from this release's Secret. license_env_wired() { local deployment="$1" current current=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.spec.template.spec.containers[*].env[?(@.name==\"$LICENSE_ENV_NAME\")].valueFrom.secretKeyRef.name}" \ 2>/dev/null || true) [ "$current" = "$(license_secret_name)" ] } # Puts KWATCH_LICENSE_KEY (from the Secret, optional) into the manifest. The # Secret is optional so a Pod still starts, as Community, if the key is gone. inject_license_env_into_manifest() { local manifest="$1" rewritten header=false grep -q "name: $LICENSE_ENV_NAME\$" "$manifest" && return 0 grep -q '^ env:$' "$manifest" || header=true rewritten=$(mktemp) || return 1 if ! KWATCH_LICENSE_SECRET="$(license_secret_name)" \ KWATCH_LICENSE_ENV="$LICENSE_ENV_NAME" \ KWATCH_LICENSE_KEYNAME="$LICENSE_SECRET_KEY" \ KWATCH_ENV_HEADER="$header" awk ' function emit() { printf " - name: %s\n", ENVIRON["KWATCH_LICENSE_ENV"] print " valueFrom:" print " secretKeyRef:" printf " name: %s\n", ENVIRON["KWATCH_LICENSE_SECRET"] printf " key: %s\n", ENVIRON["KWATCH_LICENSE_KEYNAME"] print " optional: true" } ENVIRON["KWATCH_ENV_HEADER"] == "false" && /^ env:$/ && done == 0 { print; emit(); done = 1; next } ENVIRON["KWATCH_ENV_HEADER"] == "true" && /^ image:/ && done == 0 { print; print " env:"; emit(); done = 1; next } { print } ' "$manifest" >"$rewritten" || ! grep -q "name: $LICENSE_ENV_NAME\$" "$rewritten"; then rm -f "$rewritten" return 1 fi mv "$rewritten" "$manifest" || { rm -f "$rewritten"; return 1; } } # Create or update the license Secret. license_store_secret() { local key="$1" name tmp name=$(license_secret_name) tmp=$(mktemp) || return 1 chmod 600 "$tmp" printf '%s' "$key" >"$tmp" if ! kubectl -n "$NAMESPACE" create secret generic "$name" \ --from-file="$LICENSE_SECRET_KEY=$tmp" --dry-run=client -o yaml | kubectl apply -f - >/dev/null; then rm -f "$tmp" return 1 fi rm -f "$tmp" kubectl -n "$NAMESPACE" label secret "$name" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true } license_wire_env() { local deployment="$1" container license_env_wired "$deployment" && return 0 container=$(kwatch_container_name "$deployment") || container=kwatch kubectl -n "$NAMESPACE" patch deployment "$deployment" --type=strategic \ -p "$(license_env_patch "$container")" >/dev/null } # The license Role, RoleBinding and instance Secret. Installs older than # licensing lack them and kwatch cannot keep its instance key without them. # Anything missing is created; anything present is left alone (the Secret in # particular holds kwatch's key and is never rewritten). license_ensure_instance_access() { local deployment="$1" sa role role=$(license_secret_name) sa=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={.spec.template.spec.serviceAccountName}' 2>/dev/null || true) [ -n "$sa" ] || sa="$RELEASE" if ! kubectl -n "$NAMESPACE" get role "$role" >/dev/null 2>&1; then kubectl -n "$NAMESPACE" apply -f - >/dev/null </dev/null 2>&1; then kubectl -n "$NAMESPACE" apply -f - >/dev/null </dev/null 2>&1; then kubectl -n "$NAMESPACE" create secret generic "$LICENSE_INSTANCE_SECRET" \ >/dev/null || return 1 kubectl -n "$NAMESPACE" label secret "$LICENSE_INSTANCE_SECRET" \ app.kubernetes.io/instance="$RELEASE" \ app.kubernetes.io/managed-by=kwatch.sh --overwrite >/dev/null 2>&1 || true fi } license_rollout_incomplete() { ui_warn "⚠️ The key is saved and kwatch was told to use it, but the rollout has not finished." ui_warn " Nothing was deleted or repaired. Check the Pod with Status and Logs; the rollout" ui_warn " keeps going on its own, and Remove key restores the previous state." } # Stores the key and wires it into kwatch. Mode "record" stops short of touching # the running workload: the next apply_manifests picks the marker up (used when # an upgrade is about to apply the manifest anyway). license_apply_key() { local key="$1" mode="${2:-full}" deployment already=false with_loading "Saving the license key" license_store_secret "$key" || { ui_error "❌ Could not save the license Secret." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" return 1 } deployment=$(deployment_name || true) deployment_overrides_read license_overrides_set on if [ -z "$deployment" ] || [ "$mode" = record ]; then deployment_overrides_write "${deployment:-$RELEASE}" "$DEPLOYMENT_OVERRIDES" \ >/dev/null 2>&1 || true LICENSE_FLOW_CHANGED=true ui_detail " The key is stored; it is applied with the next install or upgrade." return 0 fi with_loading "Checking kwatch's access to its license record" \ license_ensure_instance_access "$deployment" || { ui_error "❌ This install predates licensing and its license RBAC could not be added." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" ui_detail " The key is stored, but kwatch was not changed. Upgrade kwatch from the" ui_detail " menu first (it adds the missing Role, RoleBinding and Secret), then add the key again." return 1 } license_env_wired "$deployment" && already=true with_loading "Pointing kwatch at the license Secret" \ license_wire_env "$deployment" || { ui_error "❌ Could not add $LICENSE_ENV_NAME to the Deployment." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" return 1 } with_loading "Recording the license for future upgrades" \ deployment_overrides_write "$deployment" "$DEPLOYMENT_OVERRIDES" || { ui_warn "⚠️ The key is active, but it could not be recorded;" ui_warn " an upgrade might drop it. Add it again after upgrading." } LICENSE_FLOW_CHANGED=true invalidate_deployment_name LICENSE_COMMUNITY_CACHE="" # Exactly one rollout. Wiring the env into the Deployment changes the Pod # template, which rolls it out by itself; only a key that was already wired # (a replaced key) needs the restart. Neither path deletes anything: on a # timeout the rollout is left as it is, with its diagnostics shown. if [ "$already" = true ]; then restart_kwatch || { license_rollout_incomplete return 1 } else with_loading "Waiting for kwatch rollout" \ wait_for_kwatch_rollout "$deployment" || { license_rollout_incomplete return 1 } fi ui_success "✅ License key applied: $(license_mask "$key")" } # The same key supplied through the environment: no prompts. An unusable key is # reported and skipped rather than failing an install that otherwise worked. license_apply_from_env() { local mode="${1:-full}" key="${KWATCH_LICENSE_KEY:-}" rc=0 key="${key//[[:space:]]/}" [ -n "$key" ] || return 0 ui_info "🔑 Applying $LICENSE_ENV_NAME ($(license_mask "$key"))" license_report_key "$key" || rc=$? if [ "$rc" -ne 0 ]; then ui_warn "⚠️ Skipping the license key from $LICENSE_ENV_NAME." return 0 fi license_apply_key "$key" "$mode" || ui_warn "⚠️ The license key could not be applied; add it from License / Pro." } # The last step of an install. license_install_prompt() { local key rc=0 if [ -n "${KWATCH_LICENSE_KEY:-}" ]; then license_apply_from_env full return 0 fi license_secret_exists && return 0 key=$(ask_secret "🔑 Have a Pro license key? (Enter to skip)") || exit_expected key="${key//[[:space:]]/}" if [ -z "$key" ]; then ui_detail " Get a free Pro license: $LICENSE_SIGNUP_URL" return 0 fi license_report_key "$key" || rc=$? if [ "$rc" -eq 1 ]; then ui_detail " Add it later from License / Pro in the menu." return 0 fi if [ "$rc" -eq 2 ]; then confirm_action "Use the expired key anyway" "n" || return 0 fi license_apply_key "$key" full || ui_warn "⚠️ The license key could not be applied; add it from License / Pro." } license_show_status() { local key text ui_screen "license" if key=$(license_current_key); then ui_kv "🔑" "Key" "$(license_mask "$key")" local rc=0 license_inspect "$key" || rc=$? if [ "$rc" -eq 1 ]; then ui_warn "⚠️ The stored key does not decode as a kwatch license key." else ui_kv "🎟️" "Plan" "${LICENSE_PLAN:-unknown}" if [ "$rc" -eq 2 ]; then ui_warn " ⌛ Expired on $(license_date "$LICENSE_EXP")" else ui_kv "⏳" "Days left" "$LICENSE_DAYS_LEFT (expires $(license_date "$LICENSE_EXP"))" fi fi else ui_detail " No license key is configured: this is the Community edition." ui_detail " Get a free Pro license: $LICENSE_SIGNUP_URL" fi printf '\n' >&2 text=$(kwatch_status_text || true) if [ -z "$text" ] || ! license_print_status "$text"; then ui_detail " $LICENSE_STATUS_UNAVAILABLE" fi } license_add_flow() { local key rc=0 ui_heading "🔑 Add or replace the license key" license_secret_exists || ui_detail " No key yet? Get a free Pro license: $LICENSE_SIGNUP_URL" key=$(ask_secret_or_back "Pro license key") || { ui_info "↩️ Nothing was changed." return 0 } key="${key//[[:space:]]/}" [ -n "$key" ] || { ui_info "↩️ Nothing was changed."; return 0; } license_report_key "$key" || rc=$? [ "$rc" -ne 1 ] || return 0 if [ "$rc" -eq 2 ]; then confirm_action "Save the expired key anyway" "n" || { ui_info "↩️ Nothing was changed." return 0 } fi check_access patch deployments namespace license_apply_key "$key" full || ui_error "❌ The license key was not fully applied." } license_remove_flow() { local deployment container name ui_heading "🗑️ Remove the license key" name=$(license_secret_name) if ! license_secret_exists; then ui_detail " No license key is configured." ui_detail " Get a free Pro license: $LICENSE_SIGNUP_URL" return 0 fi confirm_change "🗑️ Remove the license key and return to Community." \ "The Secret $name and the $LICENSE_ENV_NAME setting are deleted and kwatch restarts. To use the key on another cluster, release it first (uninstall offers this)." || { ui_info "↩️ Nothing was changed."; return 0; } deployment=$(deployment_name || true) deployment_overrides_read license_overrides_set off if [ -n "$deployment" ]; then container=$(kwatch_container_name "$deployment") || container=kwatch with_loading "Removing $LICENSE_ENV_NAME from the Deployment" \ kubectl -n "$NAMESPACE" patch deployment "$deployment" --type=strategic \ -p "$(license_env_patch "$container" remove)" >/dev/null || { ui_error "❌ Could not update the Deployment." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" return 1 } deployment_overrides_write "$deployment" "$DEPLOYMENT_OVERRIDES" \ >/dev/null 2>&1 || true fi kubectl -n "$NAMESPACE" delete secret "$name" --ignore-not-found >/dev/null || ui_warn "⚠️ The Secret $name could not be deleted." LICENSE_FLOW_CHANGED=true LICENSE_COMMUNITY_CACHE="" invalidate_deployment_name if [ -n "$deployment" ]; then with_loading "Waiting for kwatch rollout" \ wait_for_kwatch_rollout "$deployment" || return 1 fi ui_success "✅ License key removed; kwatch is running as Community." } # Takes the license over from the cluster that holds it. `kwatch license move` # runs in the Pod: it asks the leader to move and prints the outcome (moved, # already here, transfer limit reached, invalid key). It can wait up to about # two minutes for the leader, so its output is shown as it comes. license_move_flow() { local pod ui_heading "🚚 Move the license to this cluster" if ! license_secret_exists; then ui_detail " No license key is configured here. Add it first with Add or replace key." return 0 fi confirm_change "🚚 Move the license to this cluster." \ "The cluster that holds it now drops to Community at its next daily check. A license can move 3 times in 30 days." || { ui_info "↩️ Nothing was changed."; return 0; } pod=$(kwatch_pod_name) || { ui_error "❌ No kwatch Pod was found in $NAMESPACE; wait for kwatch to start and try again." return 1 } ui_info "⏳ Asking kwatch to move the license (this can take up to 2 minutes)" LICENSE_COMMUNITY_CACHE="" if ! kubectl -n "$NAMESPACE" exec "$pod" -- kwatch license move; then ui_warn "⚠️ The license was not moved. You can also transfer it at $LICENSE_ACCOUNT_URL" return 1 fi } # Cluster check-ins: whether the Pod pings api.kwatch.dev every 30 minutes so # app.kwatch.dev can show the cluster as silent. Stored in the KwatchConfig # (spec.clusterCheckins, default on) so upgrades keep it. CHECKINS_CONFIG_PATH="clusterCheckins" checkins_state() { case "$(config_value "$CHECKINS_CONFIG_PATH")" in false) printf 'off' ;; *) printf 'on' ;; esac } checkins_flow() { local state choice target reason ui_heading "Cluster check-ins" ui_detail " kwatch checks in with api.kwatch.dev every 30 minutes. If it goes quiet" ui_detail " for 60 minutes, app.kwatch.dev shows this cluster as silent." ui_detail " Nothing is sent to you. Sent: a random or hashed installation ID, the kwatch version," ui_detail " the node count and the cluster name if you set one." state=$(checkins_state) if [ "$state" = on ]; then choice=$(ui_select 0 "Turn off" "Back") || return 0 target=false else choice=$(ui_select 0 "Turn on" "Back") || return 0 target=true fi [ "$choice" = 0 ] || return 0 if reason=$(patch_config_value "$CHECKINS_CONFIG_PATH" boolean "$target" 2>&1); then LICENSE_FLOW_CHANGED=true if [ "$target" = true ]; then ui_success "Cluster check-ins are on. The first check-in follows within 30 minutes." else ui_success "Cluster check-ins are off." ui_detail " If the final check-in cannot reach api.kwatch.dev, app.kwatch.dev shows this" ui_detail " cluster as silent until the key is removed or moved." fi else ui_error "Could not change Cluster check-ins: $(compact_reason "${reason:-no diagnostic was returned}"). Nothing was changed. Run the installer again after fixing it." fi } # Runs `kwatch test-alert` in the Pod through the live delivery path. testalert_flow() { local selector pod rc=0 out status=0 count choice local -a args=() names=() ui_screen "test alert" selector=$(workload_selector) ui_busy "Finding the kwatch Pod" pod=$(select_workload_pod "$selector") || rc="$?" ui_busy_done [ "${rc:-0}" -ne 2 ] || return 0 if [ -z "$pod" ]; then ui_warn "No kwatch Pod was found in namespace $NAMESPACE. Install or repair kwatch first." ui_pause return 0 fi if [ "$(kubectl -n "$NAMESPACE" get pod "$pod" \ -o 'jsonpath={.status.containerStatuses[0].ready}' 2>/dev/null || true)" != true ]; then ui_warn "The kwatch Pod is not ready yet. Wait a minute, or choose View status." ui_pause return 0 fi # `--provider ?` prints the configured provider names, one per line. while IFS= read -r name; do name="${name//[[:space:]]/}" [ -n "$name" ] && names+=("$name") done < <(kubectl -n "$NAMESPACE" exec "$pod" -- kwatch test-alert --provider "?" 2>/dev/null || true) count=${#names[@]} if [ "$count" -gt 1 ]; then choice=$(ui_select 0 "All providers" "${names[@]}") || return 0 [ "$choice" = 0 ] || args=(--provider "${names[$((choice - 1))]}") fi ui_busy "Sending the test alert" out=$(kubectl -n "$NAMESPACE" exec "$pod" -- kwatch test-alert --from installer ${args[@]+"${args[@]}"} 2>&1) || status=$? ui_busy_done if printf '%s' "$out" | grep -qi 'unknown command'; then ui_warn "This kwatch version does not have test-alert yet. Choose Upgrade kwatch, then try again." else printf '%s\n' "$out" | redact_stream >&2 fi ui_pause return 0 } # ---- Save diagnostics to file ------------------------------------------------ # Collects on this machine with kubectl, redacts, asks, and writes one archive. # Nothing is uploaded. No Secret is read. DIAG_WORK="" DIAG_MAX_BYTES=$((10 * 1024 * 1024)) diag_text_redact() { sed -E 's#https?://[^[:space:]"'"'"')]+##g; s#[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}##g; s#KW1-[A-Za-z0-9_-]+##g; s#([0-9]{1,3}\.){3}[0-9]{1,3}##g; s#([0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{0,4}##g; s#eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_.-]+##g; s#AKIA[0-9A-Z]{16}##g' | redact_stream } # Prefer the Pod's own `kwatch redact` (one list for shell and Go); then the # shell pass on top as a floor. diag_redact() { if [ -n "${DIAG_POD_REDACT:-}" ]; then kubectl -n "$NAMESPACE" exec -i "$DIAG_POD" -- kwatch redact 2>/dev/null | diag_text_redact else diag_text_redact fi } # Keeps keys, booleans, numbers and a few enum words; every other string is # or . diag_config_allowlist() { awk ' /^spec:/ { on=1; print; next } on && /^[^ #]/ { exit } on { line=$0 if (match(line, /^[ -]*[A-Za-z0-9_.\/-]+:[ ]+/)) { head=substr(line, 1, RLENGTH); val=substr(line, RLENGTH+1) if (val ~ /^(true|false|[0-9]+([.][0-9]+)?|debug|info|warn|error|slack|discord|telegram|email|pagerduty|opsgenie|teams|webhook|mattermost|rocketchat|zulip|ntfy|gotify|googlechat|matrix|line|dingtalk|feishu|lark|sns|twilio|pushover|text|json)$/) print line else if (val ~ /^(""|'"''"'|\[\]|\{\})$/) print head "" else if (val ~ /^[|>]/) print head "" else print head "" } else if (match(line, /^[ ]*-[ ]+/)) { head=substr(line, 1, RLENGTH); val=substr(line, RLENGTH+1) if (val ~ /^(true|false|[0-9]+)$/) print line; else print head "" } else if (line ~ /:[ ]*$/) print line else if (line ~ /^[ ]+[^ ]/) { match(line, /^[ ]+/); print substr(line, 1, RLENGTH) "" } }' } diag_deployment_clean() { awk ' /^ managedFields:/ { skip=1; next } skip && /^ / { next } skip && /^ [^ ]/ { skip=0 } /kubectl.kubernetes.io\/last-applied-configuration/ { next } { if ($0 ~ /^[ -]*value:[ ]/) { sub(/value:.*/, "value: ") } print }' } # Final scan: any hit aborts. Prints the offending file name. diag_scan() { local dir="$1" f hit="" for f in "$dir"/*; do [ -f "$f" ] || continue case "$(basename "$f")" in README.txt) continue ;; esac if sed -E 's#@sha256:[0-9a-f]+##g' "$f" | grep -Eiq \ 'https?://|@|[A-Za-z0-9+/=_-]{41,}|(token|secret|password|key)[[:space:]]*[=:][[:space:]]*[^<[:space:]]'; then # Allow plain booleans and numbers after a key name. if sed -E 's#@sha256:[0-9a-f]+##g' "$f" | grep -Ei \ 'https?://|@|[A-Za-z0-9+/=_-]{41,}|(token|secret|password|key)[[:space:]]*[=:][[:space:]]*[^<[:space:]]' | grep -Eiv '(token|secret|password|key)[[:space:]]*[=:][[:space:]]*(true|false|[0-9]+)[[:space:]]*$' | grep -q .; then hit=$(basename "$f") break fi fi done [ -z "$hit" ] || { printf '%s' "$hit"; return 1; } return 0 } diag_kb() { local f="$1" bytes bytes=$(wc -c <"$f" 2>/dev/null | tr -d ' ') printf '%s' $(((${bytes:-0} + 1023) / 1024)) } diag_cluster_slug() { local name slug name=$(config_value app.clusterName) [ -n "$name" ] || name="$NAMESPACE" slug=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed -E 's/-+/-/g;s/^-//;s/-$//' | cut -c1-30 | sed -E 's/-$//') printf '%s' "${slug:-cluster}" } diag_collect() { local dir="$1" lines="$2" missing="" err local deployment mkdir -p "$dir" ui_detail " Status" { kwatch_status_text 2>/dev/null || true; } | diag_redact >"$dir/status.txt" ui_detail " Logs" if [ -n "$DIAG_POD" ]; then kubectl -n "$NAMESPACE" logs "$DIAG_POD" --tail="$lines" 2>&1 | diag_redact >"$dir/logs.txt" if kubectl -n "$NAMESPACE" logs "$DIAG_POD" --previous --tail="$lines" >"$dir/.prev" 2>/dev/null; then diag_redact <"$dir/.prev" >"$dir/logs-previous.txt" fi rm -f "$dir/.prev" fi ui_detail " Events" if err=$(kubectl -n "$NAMESPACE" get events 2>&1 >"$dir/.events"); then diag_redact <"$dir/.events" >"$dir/events.txt" else missing="${missing:+$missing,}\"events\"" DIAG_NOTES="${DIAG_NOTES}Could not read events ($(compact_reason "$err")). The diagnostics were saved without them. " fi rm -f "$dir/.events" ui_detail " Configuration (secrets removed)" kubectl -n "$NAMESPACE" get kwatchconfig "$RELEASE" -o yaml 2>/dev/null | diag_config_allowlist >"$dir/config.yaml" || true [ -s "$dir/config.yaml" ] || rm -f "$dir/config.yaml" if [ -n "$DIAG_POD" ]; then kubectl -n "$NAMESPACE" describe pod "$DIAG_POD" 2>/dev/null | sed -E 's/^([[:space:]]+[A-Za-z0-9_.-]+):[[:space:]]+.*(/' | awk '/^ Environment:/ {env=1; print; next} env && /^ [A-Za-z]/ {env=0} env && /^ [A-Za-z_]/ {sub(/:.*/, ": ")} {print}' | diag_redact >"$dir/pods.txt" fi deployment=$(deployment_name 2>/dev/null || true) if [ -n "$deployment" ]; then kubectl -n "$NAMESPACE" get deployment "$deployment" -o yaml 2>/dev/null | diag_deployment_clean | diag_redact >"$dir/deployment.yaml" fi DIAG_MISSING="$missing" } diag_write_manifest() { local dir="$1" lines="$2" server nodes edition server=$(kubectl version -o json 2>/dev/null | sed -n '/serverVersion/,$p' | sed -n 's/.*"gitVersion": *"\([^"]*\)".*/\1/p' | head -1) nodes=$(kubectl get nodes --no-headers 2>/dev/null | wc -l | tr -d ' ') edition=$(kwatch_status_text 2>/dev/null | sed -n 's/^Edition:[[:space:]]*//p' | head -1) { printf '{\n' printf ' "bundle_format": 1,\n' printf ' "created_utc": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf ' "installer_catalog": "%s",\n' "$(json_escape "${INSTALL_VERSION:-unknown}")" printf ' "edition": "%s",\n' "$(json_escape "${edition:-unknown}")" printf ' "kubernetes_server": "%s",\n' "$(json_escape "${server:-unknown}")" printf ' "node_count": %s,\n' "${nodes:-0}" printf ' "installer_os": "%s",\n' "$(uname -s 2>/dev/null || echo unknown)" printf ' "log_lines": %s,\n' "$lines" printf ' "kwatch_pod_found": %s,\n' "$([ -n "$DIAG_POD" ] && echo true || echo false)" printf ' "missing": [%s]\n' "${DIAG_MISSING:-}" printf '}\n' } >"$dir/manifest.json" } diag_write_readme() { cat >"$1/README.txt" <<'README' kwatch diagnostics This file was made on your computer by the kwatch installer. kwatch did not upload it. It holds recent kwatch logs, events from the kwatch namespace, the kwatch settings with secrets removed, and version information. Removed: Secrets, your license key, webhook URLs, tokens, e-mail addresses and IP addresses. Pod names and the namespace name are kept because they are needed to debug. Look through it before you send it. Attach it when you reply to our support email. README } diag_output_dir() { if [ -w . ]; then printf '.'; else printf '%s' "$HOME"; fi } diagnostics_flow() { local selector lines logs_cut=false dir out_dir slug date_s name n=1 file local total_kb size choice scan_hit review ui_screen "diagnostics" command -v kubectl >/dev/null 2>&1 || { ui_error "kubectl is needed to collect the diagnostics and could not reach a cluster. Select a cluster first (View status shows which one)." ui_pause return 0 } if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then ui_error "kubectl is needed to collect the diagnostics and could not reach a cluster. Select a cluster first (View status shows which one)." ui_pause return 0 fi selector=$(workload_selector) DIAG_POD=$(kubectl -n "$NAMESPACE" get pods -l "$selector" \ -o 'jsonpath={.items[0].metadata.name}' 2>/dev/null || true) DIAG_POD_REDACT="" DIAG_NOTES="" if [ -n "$DIAG_POD" ] && printf 'x' | kubectl -n "$NAMESPACE" exec -i "$DIAG_POD" -- kwatch redact >/dev/null 2>&1; then DIAG_POD_REDACT=1 fi DIAG_WORK=$(mktemp -d 2>/dev/null) || { ui_error "Could not write a working folder. Free some space and try again."; ui_pause; return 0; } dir="$DIAG_WORK/bundle" lines=2000 ui_busy "Collecting" ui_busy_done diag_collect "$dir" "$lines" for n in 1000 500 200; do size=$(du -sk "$dir" | awk '{print $1*1024}') [ "${size:-0}" -le "$DIAG_MAX_BYTES" ] && break logs_cut=true lines=$n diag_collect "$dir" "$lines" done [ -n "$DIAG_POD" ] || DIAG_NOTES="${DIAG_NOTES}No kwatch Pod was found, so the diagnostics have no logs. Everything else was saved. " diag_write_readme "$dir" diag_write_manifest "$dir" "$lines" ui_detail " Checking nothing private is left" if scan_hit=$(diag_scan "$dir"); then :; else ui_error "A line that may hold a secret was left in $scan_hit. Nothing was saved. Please tell us in Get help." rm -rf "$DIAG_WORK"; DIAG_WORK="" ui_pause return 0 fi while true; do total_kb=0 ui_heading "Save diagnostics to a file" ui_detail " kwatch status $(diag_kb "$dir/status.txt") KB" ui_detail " Last $lines log lines $(diag_kb "$dir/logs.txt") KB" ui_detail " Events from the kwatch namespace $(diag_kb "$dir/events.txt") KB" ui_detail " Settings, secrets removed $(diag_kb "$dir/config.yaml") KB" ui_detail " kwatch and Kubernetes versions $(diag_kb "$dir/manifest.json") KB" total_kb=$(du -sk "$dir" | awk '{print $1}') ui_detail " Total ~${total_kb} KB" printf '\n' >&2 ui_detail "Removed: Secrets, your license key, webhook URLs, tokens, e-mail" ui_detail "addresses and IP addresses." ui_detail "The file stays on this computer. kwatch does not upload it." [ "$logs_cut" != true ] || ui_detail "Logs were cut to the last $lines lines to keep the file small." [ -z "$DIAG_NOTES" ] || ui_warn "$DIAG_NOTES" choice=$(ui_select 0 "Save the file" "Review the files first" "Cancel") || choice=2 case "$choice" in 1) review="$DIAG_WORK/review" rm -rf "$review"; cp -R "$dir" "$review" ui_info "Opened at $review. Look through it, then come back here." ui_pause ;; 0) break ;; *) rm -rf "$DIAG_WORK"; DIAG_WORK="" ui_info "Nothing was saved." return 0 ;; esac done out_dir=$(diag_output_dir) slug=$(diag_cluster_slug) date_s=$(date -u +%Y%m%d) name="kwatch-diagnostics-$slug-$date_s" file="$out_dir/$name.tar.gz" n=2 while [ -e "$file" ]; do file="$out_dir/$name-$n.tar.gz" n=$((n + 1)) done if ! tar -czf "$file" -C "$dir" . 2>/dev/null; then ui_error "Could not write $file. Free some space or run the installer from a folder you can write to." rm -rf "$DIAG_WORK"; DIAG_WORK="" ui_pause return 0 fi size=$(wc -c <"$file" | tr -d ' ') rm -rf "$DIAG_WORK"; DIAG_WORK="" ui_heading "Diagnostics saved" ui_detail " $file ($(((size + 1023) / 1024)) KB)" printf '\n' >&2 ui_detail "Attach this file when you reply to our support email." ui_detail "No support email yet? Pro users: open app.kwatch.dev, choose Get help" ui_detail "and describe what happened. We reply by e-mail." ui_detail "Community: open an issue at github.com/abahmed/kwatch/issues. Attach the" ui_detail "file only after you have read it; issues are public." if [ -f /.dockerenv ] || [ -n "${KUBERNETES_SERVICE_HOST:-}" ] || [ -n "${CI:-}" ]; then printf '\n' >&2 ui_detail "Copy it out of the container before it stops, for example with kubectl cp or docker cp." elif [ -n "${SSH_CONNECTION:-}" ]; then local full host full="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" host=$(hostname 2>/dev/null || echo host) printf '\n' >&2 ui_detail "This file is on $host. To copy it to your computer, run this on your" ui_detail "computer:" ui_detail " scp ${USER:-user}@$host:$full ." fi ui_pause return 0 } # The Pro checks count from the /status text (Community only, and only when # healthCheck.statusEndpoint is on). Prints the two installer lines, or nothing # when the line is missing, the edition is not Community, or the count is zero. license_pro_checks_lines() { local text line rest text=$(kwatch_status_proxy 2>/dev/null || true) [ -n "$text" ] || return 0 printf '%s\n' "$text" | grep -iE '^[[:space:]]*Edition[^:]*:.*community' >/dev/null || return 0 line=$(printf '%s\n' "$text" | grep -E '^[[:space:]]*Pro checks: [0-9]+ issues? in the last 7 days' | head -1 || true) [ -n "$line" ] || return 0 rest="${line#*Pro checks: }" rest="${rest%%. Details*}" case "$rest" in *' ('*')') rest="${rest/ (/: }"; rest="${rest%)}" ;; esac printf 'Pro checks found %s.\n' "$rest" printf 'Pro is free during early access. Choose "Add or replace key" to see them.\n' } license_flow() { local choice pro_lines line LICENSE_FLOW_CHANGED=false LICENSE_COMMUNITY_CACHE="" pro_lines=$(license_pro_checks_lines 2>/dev/null || true) while true; do ui_screen "license / pro" if [ -n "$pro_lines" ]; then while IFS= read -r line; do ui_detail " $line"; done <<<"$pro_lines" fi local -a lacts=(status) llabels=("📋 Show license status") # Only with an active key: absent otherwise, not greyed. if license_secret_exists; then lacts+=(checkins) llabels+=("📡 Cluster check-ins: $(checkins_state) (change)") fi lacts+=(add move remove back) llabels+=("🔑 Add or replace key" "🚚 Move license to this cluster" "🗑️ Remove key" "↩️ Back") choice=$(ui_select 0 "${llabels[@]}") || break case "${lacts[$choice]}" in status) license_show_status; ui_pause ;; checkins) checkins_flow; ui_pause ;; add) license_add_flow; ui_pause ;; move) license_move_flow || true; ui_pause ;; remove) license_remove_flow; ui_pause ;; *) break ;; esac done [ "$LICENSE_FLOW_CHANGED" = true ] || MENU_READ_ONLY=true return 0 } # Uninstall: free the key so another cluster can use it. license_release_before_uninstall() { local pod license_secret_exists || return 0 confirm_action "Release the license so it can be used on another cluster?" "y" || return 0 pod=$(kubectl -n "$NAMESPACE" get pods -l "$(workload_selector)" \ -o 'jsonpath={.items[0].metadata.name}' 2>/dev/null || true) if [ -n "$pod" ] && kubectl -n "$NAMESPACE" exec "$pod" -- kwatch license deactivate; then ui_success "✅ License released." else ui_warn "⚠️ Could not release the license from the Pod; you can release it from $LICENSE_ACCOUNT_URL" fi } license_delete_after_uninstall() { local name name=$(license_secret_name) if license_secret_exists || [ -n "$(kubectl -n "$NAMESPACE" get secret \ "$LICENSE_INSTANCE_SECRET" --ignore-not-found -o name 2>/dev/null || true)" ]; then if confirm_action "Delete the license Secrets ($name and $LICENSE_INSTANCE_SECRET)?" "n"; then kubectl -n "$NAMESPACE" delete secret "$name" "$LICENSE_INSTANCE_SECRET" \ --ignore-not-found >/dev/null 2>&1 || true else ui_info "🔐 Keeping the license Secrets." fi fi } usage() { cat <<'USAGE' 🧭 Usage: kwatch.sh [share-dashboard] Interactive kubectl manager with operational security checks. Run it with no arguments; every action is chosen from the menu. --help, -h Show this help and exit --version, -v Show the manager catalog version and exit share-dashboard Share the dashboard without questions, from the KWATCH_DASHBOARD_* variables below (Pro; kwatch must already be installed). Setting KWATCH_DASHBOARD_EXPOSE does the same without the argument. Environment: KWATCH_CONTEXT kubeconfig context to manage; skips the cluster picker KWATCH_NAMESPACE Namespace to manage (default: kwatch) KWATCH_RELEASE Release name to manage (default: kwatch) KWATCH_PLAIN_UI=true Disable spinners and colour KWATCH_ALLOW_NAMESPACE_LABELS=true Allow restricted Pod Security labels on a namespace the manager did not create KWATCH_SKIP_ADMISSION_PREFLIGHT=true Skip the admission-injection dry run KWATCH_SHOW_DIFF=true Print what an upgrade will change in the cluster before applying it KWATCH_NO_TITLE=1 Leave the terminal tab title alone KWATCH_ROLLOUT_TIMEOUT How long to wait for a rollout (default: 5m) KWATCH_BACKUP_KEEP Configuration backups to retain (default: 5) KWATCH_STORAGE=pvc|emptydir Where the state lives: a claim on the default StorageClass (default), or an emptyDir that is lost when the Pod moves KWATCH_STORAGE_CLASS StorageClass for the state claim KWATCH_STORAGE_SIZE Size of the state volume (default: 2Gi) KWATCH_LICENSE_KEY Pro license key (KW1-...). When set, install and upgrade store it in the ${RELEASE}-license Secret and apply it without prompting. Manage it later from License / Pro in the menu Sharing the dashboard without questions: KWATCH_DASHBOARD_EXPOSE auto | ingress | gateway | lb | route | proxy | service. auto shares it the way the cluster's other apps are shared (class or Gateway, domain, https), so usually nothing else is needed KWATCH_DASHBOARD_HOST Address people open (default: kwatch.) KWATCH_DASHBOARD_INGRESS_CLASS IngressClass (default: the default class, or the only one) KWATCH_DASHBOARD_TLS How https is provided: upstream a load balancer in front of the ingress ends https default the ingress's default (e.g. wildcard) certificate cert-manager[:] secret: an existing TLS Secret self self-signed acm: AWS ALB with ACM (default: worked out from a Secret that covers the name, a live https check, the issuer your other apps or this domain use) KWATCH_DASHBOARD_PRIVATE true (default) | false: internal or public ALB / cloud load balancer KWATCH_DASHBOARD_ALLOW_IPS Allowed addresses or CIDRs, comma separated KWATCH_DASHBOARD_PROXIES Addresses or CIDRs the ingress, gateway or proxy connects to kwatch from, comma separated (needed with ingress, gateway, route, proxy) KWATCH_DASHBOARD_DNS auto (default): add the DNS record with your provider's CLI (aws, gcloud, az, doctl) or CLOUDFLARE_API_TOKEN when the name is free; overwrite: also replace one that points elsewhere; off: only show the record KWATCH_DASHBOARD_ANNOTATIONS Extra annotations for the Ingress, Route or load balancer, key=value;key=value (for example a controller setting); also used by the menu Example, share it like the cluster's other apps: KWATCH_CONTEXT=my-cluster KWATCH_DASHBOARD_EXPOSE=auto \ /bin/bash -c "$(curl -fsSL https://kwatch.dev/kwatch.sh)" Example, a private link behind an internal ingress whose load balancer already ends https: KWATCH_CONTEXT=my-cluster KWATCH_DASHBOARD_EXPOSE=ingress \ KWATCH_DASHBOARD_INGRESS_CLASS=nlb-internal \ KWATCH_DASHBOARD_HOST=kwatch.example.com KWATCH_DASHBOARD_TLS=upstream \ /bin/bash -c "$(curl -fsSL https://kwatch.dev/kwatch.sh)" USAGE } # Loading a release catalog is a precondition for every configuration screen, # so keep the failure message in one place. require_catalog_for() { local purpose="$1" maybe_load_catalog "$INSTALL_VERSION" || die "release catalogs are unavailable; $purpose cannot continue" migration_notice } # Return to the menu after each action instead of ending the session, so a # status check or a settings edit does not cost a fresh run and another context # selection. The installation is reassessed every time, because the previous # action usually changed it. # Resources and placement, asked once and then remembered. The values shown as # defaults are the ones running now, so pressing Enter through the questions # changes nothing. # One value at a time, chosen from a list, the way settings are edited: walking # through five prompts to change one number was four questions too many. Nothing # reaches the cluster until Apply, so Escape at any point costs nothing. configure_deployment_resources_flow() { local deployment index choice count cursor=0 local requests_cpu requests_memory limits_cpu limits_memory node_spec local live_requests_cpu live_requests_memory live_limits_cpu local live_limits_memory live_node_spec local -a labels=() captions=() deployment=$(deployment_name || true) if [ -z "$deployment" ]; then ui_error "❌ kwatch is not installed, so there is no Deployment to size." return 1 fi index=$(kwatch_container_index "$deployment") || index="" if [ -z "$index" ]; then ui_error "❌ Could not find the kwatch container in the Deployment." return 1 fi ui_busy "Reading the current Deployment resources" live_requests_cpu=$(live_resource_value "$deployment" "$index" requests cpu) live_requests_memory=$(live_resource_value "$deployment" "$index" requests memory) live_limits_cpu=$(live_resource_value "$deployment" "$index" limits cpu) live_limits_memory=$(live_resource_value "$deployment" "$index" limits memory) live_node_spec=$(live_node_selector "$deployment") deployment_overrides_read ui_busy_done requests_cpu="$live_requests_cpu" requests_memory="$live_requests_memory" limits_cpu="$live_limits_cpu" limits_memory="$live_limits_memory" node_spec="$live_node_spec" while true; do ui_screen_clear ui_heading "🧮 Deployment resources and placement" if [ -n "$(overrides_without_license)" ]; then ui_detail " Recorded; upgrades and reinstalls keep these values." else ui_detail " Release defaults; applying here makes upgrades keep them." fi labels=() captions=() override_field_row "CPU request" "$requests_cpu" "$live_requests_cpu" captions+=("Guaranteed CPU. Written as 100m, 250m, 0.5 or 2.") override_field_row "CPU limit" "$limits_cpu" "$live_limits_cpu" captions+=("Ceiling before the container is throttled.") override_field_row "Memory request" "$requests_memory" "$live_requests_memory" captions+=("Guaranteed memory. Written as 128Mi, 512Mi or 1Gi.") override_field_row "Memory limit" "$limits_memory" "$live_limits_memory" captions+=("Ceiling before the container is killed; also caps the Go heap.") override_field_row "Placement" "${node_spec:-any node}" "${live_node_spec:-any node}" captions+=("Node selector as key=value,key=value — or any node.") count=5 if deployment_shape_changed "$requests_cpu" "$live_requests_cpu" \ "$requests_memory" "$live_requests_memory" "$limits_cpu" \ "$live_limits_cpu" "$limits_memory" "$live_limits_memory" \ "$node_spec" "$live_node_spec"; then labels+=("✅ Apply changes") captions+=("Patch the Deployment, record the values, wait for the new Pod.") else labels+=("${UI_DIM}✅ Apply changes (nothing changed yet)${UI_RESET}") captions+=("Edit a value above first.") fi labels+=("↩️ Back") captions+=("Leave without changing anything.") choice=$(ui_select_captioned "$cursor" "${#labels[@]}" "${labels[@]}" \ "${captions[@]}") || { MENU_READ_ONLY=true; return 0; } # Stay on the row that was just edited rather than jumping back to the top. cursor="$choice" case "$choice" in 0) requests_cpu=$(edit_cpu_field "CPU request" "$requests_cpu") ;; 1) limits_cpu=$(edit_cpu_field "CPU limit" "$limits_cpu") ;; 2) requests_memory=$(edit_memory_field "Memory request" "$requests_memory") ;; 3) limits_memory=$(edit_memory_field "Memory limit" "$limits_memory") ;; 4) node_spec=$(edit_placement_field "$node_spec") ;; "$count") if ! deployment_shape_changed "$requests_cpu" "$live_requests_cpu" \ "$requests_memory" "$live_requests_memory" "$limits_cpu" \ "$live_limits_cpu" "$limits_memory" "$live_limits_memory" \ "$node_spec" "$live_node_spec"; then ui_info "ℹ️ Nothing has been changed yet." continue fi apply_deployment_shape "$deployment" "$index" "$requests_cpu" \ "$requests_memory" "$limits_cpu" "$limits_memory" "$node_spec" return "$?" ;; *) MENU_READ_ONLY=true; return 0 ;; esac done } # Appends one row to the caller's `labels` array -- bash scopes locals # dynamically, so this writes the flow's own list, not a global. Shows the # pending value, and what it was whenever an edit has changed it. override_field_row() { local name="$1" value="$2" live="$3" row printf -v row '%-16s %s' "$name" "${value:-—}" if [ "$value" != "$live" ]; then row="$row ${UI_YELLOW}← was ${live:-—}${UI_RESET}" fi labels+=("$row") } deployment_shape_changed() { [ "$1" != "$2" ] || [ "$3" != "$4" ] || [ "$5" != "$6" ] || [ "$7" != "$8" ] || [ "$9" != "${10}" ] } # Each editor keeps the current value when the prompt is left with Escape, so # cancelling one field does not discard the others. edit_cpu_field() { local name="$1" current="$2" answer while true; do answer=$(ask_or_back "$name" "$current") || { printf '%s' "$current"; return 0; } if valid_cpu_quantity "$answer"; then printf '%s' "$answer" return 0 fi ui_warn "⚠️ Not a CPU quantity. Use 100m, 250m, 0.5 or 2." done } edit_memory_field() { local name="$1" current="$2" answer while true; do answer=$(ask_or_back "$name" "$current") || { printf '%s' "$current"; return 0; } if valid_memory_quantity "$answer"; then printf '%s' "$answer" return 0 fi ui_warn "⚠️ Not a memory quantity. Use 128Mi, 512Mi or 1Gi." done } edit_placement_field() { local current="$1" answer ui_detail " Type - for any node." while true; do answer=$(ask_or_back "Node selector (key=value,key=value)" "$current") || { printf '%s' "$current"; return 0; } # An empty answer keeps the current selector, so a dash is the only way to # say "no placement constraint". [ "$answer" = "-" ] && answer="" if valid_node_selector "$answer"; then printf '%s' "$answer" return 0 fi ui_warn "⚠️ Not a label selector. Use key=value pairs, comma separated, or - for any node." done } apply_deployment_shape() { local deployment="$1" index="$2" requests_cpu="$3" requests_memory="$4" local limits_cpu="$5" limits_memory="$6" node_spec="$7" details patch had_license # A Deployment that carries no resources at all leaves these empty, and an # empty quantity is not something to send: the API server rejects it, and # cpu_to_milli reads it as 0, so the request-vs-limit check below would not # have caught it either. if [ -z "$requests_cpu" ] || [ -z "$limits_cpu" ] || [ -z "$requests_memory" ] || [ -z "$limits_memory" ]; then ui_error "❌ Every request and limit needs a value before this can be applied." ui_detail " Set the empty ones (shown as —) and try again." return 1 fi # The API server rejects a request above its limit; say so before the rollout # rather than after it. if [ "$(cpu_to_milli "$requests_cpu")" -gt "$(cpu_to_milli "$limits_cpu")" ]; then ui_error "❌ The CPU request ($requests_cpu) is above the CPU limit ($limits_cpu)." return 1 fi if awk -v r="$(memory_to_bytes "$requests_memory")" \ -v l="$(memory_to_bytes "$limits_memory")" \ 'BEGIN { exit (r > l) ? 0 : 1 }'; then ui_error "❌ The memory request ($requests_memory) is above the memory limit ($limits_memory)." return 1 fi had_license=$(deployment_override_get license || true) DEPLOYMENT_OVERRIDES="requests.cpu=$requests_cpu;requests.memory=$requests_memory" DEPLOYMENT_OVERRIDES="$DEPLOYMENT_OVERRIDES;limits.cpu=$limits_cpu;limits.memory=$limits_memory" [ -n "$node_spec" ] && DEPLOYMENT_OVERRIDES="$DEPLOYMENT_OVERRIDES;nodeSelector=$node_spec" # The license marker shares this record; rewriting the sizing must not drop it. [ "$had_license" = 1 ] && DEPLOYMENT_OVERRIDES="$DEPLOYMENT_OVERRIDES;license=1" details="This replaces the kwatch container's resources with requests" details="$details $requests_cpu/$requests_memory and limits" details="$details $limits_cpu/$limits_memory" if [ -n "$node_spec" ]; then details="$details, and restricts the Pod to nodes matching $node_spec" else details="$details, and removes any node selector" fi details="$details. The Pod is replaced. The values are recorded on the" details="$details Deployment and in a ConfigMap that outlives it, so an" details="$details upgrade -- or a reinstall that keeps configuration --" details="$details keeps them instead of reverting to the release defaults." confirm_change "🧮 The manager will update the kwatch Deployment." \ "$details" || { ui_info "↩️ Nothing was changed." MENU_READ_ONLY=true return 0 } check_access patch deployments namespace patch=$(deployment_overrides_patch "$index" "$requests_cpu" "$requests_memory" \ "$limits_cpu" "$limits_memory" "$node_spec") with_loading "Applying resources and placement" kubectl -n "$NAMESPACE" \ patch deployment "$deployment" --type=json -p "$patch" >/dev/null || { ui_error "❌ Could not apply the new resources." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" return 1 } with_loading "Recording resources for future upgrades" \ deployment_overrides_write "$deployment" "$DEPLOYMENT_OVERRIDES" || { ui_warn "⚠️ The Pod was resized, but the values could not be recorded;" ui_warn " the next upgrade would revert them." return 1 } if ! with_loading "Waiting for the resized kwatch Pod" \ wait_for_kwatch_rollout "$deployment"; then ui_error "❌ The resized Pod did not become ready." \ "$(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" show_unready_pods "$(workload_selector)" || true return 1 fi ui_success "✅ Resources applied and recorded; upgrades will keep them." ui_pause return 0 } # The container list is patched by index, so find the kwatch container rather # than assuming it is the first one. kwatch_container_index() { local deployment="$1" names index=0 name names=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o 'jsonpath={range .spec.template.spec.containers[*]}{.name}{"\n"}{end}' \ 2>/dev/null || true) while IFS= read -r name; do [ -n "$name" ] || continue if [ "$name" = "$RELEASE" ] || [ "$name" = kwatch ]; then printf '%s' "$index" return 0 fi index=$((index + 1)) done <<< "$names" return 1 } live_resource_value() { local deployment="$1" index="$2" section="$3" field="$4" value value=$(kubectl -n "$NAMESPACE" get deployment "$deployment" \ -o "jsonpath={.spec.template.spec.containers[$index].resources.$section.$field}" \ 2>/dev/null || true) printf '%s' "$value" } # jsonpath cannot iterate a map's keys, and there is no jq to lean on, so use # go-template -- which kubectl has built in -- to render the selector back into # the same key=value,key=value form the prompt accepts. live_node_selector() { local deployment="$1" rendered rendered=$(kubectl -n "$NAMESPACE" get deployment "$deployment" -o go-template \ --template='{{range $k, $v := .spec.template.spec.nodeSelector}}{{$k}}={{$v}},{{end}}' \ 2>/dev/null || true) printf '%s' "${rendered%,}" } # JSON Patch rather than a merge patch: `add` on an object member replaces it # outright, so a node selector that lost a key does not keep the stale one, and # the container list is addressed by index instead of being overwritten whole. deployment_overrides_patch() { local index="$1" rcpu="$2" rmem="$3" lcpu="$4" lmem="$5" node_spec="$6" local pairs="" pair printf '[' printf '{"op":"add","path":"/spec/template/spec/containers/%s/resources",' "$index" printf '"value":{"requests":{"cpu":"%s","memory":"%s"},' "$rcpu" "$rmem" printf '"limits":{"cpu":"%s","memory":"%s"}}}' "$lcpu" "$lmem" if [ -n "$node_spec" ]; then local IFS=',' for pair in $node_spec; do [ -n "$pair" ] || continue if [ -n "$pairs" ]; then pairs="$pairs," fi pairs="$pairs\"${pair%%=*}\":\"${pair#*=}\"" done printf ',{"op":"add","path":"/spec/template/spec/nodeSelector","value":{%s}}' \ "$pairs" else # `remove` on a missing path fails the whole patch, so replace with an empty # object, which Kubernetes drops. printf ',{"op":"add","path":"/spec/template/spec/nodeSelector","value":{}}' fi printf ']' } interactive_session() { while true; do MENU_EXIT=false MENU_READ_ONLY=false case "$INSTALL_STATE" in absent) show_absent_menu ;; legacy) show_legacy_menu ;; supported) show_supported_menu ;; broken) show_broken_menu ;; *) die "could not assess the kwatch installation" ;; esac [ "$MENU_EXIT" = true ] && return 0 [ -t 0 ] || return 0 # Viewing the status, the logs or the capabilities cannot have changed the # workload, so re-reading it afterwards is a wait for nothing -- and a # visible one now that the step announces itself. Only an action that could # have changed something pays for the re-assessment. if [ "$MENU_READ_ONLY" = true ]; then continue fi invalidate_deployment_name ui_busy "Re-reading the installation" assess_installation ui_busy_done done } main() { local action="${1:-}" case "$action" in --help|-h) usage; exit 0 ;; --version|-v) echo "🏷️ kwatch manager catalog $CATALOG_VERSION" exit 0 ;; "") ;; share-dashboard) [ -n "${KWATCH_DASHBOARD_EXPOSE:-}" ] || die "share-dashboard needs KWATCH_DASHBOARD_EXPOSE (see --help)" ;; *) die "kwatch.sh is interactive; run it without an action argument" ;; esac require_tools SESSION_CACHE_DIR=$(mktemp -d 2>/dev/null || true) ui_title_push ui_title_step "" ui_banner select_context with_loading "Checking Kubernetes cluster" kubectl cluster-info >/dev/null || die "cannot reach the Kubernetes cluster: $(compact_reason "${LAST_COMMAND_ERROR:-no diagnostic was returned}")" check_cluster_version ui_busy "Checking the kwatch installation" assess_installation ui_busy_done if [ -n "${KWATCH_DASHBOARD_EXPOSE:-}" ]; then [ "$INSTALL_STATE" = supported ] || die "kwatch must be installed and current before the dashboard can be shared; run the installer without KWATCH_DASHBOARD_EXPOSE first" dash_share_headless || exit 1 return 0 fi interactive_session } if [[ -z "${BASH_SOURCE[0]:-}" || "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi