install-k8s.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
set -euo pipefail
# ─── rsync.ai — One-command Kubernetes installer ─────────────────────────────
#
# Usage (any cluster your current kubectl context points at):
# curl -sSL https://raw.githubusercontent.com/rsync-ai/rsync.ai/main/install-k8s.sh | bash
#
# It needs nothing else from you. Every secret and setting you do not supply is
# generated or defaulted, and the result is a working install:
#
# 1. writes ~/rsync-ai-k8s/.env -- generated secrets (chmod 600) plus every
# optional setting, commented out. Edit it and re-run to change anything.
# 2. renders ~/rsync-ai-k8s/values.generated.yaml from that .env
# 3. runs helm upgrade --install (helm is downloaded and checksum-verified
# if you do not have it) and waits for the release to be ready
# 4. prints how to open the UI; on a laptop it also opens the port-forwards
#
# Re-running is the upgrade path and is safe: secrets already in the .env -- or,
# failing that, already in the cluster -- are reused, never regenerated. That
# matters for ENCRYPTION_KEY (rotating it makes every saved connection
# undecryptable) and POSTGRES_PASSWORD (the database keeps the one it was
# initialised with).
#
# Every setting goes in the .env, or on the `bash` side of the pipe:
# curl -sSL .../install-k8s.sh | RSYNC_NAMESPACE=data bash
# Written before `curl` it is exported to `curl`, which never reads it.
#
# Flags (after `bash -s --` when piped):
# --render-only write the files and render the chart; touch no cluster
# --port-forward force the port-forwards at the end (default: only on a tty)
# --no-port-forward never open them
# -h, --help
#
# Copyright (c) 2025 Infini Data Solution (Rahul Kumar Vishnoi)
# Licensed under the rsync.ai Source-Available License — https://rsync.ai/license
# ─────────────────────────────────────────────────────────────────────────────
# The chart. Pinned to the release this script ships with, for the same reason
# install.sh pins RSYNC_REF: the chart's images default to its own appVersion, so
# one number names both halves. RSYNC_CHART may also be a local chart directory
# (a checkout, or `helm pull --untar`), in which case no version is passed.
RSYNC_CHART="${RSYNC_CHART:-oci://ghcr.io/rsync-ai/charts/rsync-ai}"
RSYNC_CHART_VERSION="${RSYNC_CHART_VERSION:-0.1.8}"
# Used only when there is no helm on PATH. OCI charts are GA from 3.8.
HELM_FALLBACK_VERSION="v3.16.4"
HELM_MIN_MINOR=8
INSTALL_DIR="${RSYNC_INSTALL_DIR:-${HOME:-$PWD}/rsync-ai-k8s}"
ENV_FILE=".env"
VALUES_FILE="values.generated.yaml"
# What an install asks the scheduler for, in REQUESTS (not usage). Measured by
# rendering the chart with an empty fleet and no demo, then adding one connector
# / the demo at a time; test_install_k8s_defaults_work.py re-measures on every
# run, so a chart change that moves these fails CI instead of the next user.
# BASE includes the post-install hook pods (kafka-init, minio-mb, blob-staging-mb): they run after
# every Deployment is up, and on a full node they are what stays Pending.
BASE_MEM_MIB=8608
BASE_CPU_M=3510
CONNECTOR_MEM_MIB=96
CONNECTOR_CPU_M=50
DEMO_MEM_MIB=128
DEMO_CPU_M=50
# The in-cluster model: its server and its pull hook.
OLLAMA_MEM_MIB=6272
OLLAMA_CPU_M=1050
# What --wait cannot survive is a pod that can never be scheduled, so the fleet
# and demo the user did NOT choose are trimmed to this when the cluster cannot
# hold the defaults. Enough for the documented PG -> Mongo CDC path.
LEAN_CONNECTORS="postgresql,mongodb"
# The rung below lean. Trimming connectors cannot rescue a 4-vCPU node: BASE
# alone asks 3510m, and a GKE e2-standard-4 has ~3920m allocatable with several
# hundred more taken by kube-system DaemonSets, so the lean set's 3610m still
# does not fit and its pods sit Pending. What does fit is one replica of each
# front-end Deployment (both default to 2) and a smaller CPU request for the
# orchestrator. The chart sets no CPU limit anywhere, so a smaller CPU request
# costs no achievable CPU -- only the guaranteed floor under contention. Memory
# limits are untouched: the orchestrator's 2Gi is what tableConcurrency reads
# itself against. Savings, re-measured every run by
# test_install_k8s_defaults_work.py: api-gateway 2->1 (200m/384Mi), frontend
# 2->1 (100m/256Mi), orchestrator cpu 500m->250m.
TIGHT_ORCHESTRATOR_CPU="250m"
TIGHT_MEM_MIB=640
TIGHT_CPU_M=550
# Set by fit_to_cluster when even the lean set does not fit; read by write_values.
TIGHT=false
# How long to wait for the cluster's OWN pods before measuring what is free.
# Nothing is installed during this wait; see wait_for_system_pods.
SETTLE_TIMEOUT_S="${RSYNC_SETTLE_TIMEOUT_S:-90}"
# Source/destination connectors a pod can be started for, as id:version. The
# version is part of the Service name the orchestrator resolves
# (rsync-ai.connectorServiceName), so it must equal the connector's
# latest.json current_version -- not a number that merely looks right. Kubernetes
# has no just-in-time deploy (that needs a Docker socket), so a connector a
# pipeline names but the fleet does not list is simply absent. Each image is
# mcp-<id>. sample-data is absent on purpose: the chart runs it itself
# (connectors.sampleData). A guard (test_install_k8s_defaults_work.py) compares
# this list with the tree, so a new connector cannot ship without being
# installable here.
KNOWN_CONNECTORS="aws-s3:v1.0.0 azure-blob:v1.0.0 bigquery:v1.0.0 clickhouse:v1.0.0 databricks:v1.0.0 gcs:v1.0.0 github-rest:v1.0.0 google-sheets:v1.0.0 mongodb:v1.0.0 mysql:v1.0.0 notion-rest:v1.0.0 oracle:v1.0.0 postgresql:v1.0.0 redshift:v1.0.0 shopify-admin-graphql:v1.0.0 snowflake:v1.0.0 sqlserver:v1.0.0 stripe:v1.0.0 widgets-graphql:v1.0.0"
# Enough for the two documented paths (PG -> Mongo CDC, and object storage) plus
# MySQL, which is the third most asked-for source. ~0.5 GiB of requests.
DEFAULT_CONNECTORS="postgresql,mysql,mongodb,aws-s3,gcs"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${GREEN}✓${NC} $*"; }
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
error() { echo -e "${RED}✗${NC} $*" >&2; }
section() { echo -e "\n${BOLD}${BLUE}▶ $*${NC}"; }
die() { error "$*"; exit 1; }
RENDER_ONLY="${RSYNC_RENDER_ONLY:-false}"
PORT_FORWARD="${RSYNC_PORT_FORWARD:-auto}"
# ─── .env access ─────────────────────────────────────────────────────────────
# The .env is read and edited, never sourced: it is a document an operator
# edits by hand, and sourcing it would execute whatever they typed into it.
env_value() {
local key="$1" file="${INSTALL_DIR}/${ENV_FILE}" line=""
[[ -f "$file" ]] || return 0
# `export KEY=value` is accepted too, and read as KEY.
line=$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "$file" | tail -1 || true)
line="${line#*=}"
# One matched pair of surrounding quotes, the way compose's env-file parser does.
line="${line%\"}"; line="${line#\"}"
line="${line%\'}"; line="${line#\'}"
printf '%s' "$line"
}
# Replaces the key in place rather than appending a second line that would win by
# being later. awk -v, not sed: a value may hold `&` or `/`, both live in a sed
# replacement and inert in an awk variable.
set_env_value() {
local key="$1" value="$2" file="${INSTALL_DIR}/${ENV_FILE}" tmp
tmp="$(mktemp "${file}.XXXXXX")"
# Before a byte is written: this file carries secrets and the temp copy is about
# to BECOME it. Same directory, so the mv is atomic.
chmod 600 "$tmp"
awk -v k="$key" -v v="$value" '
$0 ~ "^[[:space:]]*(export[[:space:]]+)?" k "=" {
if (!seen) { p = ""; if (match($0, /^[[:space:]]*export[[:space:]]+/)) p = "export "; print p k "=" v; seen = 1 }
next
}
{ print }
END { if (!seen) print k "=" v }
' "$file" > "$tmp"
mv "$tmp" "$file"
}
# A setting: the environment wins (so `... | RSYNC_X=y bash` works), then the
# .env, then the default. Empty counts as unset at every level.
cfg() {
local key="$1" default="${2:-}" v=""
v="${!key:-}"
[[ -n "$v" ]] || v="$(env_value "$key")"
[[ -n "$v" ]] || v="$default"
printf '%s' "$v"
}
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
# 32 alphanumerics: ENCRYPTION_KEY is truncated to its first 32 bytes by the
# services that read it, and the chart refuses any character that a URL or a
# libpq keyword string would mangle (rsync-ai.assertDeliverableSecret).
# No `head` on the OUTPUT side of a pipe: it closes the pipe early, the producer
# takes SIGPIPE, and pipefail fails the assignment.
generate_secret() {
local raw=""
if command -v openssl >/dev/null 2>&1; then
raw=$(openssl rand -base64 96 | LC_ALL=C tr -dc 'a-zA-Z0-9')
else
raw=$(head -c 4096 /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9')
fi
if (( ${#raw} < 32 )); then
die "Could not generate a 32-character secret (got ${#raw}). Install openssl and re-run."
fi
printf '%s' "${raw:0:32}"
}
# YAML double-quoted scalar. Backslash first, or the escapes added for the quote
# would themselves be doubled.
yq() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
printf '"%s"' "$s"
}
# ─── Prerequisites ───────────────────────────────────────────────────────────
helm_is_usable() {
local bin="$1" v
v="$("$bin" version --template '{{.Version}}' 2>/dev/null)" || return 1
[[ "$v" =~ ^v([0-9]+)\.([0-9]+)\. ]] || return 1
# 3.8+ (OCI charts went GA there) or any later major. Helm 4 is what a current
# `brew install helm` gives you; refusing it would download a second helm
# next to a perfectly good one.
(( BASH_REMATCH[1] > 3 || (BASH_REMATCH[1] == 3 && BASH_REMATCH[2] >= HELM_MIN_MINOR) ))
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
else die "Need sha256sum or shasum to verify the helm download."; fi
}
# Finds helm, or fetches it. Never touches /usr/local: a curl-pipe installer that
# needs sudo is one nobody should pipe. The download is verified against the
# checksum file published next to it before a byte of it is executed.
ensure_helm() {
if command -v helm >/dev/null 2>&1 && helm_is_usable "$(command -v helm)"; then
HELM="$(command -v helm)"
return 0
fi
local os arch bin_dir tarball base tmp want got
bin_dir="${INSTALL_DIR}/bin"
if [[ -x "${bin_dir}/helm" ]] && helm_is_usable "${bin_dir}/helm"; then
HELM="${bin_dir}/helm"
return 0
fi
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) die "No usable helm (>= 3.${HELM_MIN_MINOR}) found, and no download exists for $(uname -s). Install helm: https://helm.sh/docs/intro/install/" ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
arm64|aarch64) arch=arm64 ;;
*) die "No usable helm (>= 3.${HELM_MIN_MINOR}) found, and no download exists for $(uname -m). Install helm: https://helm.sh/docs/intro/install/" ;;
esac
command -v curl >/dev/null 2>&1 || die "Need curl to download helm (or install helm yourself: https://helm.sh/docs/intro/install/)."
warn "helm >= 3.${HELM_MIN_MINOR} not found; downloading ${HELM_FALLBACK_VERSION} into ${bin_dir}"
tarball="helm-${HELM_FALLBACK_VERSION}-${os}-${arch}.tar.gz"
base="https://get.helm.sh/${tarball}"
tmp="$(mktemp -d)"
curl -fsSL "$base" -o "${tmp}/${tarball}" || die "Could not download ${base}"
curl -fsSL "${base}.sha256sum" -o "${tmp}/sum" || die "Could not download ${base}.sha256sum"
want="$(awk '{print $1}' "${tmp}/sum")"
got="$(sha256_of "${tmp}/${tarball}")"
[[ -n "$want" && "$want" == "$got" ]] || die "Checksum mismatch for ${tarball} (want ${want:-<empty>}, got ${got}). Refusing to run it."
tar -xzf "${tmp}/${tarball}" -C "$tmp"
mkdir -p "$bin_dir"
mv "${tmp}/${os}-${arch}/helm" "${bin_dir}/helm"
chmod 755 "${bin_dir}/helm"
rm -rf "$tmp"
HELM="${bin_dir}/helm"
info "helm ${HELM_FALLBACK_VERSION} installed (checksum verified)"
}
ensure_kubectl() {
command -v kubectl >/dev/null 2>&1 || die "kubectl not found. Install it (https://kubernetes.io/docs/tasks/tools/) and point it at your cluster, then re-run."
KUBECTL=(kubectl)
local ctx
ctx="$(cfg RSYNC_KUBE_CONTEXT)"
if [[ -n "$ctx" ]]; then KUBECTL=(kubectl --context "$ctx"); fi
}
kc() { "${KUBECTL[@]}" "$@"; }
helm_ctx_args() {
local ctx
ctx="$(cfg RSYNC_KUBE_CONTEXT)"
if [[ -n "$ctx" ]]; then printf '%s\n' "--kube-context" "$ctx"; fi
}
# ─── Settings ────────────────────────────────────────────────────────────────
# id -> version, from KNOWN_CONNECTORS. bash 3.2 has no associative arrays.
connector_version() {
local e
for e in $KNOWN_CONNECTORS; do
if [[ "${e%%:*}" == "$1" ]]; then printf '%s' "${e#*:}"; return 0; fi
done
return 1
}
known_ids() {
local e out=""
for e in $KNOWN_CONNECTORS; do out="${out}${out:+ }${e%%:*}"; done
printf '%s' "$out"
}
# The connector fleet: validated against the known list, because an id with no
# published image is an ImagePullBackOff that only surfaces after the whole
# --wait timeout. The demo needs a postgresql connector; add it rather than
# fail an install whose only crime was listing fewer connectors.
build_fleet() {
FLEET=""
local c seen=" "
for c in $(printf '%s' "$CONNECTORS" | tr ',' ' '); do
c="$(printf '%s' "$c" | tr -d '[:space:]')"
[[ -n "$c" ]] || continue
connector_version "$c" >/dev/null || die "RSYNC_CONNECTORS lists '${c}', which is not a published connector image. Known: $(known_ids). (Anything else: RSYNC_EXTRA_VALUES.)"
[[ "$seen" == *" $c "* ]] && continue
seen="${seen}${c} "
FLEET="${FLEET}${FLEET:+ }${c}"
done
if [[ "$DEMO" == "true" && " $FLEET " != *" postgresql "* ]]; then
FLEET="postgresql${FLEET:+ }${FLEET}"
warn "RSYNC_DEMO=true needs the postgresql connector; added it to the fleet."
fi
}
load_settings() {
NAMESPACE_SET="$(cfg RSYNC_NAMESPACE)"
RELEASE_SET="$(cfg RSYNC_RELEASE)"
NAMESPACE="${NAMESPACE_SET:-rsync-ai}"
RELEASE="${RELEASE_SET:-rsync-ai}"
STORAGE_CLASS="$(cfg RSYNC_STORAGE_CLASS)"
IMAGE_REGISTRY="$(cfg RSYNC_IMAGE_REGISTRY)"
IMAGE_TAG="$(cfg RSYNC_IMAGE_TAG)"
PULL_SECRET="$(cfg RSYNC_IMAGE_PULL_SECRET)"
APP_HOST="$(cfg RSYNC_APP_HOST)"
API_HOST="$(cfg RSYNC_API_HOST)"
INGRESS_CLASS="$(cfg RSYNC_INGRESS_CLASS)"
TLS_SECRET="$(cfg RSYNC_TLS_SECRET)"
CONNECTORS="$(cfg RSYNC_CONNECTORS "$DEFAULT_CONNECTORS")"
DEMO="$(lower "$(cfg RSYNC_DEMO true)")"
LLM_PROVIDER="$(lower "$(cfg RSYNC_LLM_PROVIDER openai)")"
EXTRA_VALUES="$(cfg RSYNC_EXTRA_VALUES)"
WAIT_TIMEOUT="$(cfg RSYNC_WAIT_TIMEOUT 15m)"
OPENAI_KEY="$(cfg OPENAI_API_KEY)"
# Kubernetes names, before they reach a command: a typo here is otherwise an
# apiserver error three steps later that names neither this key nor the file.
[[ "$NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] || die "RSYNC_NAMESPACE='${NAMESPACE}' is not a valid namespace name (lowercase letters, digits, '-')."
[[ "$RELEASE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ && ${#RELEASE} -le 40 ]] || die "RSYNC_RELEASE='${RELEASE}' is not a valid release name (lowercase letters, digits, '-'; at most 40 characters)."
[[ "$WAIT_TIMEOUT" =~ ^[0-9]+[smh]$ ]] || die "RSYNC_WAIT_TIMEOUT='${WAIT_TIMEOUT}' must look like 900s, 15m or 1h."
case "$DEMO" in true|false) ;; *) die "RSYNC_DEMO must be true or false (got '${DEMO}')." ;; esac
case "$LLM_PROVIDER" in openai|ollama) ;; *) die "RSYNC_LLM_PROVIDER must be openai or ollama (got '${LLM_PROVIDER}'). Any other provider: put it in RSYNC_EXTRA_VALUES." ;; esac
local h
for h in "$APP_HOST" "$API_HOST"; do
[[ -z "$h" || "$h" =~ ^[A-Za-z0-9]([-A-Za-z0-9.]*[A-Za-z0-9])?$ ]] || die "'${h}' is not a valid hostname (RSYNC_APP_HOST / RSYNC_API_HOST)."
done
if [[ -n "$APP_HOST" || -n "$API_HOST" ]]; then
[[ -n "$APP_HOST" && -n "$API_HOST" ]] || die "Set BOTH RSYNC_APP_HOST and RSYNC_API_HOST, or neither. The UI calls the API from the browser, so the API needs a hostname of its own."
fi
if [[ -n "$EXTRA_VALUES" && ! -f "$EXTRA_VALUES" ]]; then
die "RSYNC_EXTRA_VALUES='${EXTRA_VALUES}' is not a file."
fi
# Whether the user chose the fleet / demo, or is on the defaults: only the
# defaults may be trimmed to fit a small cluster (see fit_to_cluster).
CONNECTORS_EXPLICIT=false; [[ -z "$(cfg RSYNC_CONNECTORS)" ]] || CONNECTORS_EXPLICIT=true
DEMO_EXPLICIT=false; [[ -z "$(cfg RSYNC_DEMO)" ]] || DEMO_EXPLICIT=true
build_fleet
# Browser-visible URLs. The UI calls the API FROM THE BROWSER, so these must be
# addresses the browser can reach -- not Service names. With no ingress the
# answer is the port-forwards this script opens.
local scheme=http
[[ -n "$TLS_SECRET" ]] && scheme=https
if [[ -n "$APP_HOST" ]]; then
APP_URL="$(cfg RSYNC_APP_URL "${scheme}://${APP_HOST}")"
API_URL="$(cfg RSYNC_API_URL "${scheme}://${API_HOST}")"
else
APP_URL="$(cfg RSYNC_APP_URL http://localhost:3000)"
API_URL="$(cfg RSYNC_API_URL http://localhost:8080)"
fi
}
# ─── Secrets ─────────────────────────────────────────────────────────────────
# .env key -> Secret key. Same names compose's .env uses, except that the chart's
# Secret calls the warehouse password DEMO_WAREHOUSE_PASSWORD.
SECRET_KEYS="JWT_SECRET ENCRYPTION_KEY INTERNAL_SERVICE_SECRET POSTGRES_PASSWORD REDIS_PASSWORD MINIO_ACCESS_KEY MINIO_SECRET_KEY BLOB_STAGING_ACCESS_KEY BLOB_STAGING_SECRET_KEY RSYNC_DEMO_WAREHOUSE_PASSWORD"
cluster_secret_key() {
case "$1" in
RSYNC_DEMO_WAREHOUSE_PASSWORD) echo DEMO_WAREHOUSE_PASSWORD ;;
*) echo "$1" ;;
esac
}
# The Secret an earlier install of this release left behind. It is kept on
# uninstall on purpose (helm.sh/resource-policy: keep), and it is the ground
# truth for the two secrets that cannot be re-rolled: the database was
# initialised with POSTGRES_PASSWORD, and every saved connection was encrypted
# with ENCRYPTION_KEY. A lost .env must not cost either.
import_from_cluster() {
local secret="${RELEASE}-secrets" key ckey val n=0
kc -n "$NAMESPACE" get secret "$secret" >/dev/null 2>&1 || return 0
for key in $SECRET_KEYS; do
[[ -z "$(env_value "$key")" ]] || continue
ckey="$(cluster_secret_key "$key")"
val="$(kc -n "$NAMESPACE" get secret "$secret" -o "go-template={{index .data \"${ckey}\"}}" 2>/dev/null | base64 -d 2>/dev/null || true)"
if [[ -n "$val" && "$val" != "<no value>" ]]; then
set_env_value "$key" "$val"
n=$((n + 1))
fi
done
if (( n > 0 )); then
info "Reused ${n} secret(s) from the existing ${NAMESPACE}/${secret} -- nothing was regenerated"
fi
}
write_env_skeleton() {
local file="${INSTALL_DIR}/${ENV_FILE}"
( umask 077; : > "$file" )
cat >> "$file" <<'EOF'
# rsync.ai Kubernetes install settings.
#
# Everything here is optional. Save this file and re-run install-k8s.sh: the
# secrets below are generated once and reused, and every commented line is a
# setting you can turn on. BACK UP THIS FILE -- ENCRYPTION_KEY encrypts every
# connection credential you save, and there is no way to recover them without it.
# ── Secrets (generated; edit only to bring your own) ─────────────────────────
# ── Access ───────────────────────────────────────────────────────────────────
# Default: no ingress. The UI is at http://localhost:3000 through a kubectl
# port-forward that this script opens for you.
# To publish it instead, give it two hostnames (the UI calls the API from the
# browser, so the API needs its own):
#RSYNC_APP_HOST=app.example.com
#RSYNC_API_HOST=api.example.com
#RSYNC_INGRESS_CLASS=nginx
#RSYNC_TLS_SECRET=rsync-ai-tls # an existing kubernetes.io/tls Secret; makes the URLs https
# ── LLM (chat is how you create pipelines in the UI) ────────────────────────
#OPENAI_API_KEY=sk-...
# Or run a model inside the cluster instead (needs ~6 GiB and 1 CPU more, and
# downloads ~4.7 GB on first start):
#RSYNC_LLM_PROVIDER=ollama
# ── Cluster ──────────────────────────────────────────────────────────────────
#RSYNC_NAMESPACE=rsync-ai
#RSYNC_RELEASE=rsync-ai
#RSYNC_KUBE_CONTEXT= # default: your current kubectl context
#RSYNC_STORAGE_CLASS= # default: the cluster's default StorageClass
# ── What to install ──────────────────────────────────────────────────────────
# Source/destination connectors to run as pods (Kubernetes cannot deploy them
# on demand). Pick from: aws-s3 azure-blob bigquery clickhouse databricks gcs
# github-rest google-sheets mongodb mysql notion-rest oracle postgresql redshift
# shopify-admin-graphql snowflake sqlserver stripe widgets-graphql
#RSYNC_CONNECTORS=postgresql,mysql,mongodb,aws-s3,gcs
#RSYNC_DEMO=true # the sample-data try-it path
# ── Images ───────────────────────────────────────────────────────────────────
#RSYNC_IMAGE_REGISTRY=ghcr.io/rsync-ai # a mirror, or your own registry
#RSYNC_IMAGE_TAG= # default: the chart's own version
#RSYNC_IMAGE_PULL_SECRET= # an existing docker-registry Secret
# ── Anything else ────────────────────────────────────────────────────────────
# A Helm values file layered on top of everything above -- external Postgres or
# Kafka, Workload Identity, resource limits. See deploy/helm/rsync-ai/values*.yaml.
#RSYNC_EXTRA_VALUES=/path/to/my-values.yaml
EOF
}
ensure_secrets() {
local file="${INSTALL_DIR}/${ENV_FILE}" key generated=0
if [[ ! -f "$file" ]]; then
write_env_skeleton
info "Created ${file}"
NEW_ENV=true
else
chmod 600 "$file"
NEW_ENV=false
fi
if [[ "$RENDER_ONLY" != "true" ]]; then import_from_cluster; fi
for key in $SECRET_KEYS; do
if [[ -z "$(env_value "$key")" ]]; then
set_env_value "$key" "$(generate_secret)"
generated=$((generated + 1))
fi
done
if (( generated > 0 )); then
info "Generated ${generated} secret(s) into ${file} (chmod 600)"
fi
# Persist only what they typed: OPENAI_API_KEY belongs to the user, so it is
# read from wherever it is and never copied into the file.
}
# ─── Cluster capacity ────────────────────────────────────────────────────────
# Kubernetes quantities -> MiB / millicores. Shared by the node and the pod sums.
QTY_AWK='
function mib(q, u, n) {
if (q == "") return 0
n = q + 0; u = ""
if (match(q, /[A-Za-z]+$/)) u = substr(q, RSTART)
if (u == "Ki") return n / 1024
if (u == "Mi") return n
if (u == "Gi") return n * 1024
if (u == "Ti") return n * 1048576
if (u == "k" || u == "K") return n * 1000 / 1048576
if (u == "M") return n * 1000000 / 1048576
if (u == "G") return n * 1000000000 / 1048576
if (u == "T") return n * 1000000000000 / 1048576
return n / 1048576
}
function milli(q) {
if (q == "") return 0
if (q ~ /m$/) { sub(/m$/, "", q); return q + 0 }
return q * 1000
}'
gib() { awk -v m="$1" 'BEGIN { printf "%.1f", m / 1024 }'; }
# What the install would ask the scheduler for: the chart's fixed set, plus one
# slice per connector pod, the demo warehouse, and (opt-in) the in-cluster model.
estimate_need() {
local n=0 f
for f in $FLEET; do n=$((n + 1)); done
NEED_MEM_MIB=$((BASE_MEM_MIB + n * CONNECTOR_MEM_MIB))
NEED_CPU_M=$((BASE_CPU_M + n * CONNECTOR_CPU_M))
if [[ "$DEMO" == "true" ]]; then
NEED_MEM_MIB=$((NEED_MEM_MIB + DEMO_MEM_MIB)); NEED_CPU_M=$((NEED_CPU_M + DEMO_CPU_M))
fi
if [[ "$LLM_PROVIDER" == "ollama" ]]; then
NEED_MEM_MIB=$((NEED_MEM_MIB + OLLAMA_MEM_MIB)); NEED_CPU_M=$((NEED_CPU_M + OLLAMA_CPU_M))
fi
}
# What the scheduler can still hand out: the nodes' allocatable minus what is
# already requested by every other pod (kube-system alone is ~300 MiB, and it is
# exactly the gap that let an "8.5 GiB" install onto a node it did not fit).
# This release's own pods are excluded: an upgrade replaces them. Sets
# FREE_MEM_MIB / FREE_CPU_M; returns 1 when the nodes cannot be read.
measure_free() {
local nodes pods alloc used
nodes="$(kc get nodes -o 'jsonpath={range .items[*]}{.status.allocatable.memory}{" "}{.status.allocatable.cpu}{"\n"}{end}' 2>/dev/null || true)"
[[ -n "$nodes" ]] || return 1
alloc="$(printf '%s\n' "$nodes" | awk "${QTY_AWK}"' NF >= 2 { m += mib($1); c += milli($2) } END { printf "%d %d", m, c }')"
# One line per pod that still holds a claim on the cluster: phase, namespace,
# release label, node, then "memory,cpu;" for each container. Tab-separated: a
# request may be empty. A pod with no node yet is counted, not skipped -- it is
# owed room, and skipping it is what made a starting cluster read as emptier
# than it is (see wait_for_system_pods). The error this direction can make is
# trimming an install that would have fit; the other direction is an install
# that never becomes ready.
pods="$(kc get pods -A -o 'jsonpath={range .items[*]}{.status.phase}{"\t"}{.metadata.namespace}{"\t"}{.metadata.labels.app\.kubernetes\.io/instance}{"\t"}{.spec.nodeName}{"\t"}{range .spec.containers[*]}{.resources.requests.memory}{","}{.resources.requests.cpu}{";"}{end}{"\n"}{end}' 2>/dev/null || true)"
used="0 0"
if [[ -n "$pods" ]]; then
used="$(printf '%s\n' "$pods" | awk -F'\t' -v ns="$NAMESPACE" -v rel="$RELEASE" "${QTY_AWK}"'
$1 == "Succeeded" || $1 == "Failed" { next }
$2 == ns && $3 == rel { next }
{ n = split($5, cs, ";"); for (i = 1; i <= n; i++) { if (cs[i] == "") continue; split(cs[i], r, ","); m += mib(r[1]); c += milli(r[2]) } }
END { printf "%d %d", m, c }')"
fi
FREE_MEM_MIB=$(( ${alloc%% *} - ${used%% *} ))
FREE_CPU_M=$(( ${alloc##* } - ${used##* } ))
}
need_fits() { (( FREE_MEM_MIB >= NEED_MEM_MIB && FREE_CPU_M >= NEED_CPU_M )); }
# A cluster reports a node's allocatable the moment the node registers, but the
# system workloads that occupy it -- the CNI, kube-proxy, DNS, and on a managed
# cluster the logging and metrics DaemonSets -- are created and scheduled over
# the following half-minute. Measured inside that window the node looks emptier
# than it is. On a kind node pinned to a GKE e2-standard-4's headroom, free CPU
# read 3700m five seconds after the cluster came up and 3600m at fifteen before
# settling at its true 3400m at twenty-five -- and 3600m is enough to make the
# lean rung's 3510m look like it fits, which is precisely the install that then
# sits Pending for fifteen minutes and fails. Waiting for the reading to stop
# moving would not catch it (3600m held for ten seconds); what has to be waited
# for is the workloads. Returns how many system pods the cluster still owes
# itself; a query that cannot be answered returns 0, because this is a wait and
# not a gate.
system_pods_owed() {
local ds dep
ds="$(kc get daemonsets -A -o 'jsonpath={range .items[*]}{.status.desiredNumberScheduled}{" "}{.status.numberReady}{"\n"}{end}' 2>/dev/null || true)"
dep="$(kc get deployments -n kube-system -o 'jsonpath={range .items[*]}{.spec.replicas}{" "}{.status.readyReplicas}{"\n"}{end}' 2>/dev/null || true)"
printf '%s\n%s\n' "$ds" "$dep" | awk 'NF { d = $1 + 0; r = $2 + 0; if (d > r) owed += d - r } END { print owed + 0 }'
}
wait_for_system_pods() {
local waited=0 owed announced=false
while (( waited < SETTLE_TIMEOUT_S )); do
owed="$(system_pods_owed)"
if [[ "$owed" == "0" ]]; then
if [[ "$announced" == "true" ]]; then info "The cluster's system pods are up (waited ${waited}s)."; fi
return 0
fi
if [[ "$announced" == "false" ]]; then
info "Waiting for the cluster's own system pods to start before measuring free capacity..."
announced=true
fi
sleep 2
waited=$((waited + 2))
done
warn "The cluster's system pods are still starting after ${SETTLE_TIMEOUT_S}s; measuring capacity anyway."
}
# A pod that cannot be scheduled never becomes ready, and `helm --wait` only says
# so ten minutes later. So compare what the install asks for with what the
# cluster has left BEFORE installing, and when the defaults do not fit, install a
# lean set instead of a broken full one -- but only the parts the user did not
# choose. Nothing is written to the .env: on a bigger cluster the next run is
# the full default again.
fit_to_cluster() {
estimate_need
wait_for_system_pods
if ! measure_free; then
warn "Could not read the nodes' capacity; skipping the capacity check."
return 0
fi
local have="~$(gib "$FREE_MEM_MIB") GiB / $((FREE_CPU_M / 1000)) CPU free"
local want="~$(gib "$NEED_MEM_MIB") GiB / $(( (NEED_CPU_M + 999) / 1000 )) CPU"
if need_fits; then
info "Capacity: ${have} (this install requests ${want})"
return 0
fi
local was_connectors="$CONNECTORS" was_demo="$DEMO" was_fleet="$FLEET" was_need_mem="$NEED_MEM_MIB" was_need_cpu="$NEED_CPU_M"
local leaned=false
[[ "$CONNECTORS_EXPLICIT" == "true" ]] || CONNECTORS="$LEAN_CONNECTORS"
[[ "$DEMO_EXPLICIT" == "true" ]] || DEMO=false
if [[ "$CONNECTORS" != "$was_connectors" || "$DEMO" != "$was_demo" ]]; then
build_fleet
estimate_need
leaned=true
if need_fits; then
warn "This cluster has ${have}; the default install requests ${want}, so pods would stay Pending."
echo " Installing a lean set instead: connectors ${CONNECTORS}, demo ${DEMO} (requests ~$(gib "$NEED_MEM_MIB") GiB)."
echo " For the full default, add nodes and re-run this script, or choose your own set in ${INSTALL_DIR}/${ENV_FILE}"
echo " (RSYNC_CONNECTORS / RSYNC_DEMO)."
return 0
fi
fi
# Still short -- which is every 4-vCPU node, because the fleet is not what
# fills it. Collapse the two front-end Deployments to one replica each and
# lower the orchestrator's CPU request before giving up: a cluster that
# reaches this line would otherwise get Pending pods and a failed helm wait,
# so there is no install here to regress. Not written to the .env -- the next
# run on a bigger cluster is the full default again.
TIGHT=true
NEED_MEM_MIB=$((NEED_MEM_MIB - TIGHT_MEM_MIB)); NEED_CPU_M=$((NEED_CPU_M - TIGHT_CPU_M))
if need_fits; then
warn "This cluster has ${have}; the default install requests ${want}, so pods would stay Pending."
if [[ "$leaned" == "true" ]]; then
echo " Installing a lean, single-replica set instead: connectors ${CONNECTORS}, demo ${DEMO},"
else
echo " Installing a single-replica set instead:"
fi
echo " one api-gateway and one frontend pod, and a smaller CPU request for the orchestrator"
echo " (requests ~$(gib "$NEED_MEM_MIB") GiB / $(( (NEED_CPU_M + 999) / 1000 )) CPU). The chart sets no CPU limit, so this"
echo " costs no speed -- it gives up the spare replica that would carry traffic during a node failure."
echo " For the full default, add nodes (or a bigger machine type) and re-run this script."
return 0
fi
TIGHT=false
CONNECTORS="$was_connectors"; DEMO="$was_demo"; FLEET="$was_fleet"
NEED_MEM_MIB="$was_need_mem"; NEED_CPU_M="$was_need_cpu"
warn "This cluster has ${have}; this install requests ${want}."
echo " Pods that do not fit will sit Pending and helm will give up after its wait timeout."
echo " Add nodes (a cluster autoscaler will do this for you), or trim what is installed in ${INSTALL_DIR}/${ENV_FILE}:"
echo " RSYNC_CONNECTORS=${LEAN_CONNECTORS} RSYNC_DEMO=false"
}
# ─── Cluster preflight ───────────────────────────────────────────────────────
# Until v0.1.8 the default was release `rsync` in namespace `rsync`. Re-running
# with the new default would install a second copy beside that one, with fresh
# secrets and empty volumes, and leave the first running. So a name the user did
# not choose falls back to the old default when an install is already there --
# unless one exists under the new name too, which then wins.
installed_as() { # <namespace> <release>
"$HELM" status "$2" -n "$1" ${HELM_CTX[@]+"${HELM_CTX[@]}"} >/dev/null 2>&1 \
|| kc -n "$1" get secret "${2}-secrets" >/dev/null 2>&1
}
keep_earlier_default_name() {
[[ -z "$NAMESPACE_SET" || -z "$RELEASE_SET" ]] || return 0
local ns="${NAMESPACE_SET:-rsync}" rel="${RELEASE_SET:-rsync}"
installed_as "$NAMESPACE" "$RELEASE" && return 0
installed_as "$ns" "$rel" || return 0
NAMESPACE="$ns"
RELEASE="$rel"
info "Found an earlier install as release '${RELEASE}' in namespace '${NAMESPACE}'; upgrading it in place"
echo " (set RSYNC_RELEASE and RSYNC_NAMESPACE in ${INSTALL_DIR}/${ENV_FILE} to choose the name yourself)"
}
preflight_cluster() {
section "Checking the cluster"
local ctx
ctx="$(kc config current-context 2>/dev/null || true)"
[[ -n "$ctx" ]] || die "kubectl has no current context. Point it at your cluster (e.g. 'gcloud container clusters get-credentials …', 'aws eks update-kubeconfig …', 'az aks get-credentials …') and re-run."
kc get --raw /readyz >/dev/null 2>&1 || kc version >/dev/null 2>&1 || die "Cannot reach the cluster behind context '${ctx}'. Check 'kubectl get nodes'."
info "Cluster reachable (context: ${ctx})"
keep_earlier_default_name
# An unbound PVC is the most common way a "successful" install hangs: nothing
# errors, the database pod just sits Pending until --wait gives up.
local sc_out default_sc=""
sc_out="$(kc get storageclass -o 'jsonpath={range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\t"}{.metadata.annotations.storageclass\.beta\.kubernetes\.io/is-default-class}{"\n"}{end}' 2>/dev/null || true)"
if [[ -n "$STORAGE_CLASS" ]]; then
if printf '%s\n' "$sc_out" | cut -f1 | grep -qx "$STORAGE_CLASS"; then
info "StorageClass ${STORAGE_CLASS}"
else
die "RSYNC_STORAGE_CLASS='${STORAGE_CLASS}' does not exist. Available: $(printf '%s\n' "$sc_out" | cut -f1 | tr '\n' ' ')"
fi
else
default_sc="$(printf '%s\n' "$sc_out" | awk -F'\t' '$2=="true" || $3=="true" {print $1; exit}')"
if [[ -n "$default_sc" ]]; then
info "Default StorageClass: ${default_sc}"
else
die "The cluster has no default StorageClass, so the database's volume would stay Pending forever.
Available: $(printf '%s\n' "$sc_out" | cut -f1 | tr '\n' ' ')
Pick one and re-run: RSYNC_STORAGE_CLASS=<name> (in ${INSTALL_DIR}/${ENV_FILE})"
fi
fi
fit_to_cluster
# Old volumes with no Secret to match them: the database was initialised with a
# password this run cannot know, so a fresh one would lock the app out of it.
if ! kc -n "$NAMESPACE" get secret "${RELEASE}-secrets" >/dev/null 2>&1; then
local pvcs
pvcs="$(kc -n "$NAMESPACE" get pvc -l "app.kubernetes.io/instance=${RELEASE}" -o name 2>/dev/null | wc -l | tr -d ' ')"
if [[ "${pvcs:-0}" -gt 0 ]]; then
warn "${NAMESPACE} holds ${pvcs} volume(s) from an earlier '${RELEASE}' install but no ${RELEASE}-secrets."
echo " The database keeps the password it was first created with; new generated secrets will not open it."
echo " Either restore the old .env, or start clean: kubectl -n ${NAMESPACE} delete pvc -l app.kubernetes.io/instance=${RELEASE}"
fi
fi
}
# ─── values.generated.yaml ───────────────────────────────────────────────────
write_values() {
local file="${INSTALL_DIR}/${VALUES_FILE}" f
( umask 077; : > "$file" )
{
echo "# Generated by install-k8s.sh from ${ENV_FILE} on every run -- edit the .env (or"
echo "# RSYNC_EXTRA_VALUES), not this file. Contains secrets; chmod 600."
echo "secrets:"
echo " jwtSecret: $(yq "$(env_value JWT_SECRET)")"
echo " encryptionKey: $(yq "$(env_value ENCRYPTION_KEY)")"
echo " internalServiceSecret: $(yq "$(env_value INTERNAL_SERVICE_SECRET)")"
echo " postgresPassword: $(yq "$(env_value POSTGRES_PASSWORD)")"
echo " redisPassword: $(yq "$(env_value REDIS_PASSWORD)")"
echo " minioAccessKey: $(yq "$(env_value MINIO_ACCESS_KEY)")"
echo " minioSecretKey: $(yq "$(env_value MINIO_SECRET_KEY)")"
# Its own login, not the MinIO pair: connector pods can reach this store.
echo " blobStagingAccessKey: $(yq "$(env_value BLOB_STAGING_ACCESS_KEY)")"
echo " blobStagingSecretKey: $(yq "$(env_value BLOB_STAGING_SECRET_KEY)")"
echo " demoWarehousePassword: $(yq "$(env_value RSYNC_DEMO_WAREHOUSE_PASSWORD)")"
[[ -z "$OPENAI_KEY" ]] || echo " openaiApiKey: $(yq "$OPENAI_KEY")"
echo "frontend:"
echo " apiUrl: $(yq "$API_URL")"
echo " publicUrl: $(yq "$APP_URL")"
# Set only when fit_to_cluster found that not even the lean set fits. These
# are the chart's defaults minus the headroom a small node cannot seat; the
# memory limits (and so the orchestrator's tableConcurrency) are untouched.
# RSYNC_EXTRA_VALUES is layered after this file, so a user's own number wins.
if [[ "$TIGHT" == "true" ]]; then
echo " replicaCount: 1"
echo "apiGateway:"
echo " replicaCount: 1"
echo "orchestrator:"
echo " resources:"
echo " requests:"
echo " cpu: $(yq "$TIGHT_ORCHESTRATOR_CPU")"
fi
if [[ -n "$STORAGE_CLASS" || -n "$IMAGE_REGISTRY" || -n "$IMAGE_TAG" || -n "$PULL_SECRET" ]]; then
echo "global:"
[[ -z "$STORAGE_CLASS" ]] || echo " storageClass: $(yq "$STORAGE_CLASS")"
if [[ -n "$IMAGE_REGISTRY" || -n "$IMAGE_TAG" || -n "$PULL_SECRET" ]]; then
echo " image:"
[[ -z "$IMAGE_REGISTRY" ]] || echo " registry: $(yq "$IMAGE_REGISTRY")"
[[ -z "$IMAGE_TAG" ]] || echo " tag: $(yq "$IMAGE_TAG")"
[[ -z "$PULL_SECRET" ]] || echo " pullSecrets: [{name: $(yq "$PULL_SECRET")}]"
fi
fi
if [[ -n "$APP_HOST" ]]; then
echo "ingress:"
echo " enabled: true"
[[ -z "$INGRESS_CLASS" ]] || echo " className: $(yq "$INGRESS_CLASS")"
echo " hosts:"
echo " app: $(yq "$APP_HOST")"
echo " api: $(yq "$API_HOST")"
if [[ -n "$TLS_SECRET" ]]; then
echo " tls:"
echo " - secretName: $(yq "$TLS_SECRET")"
echo " hosts: [$(yq "$APP_HOST"), $(yq "$API_HOST")]"
fi
fi
echo "connectors:"
if [[ -n "$FLEET" ]]; then
echo " fleet:"
for f in $FLEET; do
echo " - id: ${f}"
echo " version: $(connector_version "$f")"
echo " image: { repository: mcp-${f}, tag: \"\" }"
done
else
echo " fleet: []"
fi
echo "demo:"
echo " enabled: ${DEMO}"
if [[ "$LLM_PROVIDER" == "ollama" ]]; then
echo "ollama:"
echo " enabled: true"
echo "generation:"
echo " llm:"
echo " provider: ollama"
fi
} >> "$file"
VALUES_PATH="$file"
}
# ─── Helm ────────────────────────────────────────────────────────────────────
chart_args() {
if [[ "$RSYNC_CHART" == oci://* || "$RSYNC_CHART" == http*://* ]]; then
printf '%s\n' "$RSYNC_CHART" "--version" "$RSYNC_CHART_VERSION"
else
printf '%s\n' "$RSYNC_CHART"
fi
}
value_args() {
printf '%s\n' "-f" "$VALUES_PATH"
if [[ -n "$EXTRA_VALUES" ]]; then printf '%s\n' "-f" "$EXTRA_VALUES"; fi
}
render_check() {
section "Rendering the chart"
local args=() line
while IFS= read -r line; do args+=("$line"); done < <(chart_args)
while IFS= read -r line; do args+=("$line"); done < <(value_args)
if "$HELM" template "$RELEASE" "${args[@]}" --namespace "$NAMESPACE" >/dev/null; then
info "The chart renders with these settings"
else
die "The chart refused these settings (message above). Fix the .env / RSYNC_EXTRA_VALUES and re-run."
fi
}
release_status() {
"$HELM" status "$RELEASE" -n "$NAMESPACE" ${HELM_CTX[@]+"${HELM_CTX[@]}"} 2>/dev/null | sed -n 's/^STATUS: //p' | head -1
}
release_revision() {
"$HELM" status "$RELEASE" -n "$NAMESPACE" ${HELM_CTX[@]+"${HELM_CTX[@]}"} 2>/dev/null | sed -n 's/^REVISION: //p' | head -1
}
run_helm() {
section "Installing rsync.ai (${RELEASE} in ${NAMESPACE})"
local status revision
status="$(release_status || true)"
revision="$(release_revision || true)"
case "$status" in
pending-install|failed)
# helm refuses `upgrade` on a release whose FIRST revision never finished
# ("has no deployed releases", or "another operation in progress" after a
# Ctrl-C). Nothing of value lives in it, and the Secret and the volumes
# survive an uninstall, so clearing it is safe and saves the user a step.
if [[ "$revision" == "1" ]]; then
warn "The previous first install did not finish (${status}); clearing it before retrying (secrets and volumes are kept)"
"$HELM" uninstall "$RELEASE" -n "$NAMESPACE" --wait --timeout 5m ${HELM_CTX[@]+"${HELM_CTX[@]}"} >/dev/null
fi ;;
pending-upgrade|pending-rollback)
die "Release ${RELEASE} is stuck in '${status}' (an earlier run was interrupted). Clear it, then re-run:
helm rollback ${RELEASE} -n ${NAMESPACE} ${HELM_CTX[@]+"${HELM_CTX[*]}"}" ;;
esac
local args=() line
while IFS= read -r line; do args+=("$line"); done < <(chart_args)
while IFS= read -r line; do args+=("$line"); done < <(value_args)
echo " This pulls ~14 images and can take 5-10 minutes on a fresh cluster (timeout ${WAIT_TIMEOUT})."
if "$HELM" upgrade --install "$RELEASE" "${args[@]}" \
--namespace "$NAMESPACE" --create-namespace \
--wait --timeout "$WAIT_TIMEOUT" ${HELM_CTX[@]+"${HELM_CTX[@]}"}; then
info "Release ${RELEASE} deployed"
else
error "helm did not finish cleanly. Pods that are not ready:"
# READY is "n/m". awk regexes have no backreference, so compare the halves.
kc -n "$NAMESPACE" get pods --no-headers 2>/dev/null \
| awk '{ split($2, r, "/") } $3 == "Completed" || $3 == "Succeeded" { next } r[1] != r[2] || $3 != "Running"' \
| sed 's/^/ /' >&2 || true
echo " Look closer: kubectl -n ${NAMESPACE} describe pod <name> / kubectl -n ${NAMESPACE} logs <name>" >&2
# The scheduler says WHY a pod is Pending; helm only says "timed out".
local sched
sched="$(kc -n "$NAMESPACE" get events --field-selector reason=FailedScheduling 2>/dev/null || true)"
if printf '%s\n' "$sched" | grep -q "Insufficient"; then
echo " Cause: the cluster is out of room -- the scheduler reports 'Insufficient memory/cpu' for:" >&2
printf '%s\n' "$sched" | grep "Insufficient" | sed -n 's/.*pod\/\([^ ]*\) .*/\1/p' | sort -u | sed 's/^/ /' >&2
echo " Add nodes, or install less: set RSYNC_CONNECTORS=${LEAN_CONNECTORS} and RSYNC_DEMO=false in ${INSTALL_DIR}/${ENV_FILE}." >&2
fi
if kc -n "$NAMESPACE" get events 2>/dev/null | grep -q "no match for platform"; then
echo " Cause: an image has no build for this cluster's CPU architecture (\"no match for platform\")." >&2
echo " Tags cut before multi-arch publishing are amd64-only. On arm64 nodes (Apple Silicon, Graviton," >&2
echo " Axion, Ampere) set RSYNC_IMAGE_TAG to a newer release in ${INSTALL_DIR}/${ENV_FILE} and re-run." >&2
fi
# A registry that answers slowly is not a broken install: the kubelet retries a
# failed pull on its own, and a re-run resumes on the layers it already has.
if kc -n "$NAMESPACE" get events 2>/dev/null | grep "Failed to pull image" | grep -Eq "timeout|deadline exceeded"; then
echo " Cause: the registry was too slow to pull from (\"timeout awaiting response headers\" or similar)." >&2
echo " This is the network between the nodes and ghcr.io, not the install. Pulled layers are cached," >&2
echo " so just re-run this script; if it keeps happening, mirror the images and set RSYNC_IMAGE_REGISTRY" >&2
echo " in ${INSTALL_DIR}/${ENV_FILE}." >&2
fi
echo " Then re-run this script; it resumes where it stopped." >&2
exit 1
fi
}
svc_port() {
# $1 = service suffix, $2 = fallback
local p
p="$(kc -n "$NAMESPACE" get svc "${RELEASE}-$1" -o 'jsonpath={.spec.ports[0].port}' 2>/dev/null || true)"
printf '%s' "${p:-$2}"
}
url_port() {
# Port of a localhost URL, else nothing.
local u="$1"
if [[ "$u" =~ ^http://(localhost|127\.0\.0\.1):([0-9]+)/?$ ]]; then printf '%s' "${BASH_REMATCH[2]}"; fi
}
PF_PIDS=()
stop_port_forwards() {
# `${arr[@]}` on an empty array is an error under `set -u` in bash < 4.4.
if [[ ${#PF_PIDS[@]} -gt 0 ]]; then kill "${PF_PIDS[@]}" 2>/dev/null || true; fi
}
finish() {
section "Done"
local app_port api_port
app_port="$(url_port "$APP_URL")"
api_port="$(url_port "$API_URL")"
echo -e " ${BOLD}Open:${NC} ${APP_URL}"
if [[ -n "$app_port" ]]; then
echo " (a laptop-only address: it works while these two port-forwards run)"
echo " kubectl -n ${NAMESPACE} port-forward svc/${RELEASE}-frontend ${app_port}:$(svc_port frontend 3000)"
echo " kubectl -n ${NAMESPACE} port-forward svc/${RELEASE}-api-gateway ${api_port:-8080}:$(svc_port api-gateway 8080)"
echo " To publish it to other people, set RSYNC_APP_HOST / RSYNC_API_HOST in ${INSTALL_DIR}/${ENV_FILE} and re-run."
fi
echo
echo " Your settings and secrets: ${INSTALL_DIR}/${ENV_FILE} (back it up)"
if [[ -z "$OPENAI_KEY" && "$LLM_PROVIDER" == "openai" ]]; then
warn "No LLM is configured. The UI builds pipelines from chat; without a model it only understands"
echo " short requests like \"mongodb to gcs\". For anything free-form add OPENAI_API_KEY"
echo " (or RSYNC_LLM_PROVIDER=ollama) to the .env and re-run."
fi
echo
echo " Pods Running is not a pipeline that moves rows -- run one real pipeline and check the destination."
echo " Uninstall: helm uninstall ${RELEASE} -n ${NAMESPACE} (keeps your secrets and data volumes)"
local want_pf=false
case "$PORT_FORWARD" in
true) want_pf=true ;;
false) want_pf=false ;;
*) [[ -t 1 ]] && want_pf=true ;;
esac
if [[ "$want_pf" == "true" && -n "$app_port" ]]; then
echo
info "Opening port-forwards (Ctrl-C to stop; re-run the two commands above any time)"
# Global, not `local`: the EXIT trap runs after finish() has returned, when a local
# array is already gone -- and under `set -u` that turned a clean run into exit 1.
PF_PIDS=()
kc -n "$NAMESPACE" port-forward "svc/${RELEASE}-frontend" "${app_port}:$(svc_port frontend 3000)" >/dev/null &
PF_PIDS+=($!)
kc -n "$NAMESPACE" port-forward "svc/${RELEASE}-api-gateway" "${api_port:-8080}:$(svc_port api-gateway 8080)" >/dev/null &
PF_PIDS+=($!)
trap 'stop_port_forwards' EXIT INT TERM
wait "${PF_PIDS[@]}" || true
fi
}
usage() {
sed -n '3,33p' "$0" | sed 's/^# \{0,1\}//'
}
main() {
local arg
for arg in "$@"; do
case "$arg" in
--render-only|--dry-run) RENDER_ONLY=true ;;
--port-forward) PORT_FORWARD=true ;;
--no-port-forward) PORT_FORWARD=false ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown argument '${arg}'. Try --help." ;;
esac
done
echo -e "${BOLD}rsync.ai — Kubernetes installer${NC}"
mkdir -p "$INSTALL_DIR"
chmod 700 "$INSTALL_DIR"
section "Checking prerequisites"
ensure_helm
info "helm: $("$HELM" version --template '{{.Version}}')"
HELM_CTX=()
local line
while IFS= read -r line; do HELM_CTX+=("$line"); done < <(helm_ctx_args)
if [[ "$RENDER_ONLY" != "true" ]]; then
ensure_kubectl
info "kubectl: $(kubectl version --client -o json 2>/dev/null | sed -n 's/.*"gitVersion": *"\([^"]*\)".*/\1/p' | head -1)"
fi
load_settings
if [[ "$RENDER_ONLY" != "true" ]]; then preflight_cluster; fi
section "Secrets and settings"
ensure_secrets
write_values
info "Wrote ${VALUES_PATH}"
render_check
if [[ "$RENDER_ONLY" == "true" ]]; then
info "Render-only: no cluster was touched."
exit 0
fi
run_helm
finish
}
main "$@"