From 50ea5654333533d78646beb400f720f8bf947df9 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 14 Jul 2026 12:10:22 +0300 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 99 +++++ compose/caddy.compose.yml | 15 + config/livekit.yaml | 11 + deploy.sh | 24 ++ install.sh | 254 ++++++++++++ requirements-deploy.txt | 3 + .../__pycache__/git_remote.cpython-312.pyc | Bin 0 -> 18107 bytes scripts/deploy/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 272 bytes .../__pycache__/components.cpython-312.pyc | Bin 0 -> 4369 bytes .../__pycache__/compose_build.cpython-312.pyc | Bin 0 -> 15157 bytes .../deploy/__pycache__/config.cpython-312.pyc | Bin 0 -> 6781 bytes .../__pycache__/docker_local.cpython-312.pyc | Bin 0 -> 5304 bytes .../deploy/__pycache__/main.cpython-312.pyc | Bin 0 -> 10608 bytes .../deploy/__pycache__/paths.cpython-312.pyc | Bin 0 -> 5060 bytes .../__pycache__/ssh_auth.cpython-312.pyc | Bin 0 -> 13988 bytes .../__pycache__/transfer.cpython-312.pyc | Bin 0 -> 15416 bytes scripts/deploy/__pycache__/ui.cpython-312.pyc | Bin 0 -> 2982 bytes .../__pycache__/updater.cpython-312.pyc | Bin 0 -> 8233 bytes .../deploy/__pycache__/util.cpython-312.pyc | Bin 0 -> 4564 bytes scripts/deploy/components.py | 95 +++++ scripts/deploy/compose_build.py | 341 ++++++++++++++++ scripts/deploy/config.py | 158 ++++++++ scripts/deploy/docker_local.py | 108 +++++ scripts/deploy/main.py | 223 +++++++++++ scripts/deploy/paths.py | 118 ++++++ scripts/deploy/ssh_auth.py | 263 ++++++++++++ scripts/deploy/transfer.py | 289 +++++++++++++ scripts/deploy/ui.py | 49 +++ scripts/deploy/updater.py | 169 ++++++++ scripts/deploy/util.py | 99 +++++ scripts/docker_inputs_hash.py | 369 +++++++++++++++++ scripts/generate-compose.py | 197 +++++++++ scripts/git_remote.py | 378 ++++++++++++++++++ scripts/lib.sh | 256 ++++++++++++ scripts/resolve-tag.py | 25 ++ templates/Caddyfile | 32 ++ templates/fromchat.service | 33 ++ 39 files changed, 3611 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 compose/caddy.compose.yml create mode 100644 config/livekit.yaml create mode 100755 deploy.sh create mode 100755 install.sh create mode 100644 requirements-deploy.txt create mode 100644 scripts/__pycache__/git_remote.cpython-312.pyc create mode 100644 scripts/deploy/__init__.py create mode 100644 scripts/deploy/__pycache__/__init__.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/components.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/compose_build.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/config.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/docker_local.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/main.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/paths.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/ssh_auth.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/transfer.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/ui.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/updater.cpython-312.pyc create mode 100644 scripts/deploy/__pycache__/util.cpython-312.pyc create mode 100644 scripts/deploy/components.py create mode 100644 scripts/deploy/compose_build.py create mode 100644 scripts/deploy/config.py create mode 100644 scripts/deploy/docker_local.py create mode 100644 scripts/deploy/main.py create mode 100644 scripts/deploy/paths.py create mode 100644 scripts/deploy/ssh_auth.py create mode 100644 scripts/deploy/transfer.py create mode 100644 scripts/deploy/ui.py create mode 100644 scripts/deploy/updater.py create mode 100644 scripts/deploy/util.py create mode 100644 scripts/docker_inputs_hash.py create mode 100644 scripts/generate-compose.py create mode 100644 scripts/git_remote.py create mode 100644 scripts/lib.sh create mode 100644 scripts/resolve-tag.py create mode 100644 templates/Caddyfile create mode 100644 templates/fromchat.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e652fdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.deploy-cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7c837c --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# FromChat Deployment + +One-click installer for a production FromChat stack on Debian-based Linux. + +## Quick install + +```bash +curl -fsSL https://raw.githubusercontent.com/fromchat-messenger/app/main/deployment/install.sh | sudo bash +``` + +The installer: + +1. Ensures root (re-runs via `sudo` if needed) +2. Verifies Debian/Ubuntu +3. Offers to install Docker (official script) +4. Creates `~/fromchat-server` +5. Lets you pick components: **backend**, **frontend**, **caddy**, **updater** +6. Optionally uses custom backend/web git URLs +7. Resolves the latest common semver tag (`v1.0`, `v1.0.0`, …) +8. Downloads only `compose.yml` from each repo at that tag +9. Merges compose files and replaces `build:` with `image: fromchat/:` +10. Opens `Caddyfile` in nano when caddy is selected +11. Runs `docker compose up -d --wait` +12. Optionally starts the [updater](../updater) service + +## Classic deploy (offline, build on your PC) + +For developers who build images **locally** and transfer them to a server via SSH + `docker pussh` — no registry pulls on the server. + +```bash +cd deployment +python3 -m venv .venv && .venv/bin/pip install -r requirements-deploy.txt +# or reuse ../backend/.venv which already has rich + dotenv + +./deploy.sh user@host ~/fromchat-server linux/arm64 --tag latest +``` + +Without `--components`, an interactive checklist opens (backend, frontend, caddy, updater). Non-interactive: + +```bash +./deploy.sh user@host ~/fromchat-server linux/arm64 \ + --components backend,frontend,caddy,updater \ + --tag latest +``` + +This: + +1. Builds all `fromchat/*` images from local `../backend`, `../Web`, `../updater`, and `backend/src/caddy` +2. Merges compose the same way as the installer (`generate-compose.py`) +3. Pulls any third-party images from the registry **on this machine**, then transfers everything via pussh +4. Syncs config to the server and restarts the `fromchat` systemd unit +5. Optionally installs the updater (also built locally) + +The server never pulls from a registry during deploy. Your PC needs network access to pull base images (LiveKit, Docker `FROM` layers, unregistry for pussh). + +Override source trees with `FROMCHAT_BACKEND_DIR`, `FROMCHAT_WEB_DIR`, `FROMCHAT_UPDATER_DIR`. +`DEPLOYMENT_SERVER` in the backend (or deployment) `.env` still works. + +The old entry point `backend/scripts/deploy.sh` redirects here. + +## Generate compose only + +```bash +./install.sh --generate-config backend,frontend,caddy \ + --tag v1.0.0 \ + --output-dir ~/fromchat-server \ + --backend-repo https://github.com/fromchat-messenger/backend.git \ + --web-repo https://github.com/fromchat-messenger/web.git +``` + +## Layout after install + +``` +~/fromchat-server/ + compose.yml # merged production stack + .fromchat-version # current release tag + .env # secrets (create via backend generate:env) + Caddyfile # when caddy selected + config/livekit.yaml + updater/ # when updater selected + compose.yml + .env +``` + +## GitHub token (updater) + +When **updater** is selected, create a token with `read:packages` and `repo` (works for GitHub and git.fromchat.ru): + +https://github.com/settings/tokens/new?description=FromChat%20Updater&scopes=read:packages,repo + +Official `github.com/fromchat-messenger/*` repositories automatically fall back to **git.fromchat.ru/FromChat/** if GitHub is unreachable. + +## Environment overrides + +| Variable | Default | +|----------|---------| +| `FROMCHAT_BACKEND_REPO` | `https://github.com/fromchat-messenger/backend.git` | +| `FROMCHAT_WEB_REPO` | `https://github.com/fromchat-messenger/web.git` | +| `FROMCHAT_APP_REPO` | `https://github.com/fromchat-messenger/app.git` (deployment + updater tooling) | diff --git a/compose/caddy.compose.yml b/compose/caddy.compose.yml new file mode 100644 index 0000000..44dc504 --- /dev/null +++ b/compose/caddy.compose.yml @@ -0,0 +1,15 @@ +services: + caddy: + build: + context: . + dockerfile: Dockerfile + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + networks: + - public + depends_on: + - web + restart: unless-stopped diff --git a/config/livekit.yaml b/config/livekit.yaml new file mode 100644 index 0000000..c520c57 --- /dev/null +++ b/config/livekit.yaml @@ -0,0 +1,11 @@ +# LiveKit SFU — keys must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET in .env +port: 8303 + +rtc: + tcp_port: 8304 + port_range_start: 50000 + port_range_end: 50100 + use_external_ip: false + +keys: + fromchat_dev: "local_dev_secret_must_be_at_least_32_characters_long" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ae36005 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Classic FromChat deploy: build local images, generate production compose, push to server. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOYMENT_ROOT="${SCRIPT_DIR}" +SCRIPTS="${DEPLOYMENT_ROOT}/scripts" + +# Prefer backend venv (has rich + dotenv), then deployment .venv, then python3. +PYTHON="" +for candidate in \ + "${DEPLOYMENT_ROOT}/../backend/.venv/bin/python3" \ + "${DEPLOYMENT_ROOT}/.venv/bin/python3" \ + "$(command -v python3 || true)" +do + if [[ -n "${candidate}" && -x "${candidate}" ]]; then + PYTHON="${candidate}" + break + fi +done +[[ -n "${PYTHON}" ]] || { echo "python3 not found" >&2; exit 1; } + +export PYTHONPATH="${SCRIPTS}${PYTHONPATH:+:${PYTHONPATH}}" +exec "${PYTHON}" -m deploy.main "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..661b1b3 --- /dev/null +++ b/install.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# FromChat one-click server installer +# https://github.com/fromchat-messenger/app +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOYMENT_ROOT="${SCRIPT_DIR}" +# shellcheck source=scripts/lib.sh +source "${DEPLOYMENT_ROOT}/scripts/lib.sh" + +DEFAULT_BACKEND_REPO="${FROMCHAT_BACKEND_REPO:-https://github.com/fromchat-messenger/backend.git}" +DEFAULT_WEB_REPO="${FROMCHAT_WEB_REPO:-https://github.com/fromchat-messenger/web.git}" +DEFAULT_APP_REPO="${FROMCHAT_APP_REPO:-https://github.com/fromchat-messenger/app.git}" + +INSTALL_DIR="" +FROMCHAT_VERSION="" +BACKEND_REPO="${DEFAULT_BACKEND_REPO}" +WEB_REPO="${DEFAULT_WEB_REPO}" +GENERATE_ONLY=false +GENERATE_COMPONENTS="" +GENERATE_TAG="" +GENERATE_OUTPUT="" + +usage() { + cat </dev/null | cut -d: -f6 || true)" + if [[ -z "${home}" ]]; then + home="${HOME}" + fi + echo "${home}/fromchat-server" +} + +configure_caddyfile() { + local install_dir="$1" + local caddyfile="${install_dir}/Caddyfile" + if [[ ! -f "${caddyfile}" ]]; then + cp "${DEPLOYMENT_ROOT}/templates/Caddyfile" "${caddyfile}" + fi + step "Opening Caddyfile in nano (save with Ctrl+O, exit with Ctrl+X)…" + "${EDITOR:-nano}" "${caddyfile}" || nano "${caddyfile}" || vi "${caddyfile}" +} + +setup_updater() { + local install_dir="$1" + local tag="$2" + local updater_dir="${install_dir}/updater" + mkdir -p "${updater_dir}" + + step "Git token for the auto-updater (GitHub or Gitea: read packages + repo metadata)." + info "GitHub token:" + printf '%b%s%b\n' "$BLUE" \ + " https://github.com/settings/tokens/new?description=FromChat%20Updater&scopes=read:packages,repo" \ + "$NC" + info "Gitea token: Settings → Applications → Generate New Token (read:repository, read:package)" + prompt "Paste token (input hidden):" + local token + IFS= read -rs token || true + printf '\n' >&2 + [[ -n "${token}" ]] || die "GitHub token is required when updater is selected." + + fetch_raw_file "${DEFAULT_APP_REPO}" "${tag}" "updater/compose.yml" \ + "${updater_dir}/compose.yml" 2>/dev/null \ + || fetch_raw_file "${DEFAULT_APP_REPO}" "main" "updater/compose.yml" \ + "${updater_dir}/compose.yml" 2>/dev/null \ + || cp "${DEPLOYMENT_ROOT}/../updater/compose.yml" "${updater_dir}/compose.yml" + + local components_csv + components_csv="$(IFS=,; echo "${SELECTED[*]}")" + + cat > "${updater_dir}/.env" </dev/null || true + + SELECTED=() + select_components + + prompt "Use custom git repositories? [y/N]:" + local custom + IFS= read -r custom || true + if [[ "${custom:-}" =~ ^[Yy] ]]; then + prompt "Backend repository URL [${DEFAULT_BACKEND_REPO}]:" + IFS= read -r line || true + [[ -n "${line}" ]] && BACKEND_REPO="${line}" + prompt "Web repository URL [${DEFAULT_WEB_REPO}]:" + IFS= read -r line || true + [[ -n "${line}" ]] && WEB_REPO="${line}" + fi + + step "Resolving latest common semver tag…" + local repos_for_tag=() + component_selected backend && repos_for_tag+=("${BACKEND_REPO}") + component_selected frontend && repos_for_tag+=("${WEB_REPO}") + if ((${#repos_for_tag[@]} == 0)); then + repos_for_tag+=("${BACKEND_REPO}" "${WEB_REPO}") + fi + + step "GitHub / Gitea token for private repos (leave empty if public):" + prompt "Paste token (input hidden):" + local gh_token="" + IFS= read -rs gh_token || true + printf '\n' >&2 + export GITHUB_TOKEN="${gh_token}" + export GIT_TOKEN="${gh_token}" + + if [[ -n "${GITHUB_TOKEN}" ]]; then + FROMCHAT_VERSION="$(python3 "${DEPLOYMENT_ROOT}/scripts/resolve-tag.py" "${GITHUB_TOKEN}" "${repos_for_tag[@]}")" + else + FROMCHAT_VERSION="$(python3 "${DEPLOYMENT_ROOT}/scripts/resolve-tag.py" "${repos_for_tag[@]}")" + fi + [[ -n "${FROMCHAT_VERSION}" ]] || die "Could not resolve a semver tag." + success "Using tag ${FROMCHAT_VERSION}" + + local components_csv + components_csv="$(IFS=,; echo "${SELECTED[*]}")" + local compose_components="" + local c + for c in "${SELECTED[@]}"; do + [[ "${c}" == "updater" ]] && continue + compose_components="${compose_components:+$compose_components,}${c}" + done + + run_generate_compose "${INSTALL_DIR}" "${compose_components}" "${FROMCHAT_VERSION}" + + if component_selected caddy; then + configure_caddyfile "${INSTALL_DIR}" + fi + + if [[ ! -f "${INSTALL_DIR}/.env" ]]; then + warn "No .env in ${INSTALL_DIR}. Create one before production use (see backend generate:env)." + touch "${INSTALL_DIR}/.env" + chown "${real_user}:${real_user}" "${INSTALL_DIR}/.env" 2>/dev/null || true + fi + + step "Starting FromChat stack…" + ( + cd "${INSTALL_DIR}" + docker compose --env-file .env up -d --wait --timeout 600 + ) + + if component_selected updater; then + export GITHUB_TOKEN="${gh_token:-${GITHUB_TOKEN:-}}" + export GIT_TOKEN="${gh_token:-${GIT_TOKEN:-${GITHUB_TOKEN:-}}}" + setup_updater "${INSTALL_DIR}" "${FROMCHAT_VERSION}" + fi + + chown -R "${real_user}:${real_user}" "${INSTALL_DIR}" 2>/dev/null || true + print_success_banner "${INSTALL_DIR}" +} + +main() { + parse_args "$@" + if ${GENERATE_ONLY}; then + generate_config_mode + else + full_install "$@" + fi +} + +main "$@" diff --git a/requirements-deploy.txt b/requirements-deploy.txt new file mode 100644 index 0000000..3c21163 --- /dev/null +++ b/requirements-deploy.txt @@ -0,0 +1,3 @@ +rich>=13.9.4 +python-dotenv>=1.0.1 +PyYAML>=6.0.1 diff --git a/scripts/__pycache__/git_remote.cpython-312.pyc b/scripts/__pycache__/git_remote.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c393adce8e51e1f5b456cb06519c55d36aa7d66e GIT binary patch literal 18107 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/components.cpython-312.pyc b/scripts/deploy/__pycache__/components.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5dba5a65ff4f71ceb25231ec34e3d656cee7f200 GIT binary patch literal 4369 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/compose_build.cpython-312.pyc b/scripts/deploy/__pycache__/compose_build.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e58a966f35ef894a6e5271ad35ff5c0b912e9a4 GIT binary patch literal 15157 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/config.cpython-312.pyc b/scripts/deploy/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea74726babee30bd5eb9a907f894946832c47123 GIT binary patch literal 6781 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/docker_local.cpython-312.pyc b/scripts/deploy/__pycache__/docker_local.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..81f05755e243063b62411e18f7d933f9491180e3 GIT binary patch literal 5304 zcmb_gOKcm*8J^`Xmn$xxdfJlhY@9YV6Orm848x&M+-|V0LelLRX;lIu-sD6Y#r4LSE?F_R^Aaey_lt7rlt}K&tC0q>6 zvspI5S}B*{tkj)wTPdIOBs|cDO9Ik{Gf*;dOUDxwG%FCF;8 z9!VKpf|X08psE=)U78q|Gg$>oh9>ETOpJ~#j7hI%6A}0HUTetPpT#WX+t=Eg-xT?SqW|4TxYv#J;uTE5|pEZq8H&hd(-96;Eu7 zuWX60R8CHBiPIHs`u?T6p{KR`n4YGGS`K@f^g^j+R^z2ZugMv!_nbzY5gg=*#bPFf zR#s8*y<^bl9yLXiiA7xGAkDewvITuU;v*CQO%E8TZYX&|S5F3LM9q&-d^367z+lhh zVOl~>!gS|JMm0>%$mA5$t!EV_A90yNPClPJ2Xiuc8Y@_*@029luDfV&8XbVY{u_wE zt{n$|yvhxO*I#<~!n?QnpZNH6)ibs2IkV+CQ~5#SmM2jQ_BNZ2+?>9{9j$X73Ju#_ z?-tiv<@#&E@bYBMA6Oo*c|+?*Z+VBnXI78Zec{!KTF*dT@U3}QyzAdt^#iV~KfEGd z=Iet0a%!Dy_TCX5-SHzaynghycj)r~a(gdM)WneOQVphn=_1rblRv`46;!4M-o`+C z892BL1McFw!8%61C#GP|9L$wbn#1lJeA7spIOvRn5cW{wq4-8Vcz~hMF@>) zwAg;~?UM9Mclf@u1x7)U{&;EsDaEiTp^$f^cEhzSIw6mNIhme}s^>w)wK=-Ug4>n) zrj-;?K!*b(HM&tAagp43DRO_Ebfhs5fQB?@Dc129eHXjCmSrjzmKUwNSurw+p& z0h@7jLM9JlSU2=9FxfCPAT&A(fBh+dC!5J8E=*K>->Py)>f*riSlvIc_V&u#8?SGk zzq$XTvC4txE7LPo|7+X+*)9KUWiGwtPcM((_4r%j?)9sMo47Lg?4QP};>m6C%`NfG z$`9pRqFna{)}~gbDg&{qZ=~+)UyHBAHzqgdZ*tIn2-@HAovif@u8cqMrMLX)=3pnP zzR@oq?DF_sF-!pyc(c-TyefRZE_AO2Rsw$w?W?tZy|vI_Ep)gRI#de{*Fpoe(7~Mn zpFglOhz>~qI2i2mel8(*@KXFje0_3b{;K<`QRxAO{|h%|;VnSEkcV>v4Uw)PfCBvAxW=^zx6C>I{wvU%?>9#R)yIvYw|)V0akq`O7oKuuH}vJ( z`(jYSY1a}D=-yN2_fopJtIVX^E}zh>zIQXTHbsd)en>4-6}KoOBueGa}(oXyBmzMzBljoH^T z#tdQw7xGFnPZV7tXO(zKpo3EwEbWsBe9bTvd-NlEM6xci*l0>DU>9(PC7+A(dXShhn6z$7$tYBoagx-WfS{rbUwvdZFK?~Tdq7n*F z6qo=?vB}H%Jfxi<)}gaN->2y96TS)X_BIg;UiMqqOfGHm1vT@d0;qYb+GJBXtW%ee z>|kklQ>2SYHU^l0+64UdyAUm-&$!3`=09+4=JvtSYcrdvoBD^vKNLTB=a;FgGg}8o zZ~Mmp?8I>6Ru52ldH(16##29gXFXSWzIqrVc6F0*&Vo|-R6(Qw1JIbRtVsrtTMgYN)T-9K3s_B&|$$Zhe^wiwwGBUew} z7Go6jCRZlcf4o`POjr7B91U7UC*ON%CycuHecp}S)}^8n9KORv|NZwNbZCsx=}x}a zH^vIT;~)ZJ;O)D+$;CBQf$P*HG%z_}nrwt4QFtIzG@`}iMTjCiIRQzjD+^5W2At?y z*4~%Akd?|39i^>t(DWpezyApp7 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/main.cpython-312.pyc b/scripts/deploy/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a79239c73d6e335d41221ae1d5468c90163dae97 GIT binary patch literal 10608 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/ssh_auth.cpython-312.pyc b/scripts/deploy/__pycache__/ssh_auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ec335da80283b9e454fd7cc9a5d663c47618d06 GIT binary patch literal 13988 zcmb_jd2AcknV;b~Jcox!QI=$l4j;5l$@d{&k!8zHV#`S-r?p&{OLHiTGDRvglx4AW z;>~6OsdZPIn#5Q1~ zDGe2|3^ynpm!9tyle{5ip(x&n@c_#+1CEGEL!G90)2kG3epe$GQ%Vmwc?WNKRX5<` zoxByEZr;V);8{cJ*r5)q)EV$hX#MU~PhZ~|?pkDm3tf%GlRLPfgqY-#!k7r@(XlI- z6OD_>P%O5C3&nXZF`A4f;-MHPj`0a@G$e}S34w3_6bIs`B}*tCPb5QFM(l-m6CX;3 z!Z9cYFS_SK$)U%(c2OLX3{gHPh@sRecnLr+&^s4Ma`1@@T;cZaJhYz+4011ZVBrqA zEEnkIT3Wb3H`g*KBu2tRp=5xMjK&fZE#cyFk%v}(q1Rdm_@iIMgjfcvyjGC?-l;F)2s}F(O=#2$BtF8dPT5>(@z4Fc=SwM1n!d z91M;m_^}wKt-;`nW1(0vhYbeHmLeFuOReFs9zWmlLLxRc5)nI2!pd|!CnSa=;iQO( zt3qgGBpSck(F3#VhS_zNBK1{!^fIHBE|J3g=jc=1_5@X4uyLJjr?2tsHYd5?~E*x<< zF*1^f2W1)(1utaO!@p>S=oa;vMsGE&QhL3C3^`mRZpb4el}FnKFK{I3t8|hkIS?u2 z|9v`5&C+RlR-OlJqd0VzCf)co0-PX9vI~)sL^47~0^}@W9~>u;Au?o9g{#*E929mW zZiMI-Rj@jfI26?CzLky1w zLpVeZ%5H`K@*y5nhInvcQ`T~D(F;*lcTgT8lvRUzLSBJSr4s^RK?7K5c{i_tr;e}T zweZyQ9$p7e1Fz=|-=ldiZ+z7N@K(z+@Ri|x07kk26FklEG(#|QL4+{|<#P%P zrxPQwL9);57cCf!#-qt#Fy$!r(yn|7KyxCVAK2vD#!20Gtny;1qy&C!VWWuYGe^Ucvz=K>7U?(&RA0hL=^HUL^L~$iMKH8b5Cs#AV}LQ z*^w9!j>CkP6pycfK;gGanz1Nsb}}+5*@BU{I3`4bgndcQpb!xgvFni_u$16nG!~H@ zN(uE#P5GB-yjYbD60R3S28gvKH?$SvgX_>AS_7649h@NTBLGVt<;%K)GAJX4__e|& zcu0)WKbeGxN03vH$BHc#XB$b?C{$YhbtjY;XCS&otuW?kxw!k_kO;4^S zt^!$2y9__@Du-(DQw?xH3{?-;@D~FPEz~7d$SJ|da21eIAfk^d)fmR(SrL?SycR{+ zn;KqslDZ81^`1C93`NonB}O8WF)9{O#K(`Qfl^)f+VYXmNgA_zEq6l$Ub zD4D|0QWT`$h=^^&Q7;~LVF$dX>dHuFJz$-HY*B;=2*y=2w{3RYT>ET$wt3IuSC(7{ zmtDtlu47BCrzVXLEUp!I?Y!}|qs#8?IrsJ@cOYvGthl^${#pNP+cSooYxAV>Z&180t%X?sre0wp z)mWj@-c6vOVw<`zqSfIltdKV-Jper!dsEtr9r2WQqN6vZ;W+rel+s@A(Xpg}yM}_>IFODR)FmM)jz^P2DwTl1F9M4a&SE*;P&A&D z457FP#Vg1|vIhaFMn;ptiHI1CCsJOOQr3Mx2MviKh=5F3*Yw2H#4_8IW1I49bHTk~ zviE_nX=*HUVN$o^^v=8HPtPC9oX8|IgA3i++MP?zUCYh`Ip={T=b=f%1J*;T6j;x6 zdMf?xmzLSBIdavZu5p2DaLBG|LBE3m#{*d|FUjKx81K)e@`?q&5r*bd2*N8OEbx#I;ax(y=O z42x~LbE&t+AW~t~BUU@CAyQfzqK9MV9 z(p4b8bfjo1sRBE1R1VrJFtTA4#!I~oSCJxl26{FrJwtn?IoFhGDk-pt6xR$&)+%3z zeM+kI_edzQJg}d*Kn<$7Y5~gA-Sa4v2NJQSn!SeiUOND^^nBU) zs;x|IX?)NQYL^v3*i_o3>Lrq#Ql(n`d%m)@s=D@NBWzJxDn0qSN~(dAwxJj3zGx-_ z>kc=V7z0aCD9NQP9M=sxJ+Qh?F69Oj3h+CwRCN4!zwj(Ho@!FQMMX}Cyf_vGV+%AR zaI!h0{ZrhwPv3|5E~s%}Qi(`RCkorsX_Sf6^>%>nm~7otuPtg)d-g$PtqkLHQI+RG4c#~&hHXVK@}n+3Wu;A{iuMTZU_0`^r_z9$*23CJKJ?p_!`NJ2toq*Lf-`F zEF-6GL;?IqLjnl=l78eG$_z$;AJk`r%h)Q)%>tTbB(o3!^ooaZK@7lnpdsNrW)r>-zzSoVv3##LTfaSP-;rkm1=hXHw&d8BZ0o`I+p=s+o;{5+?U|`F zSTMBp0Em&0DuT+u5JHRa1=6p!Drg(7fl)_zRA#UU|8f^N_2ufl{ez3_xK!)g&k-@20*4_Gwja<4xC2!i@B3J#Rw$7RY<% zn})POaHI{e237jMPJvvgL!cG!Bvs?XFxr4B0&gk7>6fgSrs{g3SW&(!#+0SVPhBsVDe)#~4~m4SPsQj#|*{k{Ui6u`B;wS{%vc_{H)akmHveSnkGJMpI}44?Gd&=qtHy#odFx&P zEmR9L{(F6kzIQKYn-BluWcI6<@~*E>b{9Y&cxmdTjPpabWyRr|V`iCbU1#2L82kZq zoqyfAQd2kI`qs8P+wSdouk&u_M>YFbyj;fl8qA_~)kHyLq#Ux_Agm%Atdc7B{}a4` zfJzO+voZ{;0$M4j+ycRC@d%);r>d<&r_i$C)ZRg~EPwgD(p zt#3og`oSBqZ_x9YUWtB+oPbaN8-he%lIY74-AT@fUnjIdF(HLfKSZeG@sS&f2?#6$ zVzS%_1fCz+1Wjy*CG%bR*VmyPaR4IVv#cA{tYx-2$2MnLGcPW&{smy7fk|D#!cHHb zIzGQAZ)wVOFYJ9ATsXD!^!&-Up1Jc(MttLZ*1aQZ-GMEnrc(2*^H8@r1MReBnYJhO zG?i8T78ZtVSowCsU@)lgjpjGh#?(cu28Bv#o+f@tjcn8bNfm7_6``8SzFw06BcA4q z*o|<=ge6cjstCN{o~lpmLt_#R%tV!UONEv30wI}A;K{|m5{OPvPku|PTF7uUkY|W_ z!t|zAAygXL_!3>i%v;j5%$#{EfOwH5quHXG49`1Imk>8VE?1g~k{WJRQl+OdOR=tO z^bMtaCB-|Rlp>S+0T6rTshj$j^b^JBI9(b+ z#mH~TBlCNEiSbjiT#F~h;{=aH;R+0`UkF3|SU;8=3>*Y~hKZn30sJ%N?MG)n@_*v` z2gQIWW{NS`hVH~zjOS24#M0|qOKCbe zS;=ZHYY|jI)NVlKiYM?6f=GIn=#WI4Q#N!wlbZ>sC#F0cFnC!xE1R3&BYX>of~-jZ zIg=7z#7MyC6hx9PF&c?WI`rg7#`ArrP!TJWKU8%~vKRf=r*4d*a!Qmsfygow*w_hd zOg}C}lc<@3;~1@b!gcuSH^`#O6c)RU#WaIM;GKdskhGB-VG)s07D|YlS?GZGRIMtK ztoJ&77c#~BvNVFq720Z-*p^A%1J;d7nRmlnbT<0hwMg? zIflzb7Wpg#zM;bfi+9j{`-!RA}GHRf!M^WlZlSzBY?w)eiKAgh%#{d1RQFXgRZ{>Vt#_kmGc9{9}V%-+oA?AD`M_py(dBlVSiAr`_|pbgkB9ncdrgl?`!fv#>7Q9i+}_Z$iTanOF6Q)R?FS}(kIVRh z-3ZwqcuYMl+7Fs(j5pPtZqay4^=x&{lt(pc35)>Fw zwMjfCBq$66Z%kBU-Lr6Y3I5UKT5PYHrp6mlTP5<8fHpSyLWu1|C>b6)2PTW7V8Twh zBH^KgsyM)9p=)BlpOz5>+Xh>#dJ4%^4O#jkM-RRIWhPj#je|RdcxTDwmonSdX`e79lBL2+#l z-DaEpQ4Ph`O$`@V$M0(`Hs`9=W-`mdmMSVihq$)zPOtqNoZc_r5m@)47y$Lu08C0# z&ZJOWs;aqaOJ28ids(H;SsHz9!j+^&>86^Wj#p)A;at+Lmi!aVw8|Jjq|u~xyaBzQ zC8vKiT^8<=7_Wi{5GYW0k5Q=XCfEt8nrlrmrNKNrU!v)kn}E?ZOhUC0q*W2_i+c21 zdQ){z%QxVVNy44DSTs!dQXVB_OB@CueDv2ekL+z|}QR|d=2+xm=OV(ov02plCv%b%xxA^BeVZh9epzdP%G zD$jI%Vy!D!9V=FLM*HnEt42eO84j$STe3B;n&1Tvtj#%nb_xzGyiGE{w^P=d?-|~* z-mzvb=6xNC*R*4{W69MD>Tu(x$?h4`RPTzXY3>`d-^g5C)MeYATD$?#`{BIjnaQ4l zyKdRtmUFi)&~SSqYxO^{sHk5X%v=9#g{?!CdVXk$-LlN?&at}}Lpk<9mO1bw5U5($ zR)B-g^W%WBS{0~Mur`VuOpJsoWJ5+VD2GjKEkQ$7c!uM(-n&zR0A3}2@WxQpG%TeT z!CTgg3Jl6vKyELSSl9XTsz3**^i(xN-uh8O8TH_n^<((Fy1emCEzgv^M8ZKRSzD^9 zq|yL`3eX6fp-dIM@=u<18O*Gkbrpl=Iy0r}i>jtM3^yucEj@WFafRDHclP#@lN|JE zZ8fusI;*ba|Gs|~+H5L~M@jLHdrpN?hntnJrKi#-uryaWORG+Ues^z5*AD2BsyP=G z#q~%FxP=Vzs7l%T9ImRmP ztBp$2MKl9F1YBMmxe6AI@ToQ>zwE{q4h;0FSd7B~pr!>%K)*1-fwv5<3CAWn%X{Ji zQLZJWZGlMBVoupP?m3mw!1TWZ(--~Xn;gqb;*uBg4mYlsMbMT$xZy#TLA=i3%$$n%~i}uQIPJQ$CO~qeR zyM3X1VPDp>D{tLh^3Hr>VO4EL5y@-!<*oZcW%#YaOb*=F{j>EStcx#Y-935cG#J$6 zIt9Cbq3yjLcXupo%pQ0;Ykda9!BstFZF)#)t>#s@|5CHew&vJYFuTFUwgdN1E+09c zJ90jM%fZLH}CxRNS3L8(i?@Gm`I&Fh}20;cr+NK;7tzI*i1t@ z`CF?v>IeLiA<)MKuFI!OBYXvhT+&cd zTb_3={G@y{W;MLl3^3vN%r?kf5U zRvf>f2Sid-r)7y&zB+X!k%-9^|E{d?-(p%P0~J=3OOUU{Zb)eZ z0WjHGgljA}Ooma+Cs0RP!0Q`j`NbY8YLNju1*B*JiCJJV+Kka&jCNwQ10#aD+c6cu zs2!tkVbp;ULFpf13gJ}1jjs3sh0ucEFv8}GZBOt4YtT zda2{x^dBgrzJ1N5)%U_LJUIQfwUe|*-?rAS({Ea{HtCy>M4Q z$S1<`?`8(a$i1wf0PsZ#P6A5-REQO&=ie7~c-pST-l_RbxeJ@ne)j2CTDTXXK+S<3N= z?%7o>MYC|6G?qsi4PCoRLA0iY*HwI7GaG2bnv14S&}$4$A9}=G($X6~qcA2T`yb=l BUq%1` literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/transfer.cpython-312.pyc b/scripts/deploy/__pycache__/transfer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28e337c7eab5a3f738c0fb9bfda30fe09e105aa1 GIT binary patch literal 15416 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 P^s8oi^Y1B4N&Ei~7EP@Q literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/ui.cpython-312.pyc b/scripts/deploy/__pycache__/ui.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd98658998878ca6b8f915434b0d887d7bf923a8 GIT binary patch literal 2982 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 literal 0 HcmV?d00001 diff --git a/scripts/deploy/__pycache__/util.cpython-312.pyc b/scripts/deploy/__pycache__/util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac7bdfdc29d3c0ee0dc7860736e04ef55a03c5ce GIT binary patch literal 4564 zcma)9UvL}68Q;A-NvAtm@*f$8ga$!yK*X{MkkpvO5bP2fC!P=o$QT&SMR&4JoOELM z1d$?T+)ie&si{r-kkHbJ+m|Mj%#_FUq0{ol41JKJbfO&TOlN2xI(-op4~3_Gdnd_? za5G(vzTMmX_W$quecyiG(<32BkN)SC^uM6=CHrV4-Yww98Wb)d70n|RtDJ!|+&qU_ znK$@(-pPe|!O1=I9>@j5oAJ&2Kqshr&I|LR8dANG`_*362f3v7sUqaE%B%j5u^)B+ z)P7Yu@0|~*1F8&qJ!)7DKps>F)gH)01wMLTanD>vF^ot`GjbZC6OoicQ+jq`B%*3+ zKBtXD5=tVaMc&d1bo8HWFHvmEN;Yd+ilv)bIt$;zam7j{u)9-73Ls(UxRHj!1yn-m z&eoj3$Vl13kBDEN|(?Xs%!fsFKw!y{N@u8qy1M$nd9oK`f%0D6h)P^4iqJ zfvWhuTjKDBIJ};?{B%`(;+8nRA&y@kxSpzthZhgkw6g>c95#1Tw_VZU3r)z^li&J0p^eUs!CN9T@mqb> z%$>Px2vuSE(Bcmrw(|U1k^r%t*A}^OuUg7Lu zLdjWqqQ$`ob6|&pgFeodX#RALmq0T0jHKuj^3@!;7T6{0hS!PpFy?OGr0Edy8rUimAywko2mqk*LFNu>l^&qC-h2n zB={uIy2Ft6pLlem<5YWd@IIcNt0w0tLQs>cJznSut@?8hsc8-1WO5kY6*)9on2@T$VVdle`8{y&gCvOdmYz&N42S%5~+o7S_z~Jq`_KV}yz#|UD zt{zxEaB=Sq`2lG7yWGDzxjcFCp-Yc_^w_1bkH)Io_f+MPTk`%5d4E+NuL$GLodKCi zxq_84v%p$fqJ?jkOYnE-rw#>RULFj;7b6S4+_aT7lm)BpfwnT%l3=xIqr|n$Q^g&% zZE91wj#}K(1)Abj{+iITv9hP+Nwc+fCDr2)ATD^JeG4+V7i~DPNYNjQCCn@{x?&&} zbB&l$d=s_~+8ykQ0NVn!C3b>&OF3=E%0RIj$`;n8w|( zgfQ;0c^HyRK#4NqbMV*lI0-O_yaoOy3mJ{GuH;2fdX{NxAQn+Qh~&W=((aG>OTmwV zi&J;R{?*`eu;ve}B-Zv+gFDydYjd9rU47&FX1g7!yx`C~gD#JE7%Fs#_*0l*<$FeNtIy^C1IP z)VIlIE_%YOKZ6kPf6mY?L(gi|g%jsM#1@v^UV!;hB5>Ew0H2(v%;sl`$692Z`sAeA2(RE#A3Wn(6lb#9W;js z_+)s%IE#mj(RHWK3%gwH248V!=SG1X1+7y|lSKd!gvq@(!+WaXSVfLigxK9d7XTJ9 za5-!F1r$1c|7OSq(BTr?0)q|&y=87Q#M)#uf>%N%?n7>+Uw{}Mf;65)Hkq-6f9bp< z(`_ijWX-f0&w~#Ct9J18V_awfivwT@v&C=(-f@wnna`?`=5{9{MQ$Wgd`@~HuN!K_ zB#~Sm-iKC7%S0f`uu?jWWZ+2%*oh={Lah-EBlQ(zns|H`&*tu_w-aPnn<-{w0Ir!@Pw6b&G;^9j0wcEbno#6IbD7>^#8H`?j zIfffNAA*>K_e*siiJ@=)&7;sMOqqlDY40IXxFmpR zMjI|SKM74v32KL+{H@=B@ZE_oh|_iwDxvLRrpI{6t!2CpwG4&b<4EL%w(eBBG~F&S zYA;FP^Q?o9y=EXfayJK%M(*wJVkH*RIDW-3I|mWY?Oj1` z%;!7^#RC~r%^TVzc^%Z58Bq&FoyQp8MtlE(9{v&qzd(IoAnC7YTf@uYo`&SZp+=a; z!A4*key09BMmrz=N)F)%!2Q&D$p6+Gu6rPdr(yqnbw4XfsBcG|>C^+r>u>Zxsqu)2 RkK)FC7>|A}da)ac{ui!E<(vQj literal 0 HcmV?d00001 diff --git a/scripts/deploy/components.py b/scripts/deploy/components.py new file mode 100644 index 0000000..d24782c --- /dev/null +++ b/scripts/deploy/components.py @@ -0,0 +1,95 @@ +"""Interactive component selection (matches deployment/scripts/lib.sh).""" + +from __future__ import annotations + +import shutil +import subprocess +import sys + +import deploy.ui as ui + +COMPONENT_OPTIONS: list[tuple[str, str, bool]] = [ + ("backend", "Backend (API, DB, LiveKit)", True), + ("frontend", "Web frontend", True), + ("caddy", "Caddy reverse proxy (TLS)", False), + ("updater", "Auto-update service", False), +] + +ALLOWED = {name for name, _, _ in COMPONENT_OPTIONS} + + +def parse_components_csv(raw: str) -> list[str]: + out: list[str] = [] + for part in raw.split(","): + c = part.strip().lower() + if not c: + continue + if c not in ALLOWED: + sys.stderr.write( + f"Unknown component: {c} (allowed: {','.join(sorted(ALLOWED))})\n" + ) + raise SystemExit(1) + if c not in out: + out.append(c) + if not out: + sys.stderr.write("Select at least one component.\n") + raise SystemExit(1) + return out + + +def _select_whiptail() -> list[str]: + args = [ + "whiptail", + "--title", + "FromChat components", + "--checklist", + "Select components (Space toggles, Enter confirms)", + "18", + "72", + str(len(COMPONENT_OPTIONS)), + ] + for name, label, default_on in COMPONENT_OPTIONS: + args.extend([name, label, "ON" if default_on else "OFF"]) + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode != 0: + ui.error("Component selection cancelled.") + raise SystemExit(1) + selected: list[str] = [] + for token in result.stdout.split(): + name = token.strip('"') + if name in ALLOWED: + selected.append(name) + if not selected: + ui.error("Select at least one component.") + raise SystemExit(1) + return selected + + +def _select_text() -> list[str]: + ui.warning("whiptail not found; using text menu.") + selected: list[str] = [] + for name, label, default_on in COMPONENT_OPTIONS: + hint = "Y/n" if default_on else "y/N" + answer = input(f" Include {label}? [{hint}]: ").strip() + if not answer: + if default_on: + selected.append(name) + continue + if answer.lower().startswith("y"): + selected.append(name) + if not selected: + ui.error("Select at least one component.") + raise SystemExit(1) + return selected + + +def select_components_interactive() -> list[str]: + ui.step("Select components") + if shutil.which("whiptail"): + return _select_whiptail() + return _select_text() + + +def compose_components(selected: list[str]) -> list[str]: + """Components that go into the merged stack compose (excludes updater).""" + return [c for c in selected if c != "updater"] diff --git a/scripts/deploy/compose_build.py b/scripts/deploy/compose_build.py new file mode 100644 index 0000000..6d993b3 --- /dev/null +++ b/scripts/deploy/compose_build.py @@ -0,0 +1,341 @@ +"""Parse docker-compose JSON and run image builds for selected components.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from deploy.paths import ProjectPaths +import deploy.ui as ui +from deploy.util import ( + compute_inputs_hash, + dedupe_preserve, + local_docker_image_tags, + local_image_layer_fp, + read_file_if_exists, + sanitize_ref, +) + +FROMCHAT_IMAGE_SERVICES = frozenset( + {"main", "messaging", "file_storage", "postgres", "web", "caddy", "updater"} +) +FROMCHAT_PREFIX = "fromchat/" + + +@dataclass +class PushableService: + service: str + image_tag: str + dockerfile: Path + build_context: Path + build_target: str + input_hash: str + compose_root: Path + + +def fromchat_image(service: str, tag: str) -> str: + return f"{FROMCHAT_PREFIX}{service}:{tag}" + + +class ComposeBuildPhase: + def __init__( + self, + paths: ProjectPaths, + *, + tag: str, + platform: str, + use_docker_build: bool, + ) -> None: + self._paths = paths + self._tag = tag + self._platform = platform + self._use_docker_build = use_docker_build + + def load_compose_json(self, compose_root: Path) -> dict: + env = os.environ.copy() + env["COMPOSE_PROFILES"] = "production" + p = subprocess.run( + ["docker", "compose", "-f", "compose.yml", "config", "--format", "json"], + cwd=compose_root, + capture_output=True, + text=True, + env=env, + ) + if p.returncode != 0: + ui.error(f"docker compose config failed in {compose_root}") + if p.stderr: + print(p.stderr, file=sys.stderr) + sys.exit(1) + return json.loads(p.stdout) + + def list_services(self, compose_root: Path) -> list[str]: + env = os.environ.copy() + env["COMPOSE_PROFILES"] = "production" + p = subprocess.run( + ["docker", "compose", "-f", "compose.yml", "config", "--services"], + cwd=compose_root, + capture_output=True, + text=True, + env=env, + ) + if p.returncode != 0: + return [] + return [s.strip() for s in p.stdout.splitlines() if s.strip()] + + def _make_pushable( + self, + *, + service: str, + image_tag: str, + dockerfile: Path, + build_context: Path, + build_target: str, + compose_root: Path, + ) -> PushableService | None: + if not dockerfile.is_file(): + ui.error(f"Could not find Dockerfile for {service} at {dockerfile}") + sys.exit(1) + if not self._paths.input_hash_script.is_file(): + ui.error(f"Missing {self._paths.input_hash_script}") + sys.exit(1) + h = compute_inputs_hash( + build_context, + dockerfile, + hash_script=self._paths.input_hash_script, + ) + if not h: + ui.error(f"Failed to compute input hash for {service}") + sys.exit(1) + return PushableService( + service=service, + image_tag=image_tag, + dockerfile=dockerfile, + build_context=build_context, + build_target=build_target, + input_hash=h, + compose_root=compose_root, + ) + + def collect_pushable( + self, + compose: dict, + services: list[str], + compose_root: Path, + ) -> list[PushableService]: + out: list[PushableService] = [] + svc_map = compose.get("services") or {} + for service in services: + if service not in FROMCHAT_IMAGE_SERVICES: + continue + spec = svc_map.get(service) + if not isinstance(spec, dict): + continue + build = spec.get("build") + if not isinstance(build, dict): + continue + image_tag = fromchat_image(service, self._tag) + dockerfile_rel = (build.get("dockerfile") or "").strip() + context_rel = (build.get("context") or ".").strip() + build_target = (build.get("target") or "").strip() + if context_rel in (".", "./", ""): + build_context = compose_root + elif context_rel == "..": + build_context = compose_root.parent + elif context_rel.startswith("/"): + build_context = Path(context_rel) + else: + build_context = (compose_root / context_rel).resolve() + if dockerfile_rel: + if dockerfile_rel.startswith("/"): + dockerfile = Path(dockerfile_rel) + elif (compose_root / dockerfile_rel).is_file(): + dockerfile = compose_root / dockerfile_rel + else: + dockerfile = build_context / dockerfile_rel + else: + dockerfile = build_context / "Dockerfile" + ps = self._make_pushable( + service=service, + image_tag=image_tag, + dockerfile=dockerfile, + build_context=build_context, + build_target=build_target, + compose_root=compose_root, + ) + if ps: + out.append(ps) + return out + + def collect_caddy_pushable(self) -> PushableService | None: + caddy_dir = self._paths.caddy_build_dir + if not caddy_dir: + ui.error( + "Caddy selected but backend/src/caddy not found. " + "Set FROMCHAT_BACKEND_DIR to a backend checkout." + ) + sys.exit(1) + return self._make_pushable( + service="caddy", + image_tag=fromchat_image("caddy", self._tag), + dockerfile=caddy_dir / "Dockerfile", + build_context=caddy_dir, + build_target="", + compose_root=caddy_dir, + ) + + def collect_updater_pushable(self) -> PushableService | None: + updater_dir = self._paths.updater_dir + if not updater_dir: + ui.error( + "Updater selected but ../updater not found. Set FROMCHAT_UPDATER_DIR." + ) + sys.exit(1) + dockerfile = updater_dir / "Dockerfile" + return self._make_pushable( + service="updater", + image_tag=fromchat_image("updater", "latest"), + dockerfile=dockerfile, + build_context=updater_dir, + build_target="", + compose_root=updater_dir, + ) + + def plan_builds(self, pushable: list[PushableService]) -> list[PushableService]: + cache_root = self._paths.local_image_cache_dir + cache_root.mkdir(parents=True, exist_ok=True) + to_build: list[PushableService] = [] + for ps in pushable: + key = sanitize_ref(ps.image_tag) + cache_file = cache_root / key / "input.sha256" + prev = read_file_if_exists(cache_file).strip() + fp = local_image_layer_fp(ps.image_tag) + if prev and prev == ps.input_hash and fp: + continue + to_build.append(ps) + return to_build + + def run_builds(self, to_build: list[PushableService]) -> list[str]: + if not to_build: + ui.success("Build skipped (no Docker inputs changed)") + return [] + ui.step(f"Building {len(to_build)} image(s) locally") + for ps in to_build: + ui.substep(f"Building {ps.service} -> {ps.image_tag}...") + if self._use_docker_build: + args = [ + "docker", + "build", + "--pull", + "--file", + str(ps.dockerfile), + "--tag", + ps.image_tag, + ] + else: + args = [ + "docker", + "buildx", + "build", + "--pull", + "--platform", + self._platform, + "--file", + str(ps.dockerfile), + "--tag", + ps.image_tag, + "--output=type=docker", + "--provenance=false", + "--sbom=false", + ] + if ps.build_target: + args.extend(["--target", ps.build_target]) + args.append(str(ps.build_context)) + if subprocess.run(args, cwd=ps.compose_root).returncode != 0: + ui.error(f"Build failed for {ps.service}") + sys.exit(1) + built: list[str] = [] + for ps in to_build: + key = sanitize_ref(ps.image_tag) + d = self._paths.local_image_cache_dir / key + d.mkdir(parents=True, exist_ok=True) + (d / "input.sha256").write_text(ps.input_hash, encoding="utf-8") + built.append(ps.image_tag) + ui.success(f"Build complete! {len(built)} image(s) built") + return built + + +def classify_compose_images( + compose: dict, + service_order: list[str], +) -> tuple[list[str], list[str]]: + """Return (fromchat images to transfer, third-party images to transfer).""" + services = compose.get("services") or {} + fromchat_images: list[str] = [] + third_party: list[str] = [] + for name in service_order: + spec = services.get(name) + if not isinstance(spec, dict): + continue + image_from = (spec.get("image") or "").strip() + if not image_from: + continue + if image_from.startswith(FROMCHAT_PREFIX): + fromchat_images.append(image_from) + else: + third_party.append(image_from) + return dedupe_preserve(fromchat_images), dedupe_preserve(third_party) + + +def images_present_locally(images: list[str], local_tags: set[str]) -> list[str]: + return dedupe_preserve([img for img in images if img in local_tags]) + + +def verify_fromchat_images_local(fromchat_images: list[str], local_tags: set[str], ui_mod: object) -> None: + missing = [img for img in fromchat_images if img not in local_tags] + if missing: + ui_mod.error( + "Missing locally built images: " + ", ".join(missing) + ) + print("Build them on this machine first, then deploy.", file=sys.stderr) + sys.exit(1) + + +def pull_third_party_images(images: list[str], *, platform: str | None = None) -> None: + if not images: + return + import deploy.docker_local as docker_local + + docker_local.pull_images(images, platform=platform) + + +def generate_production_compose( + paths: ProjectPaths, + *, + components: list[str], + tag: str, + output: Path, +) -> None: + ui.step("Generating production compose.yml") + cmd = [ + sys.executable, + str(paths.generate_compose_script), + "--tag", + tag, + "--components", + ",".join(components), + "--output", + str(output), + ] + if "backend" in components and paths.backend_dir: + cmd.extend(["--backend-compose", str(paths.backend_dir / "compose.yml")]) + if "frontend" in components and paths.web_dir: + cmd.extend(["--frontend-compose", str(paths.web_dir / "compose.yml")]) + if "caddy" in components: + cmd.extend(["--caddy-compose", str(paths.caddy_compose)]) + if subprocess.run(cmd).returncode != 0: + ui.error("generate-compose.py failed") + sys.exit(1) diff --git a/scripts/deploy/config.py b/scripts/deploy/config.py new file mode 100644 index 0000000..4947d10 --- /dev/null +++ b/scripts/deploy/config.py @@ -0,0 +1,158 @@ +"""Load .env and CLI into settings.""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass + +from dotenv import load_dotenv + +from deploy.components import compose_components, parse_components_csv, select_components_interactive +from deploy.paths import ProjectPaths + + +@dataclass +class DeploySettings: + server: str + deploy_path: str + platform: str + host_arch: str + platform_arch: str + use_docker_build: bool + components: list[str] + compose_components: list[str] + tag: str + git_token: str | None + paths: ProjectPaths + + +def _machine_arch() -> str: + m = platform.machine().lower() + if m in ("arm64", "aarch64"): + return "arm64" + if m in ("x86_64", "amd64", "i386", "i686"): + return "amd64" + return m + + +def load_settings(paths: ProjectPaths, argv: list[str]) -> DeploySettings: + if paths.env_file.is_file(): + load_dotenv(paths.env_file, override=False) + + positional: list[str] = [] + components_raw: str | None = os.environ.get("FROMCHAT_COMPONENTS") + tag = os.environ.get("FROMCHAT_TAG", "latest") + git_token: str | None = os.environ.get("GIT_TOKEN") or os.environ.get("GITHUB_TOKEN") + interactive = True + i = 1 + while i < len(argv): + arg = argv[i] + if arg in ("--components", "-c"): + if i + 1 >= len(argv): + sys.stderr.write("--components requires a value\n") + raise SystemExit(1) + components_raw = argv[i + 1] + interactive = False + i += 2 + continue + if arg in ("--tag", "-t"): + if i + 1 >= len(argv): + sys.stderr.write("--tag requires a value\n") + raise SystemExit(1) + tag = argv[i + 1] + i += 2 + continue + if arg in ("--no-interactive",): + interactive = False + i += 1 + continue + if arg in ("-h", "--help"): + sys.stdout.write( + "Usage: deploy.sh [user@host] [deploy_path] [platform] " + "[--components backend,frontend,caddy,updater] [--tag TAG]\n" + " Interactive component menu when --components is omitted.\n" + " Or set DEPLOYMENT_SERVER in .env\n" + " Paths: FROMCHAT_BACKEND_DIR, FROMCHAT_WEB_DIR, FROMCHAT_UPDATER_DIR\n" + ) + raise SystemExit(0) + if arg.startswith("-"): + sys.stderr.write(f"Unknown option: {arg}\n") + raise SystemExit(1) + positional.append(arg) + i += 1 + + server = (positional[0] if positional else None) or os.environ.get("DEPLOYMENT_SERVER", "") + server = server.strip() + if not server: + sys.stderr.write( + "Server not specified. Usage: deploy.sh [user@host] [deploy_path] [platform]\n" + f" Or set DEPLOYMENT_SERVER in {paths.env_file} or as an environment variable\n\n" + "Example:\n" + " deploy.sh user@example.com ~/fromchat-server linux/arm64 --tag latest\n" + ) + raise SystemExit(1) + + deploy_path = ( + (positional[1] if len(positional) > 1 else None) + or os.environ.get("DEPLOYMENT_PATH", "") + or "~/fromchat-server" + ).strip() + docker_platform = ( + (positional[2] if len(positional) > 2 else None) + or os.environ.get("DEPLOYMENT_PLATFORM", "") + or "linux/arm64" + ).strip() + + if components_raw is not None: + components = parse_components_csv(components_raw) + elif interactive and sys.stdin.isatty(): + components = select_components_interactive() + else: + components = parse_components_csv("backend,frontend") + + stack = compose_components(components) + + host_arch = _machine_arch() + platform_arch = docker_platform.split("/", 1)[-1] + use_docker_build = bool(host_arch and host_arch == platform_arch) + + if "backend" in components and not paths.backend_dir: + sys.stderr.write( + "Backend component selected but backend repo not found.\n" + "Set FROMCHAT_BACKEND_DIR or keep a sibling ../backend with compose.yml.\n" + ) + raise SystemExit(1) + if "frontend" in components and not paths.web_dir: + sys.stderr.write( + "Frontend component selected but web repo not found.\n" + "Set FROMCHAT_WEB_DIR or keep a sibling ../Web with compose.yml.\n" + ) + raise SystemExit(1) + if "caddy" in components and not paths.caddy_build_dir: + sys.stderr.write( + "Caddy component selected but backend/src/caddy not found.\n" + "Set FROMCHAT_BACKEND_DIR to a backend checkout.\n" + ) + raise SystemExit(1) + if "updater" in components and not paths.updater_dir: + sys.stderr.write( + "Updater component selected but ../updater not found.\n" + "Set FROMCHAT_UPDATER_DIR to the updater repo.\n" + ) + raise SystemExit(1) + + return DeploySettings( + server=server, + deploy_path=deploy_path, + platform=docker_platform, + host_arch=host_arch, + platform_arch=platform_arch, + use_docker_build=use_docker_build, + components=components, + compose_components=stack, + tag=tag, + git_token=git_token, + paths=paths, + ) diff --git a/scripts/deploy/docker_local.py b/scripts/deploy/docker_local.py new file mode 100644 index 0000000..8922041 --- /dev/null +++ b/scripts/deploy/docker_local.py @@ -0,0 +1,108 @@ +"""Local Docker daemon, Docker Desktop, and buildx setup.""" + +from __future__ import annotations + +import subprocess +import sys +import time + +import deploy.ui as ui + + +BUILDER_NAME = "fromchat-builder" + + +def ensure_daemon() -> None: + if _daemon_ok(): + return + ui.warning("Docker daemon is not running") + if not _start_desktop(): + ui.error("Failed to start Docker Desktop. Please start it manually and try again.") + sys.exit(1) + + +def _daemon_ok() -> bool: + return subprocess.run(["docker", "info"], capture_output=True).returncode == 0 + + +def _start_desktop() -> bool: + ui.substep("Starting Docker Desktop...") + if subprocess.run(["docker", "desktop", "start"], capture_output=True).returncode != 0: + return False + ui.substep("Waiting for Docker to start...", end="") + sys.stdout.flush() + max_wait = 60 + waited = 0 + while waited < max_wait: + if _daemon_ok(): + print() + return True + time.sleep(2) + waited += 2 + print(".", end="", flush=True) + print() + return False + + +def ensure_buildx(use_compose_build: bool) -> None: + if use_compose_build: + return + if subprocess.run(["docker", "buildx", "version"], capture_output=True).returncode != 0: + ui.error("Docker buildx not available. Install Docker Desktop.") + sys.exit(1) + _setup_builder() + + +def _setup_builder() -> None: + ui.step("Setting up buildx builder") + name = BUILDER_NAME + exists = subprocess.run(["docker", "buildx", "inspect", name], capture_output=True).returncode == 0 + if exists: + if subprocess.run(["docker", "buildx", "use", name], capture_output=True).returncode != 0: + ui.substep("Recreating builder...") + subprocess.run(["docker", "buildx", "rm", name], capture_output=True) + exists = False + elif subprocess.run(["docker", "buildx", "inspect", name], capture_output=True).returncode != 0: + ui.substep("Recreating builder (inspection failed)...") + subprocess.run(["docker", "buildx", "rm", name], capture_output=True) + exists = False + if not exists: + ui.substep("Creating builder with persistent cache...") + subprocess.run( + [ + "docker", + "buildx", + "create", + "--name", + name, + "--driver", + "docker-container", + "--driver-opt", + "image=moby/buildkit:latest", + "--use", + "--bootstrap", + ], + capture_output=True, + ) + subprocess.run(["docker", "buildx", "use", name], capture_output=True) + + +def pull_images(images: list[str], *, platform: str | None = None) -> None: + """Pull third-party images on this machine (then transfer via pussh).""" + from deploy.util import dedupe_preserve + + unique = dedupe_preserve([img for img in images if img.strip()]) + if not unique: + return + ui.step(f"Pulling {len(unique)} remote image(s) locally") + for image in unique: + ui.substep(f"Pulling {image}...") + cmd = ["docker", "pull"] + if platform: + cmd.extend(["--platform", platform]) + cmd.append(image) + if subprocess.run(cmd).returncode != 0: + ui.error(f"Failed to pull {image}") + sys.exit(1) + ui.success("Remote images ready locally") + diff --git a/scripts/deploy/main.py b/scripts/deploy/main.py new file mode 100644 index 0000000..5aeef79 --- /dev/null +++ b/scripts/deploy/main.py @@ -0,0 +1,223 @@ +"""CLI entry: build locally, transfer images via pussh, rsync config — no registry pulls.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +_SCRIPTS = Path(__file__).resolve().parent.parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +from deploy.compose_build import ( # noqa: E402 + ComposeBuildPhase, + classify_compose_images, + fromchat_image, + generate_production_compose, + pull_third_party_images, + verify_fromchat_images_local, +) +from deploy.config import load_settings # noqa: E402 +import deploy.docker_local as docker_local # noqa: E402 +from deploy.paths import ProjectPaths # noqa: E402 +from deploy.ssh_auth import SshAuth # noqa: E402 +from deploy.transfer import DeployTransfer # noqa: E402 +from deploy.updater import setup_updater_remote # noqa: E402 +import deploy.ui as ui # noqa: E402 +from deploy.util import dedupe_preserve, local_docker_image_tags # noqa: E402 + +UPDATER_IMAGE = fromchat_image("updater", "latest") + + +def _sync_caddy_build_files(paths: ProjectPaths, compose_components: list[str]) -> None: + if "caddy" not in compose_components or not paths.caddy_build_dir: + return + if paths.caddyfile_template.is_file(): + shutil.copy2(paths.caddyfile_template, paths.caddy_build_dir / "Caddyfile") + + +def _prepare_staging(paths: ProjectPaths, compose_components: list[str], tag: str) -> Path: + staging = paths.staging_dir + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + (staging / "config").mkdir() + + if compose_components: + generate_production_compose( + paths, components=compose_components, tag=tag, output=staging / "compose.yml" + ) + (staging / ".fromchat-version").write_text(tag + "\n", encoding="utf-8") + + if paths.livekit_config.is_file(): + shutil.copy2(paths.livekit_config, staging / "config" / "livekit.yaml") + + if "caddy" in compose_components: + caddyfile = staging / "Caddyfile" + if paths.caddyfile_template.is_file(): + shutil.copy2(paths.caddyfile_template, caddyfile) + + if paths.systemd_unit_template.is_file(): + unit = paths.systemd_unit_template.read_text(encoding="utf-8") + if "WorkingDirectory=" not in unit: + unit += "\nWorkingDirectory=/opt/fromchat-server\n" + (staging / "fromchat.service").write_text(unit, encoding="utf-8") + + return staging + + +def _load_generated_compose(staging: Path) -> dict: + compose_file = staging / "compose.yml" + if not compose_file.is_file(): + return {} + env = os.environ.copy() + p = subprocess.run( + ["docker", "compose", "-f", "compose.yml", "config", "--format", "json"], + cwd=staging, + capture_output=True, + text=True, + env=env, + ) + if p.returncode != 0: + ui.warning("docker compose config on staging failed; using raw YAML structure") + import yaml + + return yaml.safe_load(compose_file.read_text(encoding="utf-8")) or {} + return json.loads(p.stdout) + + +def _collect_all_pushable( + build_phase: ComposeBuildPhase, + paths: ProjectPaths, + stack: list[str], + include_updater: bool, +) -> list: + pushable = [] + ui.step("Detecting buildable services") + for component in stack: + if component == "caddy": + ps = build_phase.collect_caddy_pushable() + if ps: + pushable.append(ps) + ui.substep(f"caddy: {ps.service}") + continue + compose_root = paths.project_root_for(component) + if not compose_root: + continue + services = build_phase.list_services(compose_root) + if not services: + ui.warning(f"No services in {compose_root}/compose.yml") + continue + compose_json = build_phase.load_compose_json(compose_root) + found = build_phase.collect_pushable(compose_json, services, compose_root) + ui.substep(f"{component}: {', '.join(p.service for p in found) or '(none)'}") + pushable.extend(found) + + if include_updater: + ps = build_phase.collect_updater_pushable() + if ps: + pushable.append(ps) + ui.substep(f"updater: {ps.service}") + + return pushable + + +def main() -> None: + paths = ProjectPaths.from_deploy_package() + settings = load_settings(paths, sys.argv) + ui.banner() + ui.info("Build locally, pull remote images on this PC, transfer via pussh") + ui.info(f"Components: {', '.join(settings.components)}") + ui.info(f"Image tag: {settings.tag}") + if paths.backend_dir: + ui.info(f"Backend: {paths.backend_dir}") + if paths.web_dir: + ui.info(f"Web: {paths.web_dir}") + + creds = SshAuth(settings.server).authenticate() + transfer = DeployTransfer(paths) + deploy_resolved = transfer.resolve_deploy_path_on_server(settings.server, settings.deploy_path) + + stack = settings.compose_components + include_updater = "updater" in settings.components + + docker_local.ensure_daemon() + docker_local.ensure_buildx(settings.use_docker_build) + + build_phase = ComposeBuildPhase( + paths, + tag=settings.tag, + platform=settings.platform, + use_docker_build=settings.use_docker_build, + ) + + all_pushable = _collect_all_pushable( + build_phase, paths, stack, include_updater=include_updater + ) + + if not all_pushable: + if stack or include_updater: + ui.error("No buildable services found for selected components") + raise SystemExit(1) + + _sync_caddy_build_files(paths, stack) + + ui.build_banner() + to_build = build_phase.plan_builds(all_pushable) + build_phase.run_builds(to_build) + + staging = _prepare_staging(paths, stack, settings.tag) + + ui.deploy_banner(settings.server) + transfer.ensure_pussh() + + images_to_transfer: list[str] = [] + + if stack: + generated = _load_generated_compose(staging) + services = list((generated.get("services") or {}).keys()) + local_tags = local_docker_image_tags() + fromchat_images, third_party = classify_compose_images(generated, services) + + verify_fromchat_images_local(fromchat_images, local_tags, ui) + pull_third_party_images(third_party, platform=settings.platform) + + images_to_transfer.extend(fromchat_images) + images_to_transfer.extend(third_party) + + if include_updater: + local_tags = local_docker_image_tags() + verify_fromchat_images_local([UPDATER_IMAGE], local_tags, ui) + images_to_transfer.append(UPDATER_IMAGE) + + images_to_transfer = dedupe_preserve(images_to_transfer) + transfer.pussh_images(creds, images_to_transfer) + + if stack: + transfer.rsync_staging(creds, settings.deploy_path, staging) + transfer.copy_env_prod(creds, settings.deploy_path) + if "backend" in stack: + deploy_resolved = transfer.sync_firebase_cert(creds, settings.deploy_path) + transfer.run_remote_systemd(creds, deploy_resolved) + + if include_updater: + setup_updater_remote( + creds, + settings.deploy_path, + deploy_resolved, + components=settings.components, + paths=paths, + git_token=settings.git_token, + sudo_password=creds.sudo_password, + ) + + print() + ui.success("Deployment complete!") + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy/paths.py b/scripts/deploy/paths.py new file mode 100644 index 0000000..ef35a69 --- /dev/null +++ b/scripts/deploy/paths.py @@ -0,0 +1,118 @@ +"""Resolved paths for classic deploy against sibling backend/web repos.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def _find_repo_with_compose(*candidates: str) -> Path | None: + for cand in candidates: + if not cand: + continue + p = Path(cand).expanduser().resolve() + if p.is_dir() and (p / "compose.yml").is_file(): + return p + return None + + +def _find_dir(*candidates: str) -> Path | None: + for cand in candidates: + if not cand: + continue + p = Path(cand).expanduser().resolve() + if p.is_dir(): + return p + return None + + +@dataclass(frozen=True) +class ProjectPaths: + """Deployment repo + local component source trees.""" + + deployment_root: Path + scripts_dir: Path + backend_dir: Path | None + web_dir: Path | None + updater_dir: Path | None + caddy_build_dir: Path | None + env_file: Path + staging_dir: Path + local_cache_root: Path + local_image_cache_dir: Path + input_hash_script: Path + livekit_config: Path + caddy_compose: Path + caddyfile_template: Path + systemd_unit_template: Path + generate_compose_script: Path + + @classmethod + def from_deploy_package(cls) -> ProjectPaths: + deploy_pkg = Path(__file__).resolve().parent + scripts_dir = deploy_pkg.parent + deployment_root = scripts_dir.parent + parent = deployment_root.parent + + backend = _find_repo_with_compose( + os.environ.get("FROMCHAT_BACKEND_DIR", ""), + str(parent / "backend"), + str(parent / "Backend"), + ) + web = _find_repo_with_compose( + os.environ.get("FROMCHAT_WEB_DIR", ""), + str(parent / "Web"), + str(parent / "web"), + ) + updater = _find_dir( + os.environ.get("FROMCHAT_UPDATER_DIR", ""), + str(parent / "updater"), + ) + caddy_build = None + if backend: + cand = backend / "src" / "caddy" + if cand.is_dir() and (cand / "Dockerfile").is_file(): + caddy_build = cand + + env_file = deployment_root / ".env" + if backend and (backend / ".env").is_file(): + env_file = backend / ".env" + + cache = deployment_root / ".deploy-cache" + return cls( + deployment_root=deployment_root, + scripts_dir=scripts_dir, + backend_dir=backend, + web_dir=web, + updater_dir=updater, + caddy_build_dir=caddy_build, + env_file=env_file, + staging_dir=cache / "staging", + local_cache_root=cache, + local_image_cache_dir=cache / "images", + input_hash_script=scripts_dir / "docker_inputs_hash.py", + livekit_config=deployment_root / "config" / "livekit.yaml", + caddy_compose=deployment_root / "compose" / "caddy.compose.yml", + caddyfile_template=deployment_root / "templates" / "Caddyfile", + systemd_unit_template=deployment_root / "templates" / "fromchat.service", + generate_compose_script=scripts_dir / "generate-compose.py", + ) + + def compose_for(self, component: str) -> Path | None: + if component == "backend" and self.backend_dir: + return self.backend_dir / "compose.yml" + if component == "frontend" and self.web_dir: + return self.web_dir / "compose.yml" + if component == "caddy": + return self.caddy_compose if self.caddy_compose.is_file() else None + return None + + def project_root_for(self, component: str) -> Path | None: + if component == "backend": + return self.backend_dir + if component == "frontend": + return self.web_dir + if component == "caddy": + return self.caddy_build_dir + return None diff --git a/scripts/deploy/ssh_auth.py b/scripts/deploy/ssh_auth.py new file mode 100644 index 0000000..34d44e6 --- /dev/null +++ b/scripts/deploy/ssh_auth.py @@ -0,0 +1,263 @@ +"""SSH key agent, host trust, pubkey install, and optional sudo password.""" + +from __future__ import annotations + +import getpass +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import deploy.ui as ui + +DEFAULT_KEY_FILE = Path.home() / ".ssh" / "id_rsa" +KEYGEN_DISPLAY = ( + 'ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" -C "fromchat-deploy"' +) + + +@dataclass +class SshCredentials: + server: str + sudo_password: str + + +def ssh_common_options() -> list[str]: + return [ + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ConnectTimeout=10", + ] + + +def ssh_argv(server: str, remote_command: str) -> list[str]: + return ["ssh", *ssh_common_options(), server, remote_command] + + +def scp_argv(local: str, remote: str) -> list[str]: + return ["scp", *ssh_common_options(), local, remote] + + +class SshAuth: + def __init__(self, server: str) -> None: + self._server = server + + def authenticate(self) -> SshCredentials: + ui.step("Authentication") + self._ensure_agent() + key_file = self._resolve_key_file() + self._ensure_key_file(key_file) + self._ensure_key_in_agent(key_file) + self._trust_host_key() + sudo_password = self._read_sudo_password() + self._verify_key_auth(key_file, sudo_password) + sudo_password = self._verify_sudo_password(sudo_password) + return SshCredentials(server=self._server, sudo_password=sudo_password) + + def _read_sudo_password(self) -> str: + pw = getpass.getpass(" • Sudo password: ") + if not pw: + ui.warning("No password provided - assuming passwordless sudo") + return pw + + def _resolve_key_file(self) -> Path: + env_key = os.environ.get("FROMCHAT_SSH_KEY", "").strip() + if env_key: + return Path(env_key).expanduser() + ssh_dir = Path.home() / ".ssh" + for name in ("id_ed25519", "id_rsa"): + candidate = ssh_dir / name + if candidate.is_file(): + return candidate + return DEFAULT_KEY_FILE + + def _prompt_yes_no(self, message: str, *, default: bool = True) -> bool: + if not sys.stdin.isatty(): + return default + hint = "Y/n" if default else "y/N" + answer = input(f" {message} [{hint}]: ").strip() + if not answer: + return default + return answer.lower().startswith("y") + + def _ensure_agent(self) -> None: + if os.environ.get("SSH_AUTH_SOCK"): + return + subprocess.run(["ssh-agent", "-s"], capture_output=True, check=False) + + def _ensure_key_file(self, key_file: Path) -> None: + if key_file.is_file(): + return + ui.warning(f"SSH key not found at {key_file}") + print(f" Command: {KEYGEN_DISPLAY}") + if not self._prompt_yes_no("Create SSH key now?", default=True): + ui.error("SSH key is required for deploy.") + raise SystemExit(1) + key_file.parent.mkdir(mode=0o700, exist_ok=True) + ui.substep("Creating SSH key...") + result = subprocess.run( + [ + "ssh-keygen", + "-t", + "rsa", + "-b", + "4096", + "-f", + str(key_file), + "-N", + "", + "-C", + "fromchat-deploy", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + ui.error("Failed to create SSH key") + if result.stderr: + print(result.stderr, file=sys.stderr) + raise SystemExit(1) + ui.success(f"Created {key_file}") + + def _ensure_key_in_agent(self, key_file: Path) -> None: + loaded = False + r = subprocess.run(["ssh-add", "-l"], capture_output=True, text=True) + if r.returncode == 0: + fp_r = subprocess.run( + ["ssh-keygen", "-lf", str(key_file)], + capture_output=True, + text=True, + ) + if fp_r.returncode == 0: + parts = fp_r.stdout.strip().split() + fingerprint = parts[1] if len(parts) > 1 else "" + if fingerprint and fingerprint in r.stdout: + loaded = True + if not loaded: + ui.substep("Adding SSH key to agent...") + add = subprocess.run(["ssh-add", str(key_file)], capture_output=True, text=True) + if add.returncode != 0: + ui.error("Failed to add SSH key to agent.") + if add.stderr: + print(add.stderr, file=sys.stderr) + raise SystemExit(1) + + def _server_host(self) -> str: + host = self._server.split("@", 1)[-1] + if host.startswith("[") and "]" in host: + return host[1 : host.index("]")] + return host.split(":", 1)[0] + + def _trust_host_key(self) -> None: + host = self._server_host() + if not host: + return + known_hosts = Path.home() / ".ssh" / "known_hosts" + known_hosts.parent.mkdir(mode=0o700, exist_ok=True) + if known_hosts.is_file(): + try: + with known_hosts.open(encoding="utf-8") as fh: + if host in fh.read(): + return + except OSError: + pass + ui.substep(f"Trusting host key for {host}...") + try: + scan = subprocess.run( + ["ssh-keyscan", "-H", host], + capture_output=True, + text=True, + timeout=30, + ) + except (subprocess.TimeoutExpired, OSError) as exc: + ui.warning(f"Could not scan host key for {host}: {exc}") + return + if scan.returncode != 0 or not scan.stdout.strip(): + ui.warning(f"ssh-keyscan returned no keys for {host}") + return + with known_hosts.open("a", encoding="utf-8") as fh: + fh.write(scan.stdout) + if not scan.stdout.endswith("\n"): + fh.write("\n") + ui.success(f"Host key for {host} added to known_hosts") + + def _test_key_auth(self) -> bool: + return ( + subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + *ssh_common_options(), + self._server, + "echo SSH key works", + ], + capture_output=True, + ).returncode + == 0 + ) + + def _install_pubkey(self, pub: Path, password: str) -> bool: + ui.substep(f"Installing public key on {self._server}...") + base_cmd = [ + "ssh-copy-id", + "-i", + str(pub), + *ssh_common_options(), + self._server, + ] + if password and shutil.which("sshpass"): + env = os.environ.copy() + env["SSHPASS"] = password + result = subprocess.run(["sshpass", "-e", *base_cmd], env=env) + return result.returncode == 0 + if password: + ui.substep("sshpass not found — enter the same password when ssh-copy-id prompts") + return subprocess.run(base_cmd).returncode == 0 + + def _verify_key_auth(self, key_file: Path, sudo_password: str) -> None: + pub = key_file.with_suffix(key_file.suffix + ".pub") + if not pub.is_file(): + ui.error(f"Missing public key: {pub}") + raise SystemExit(1) + + if self._test_key_auth(): + ui.success("SSH key authentication works") + return + + ui.warning(f"SSH key authentication failed for {self._server}") + if self._prompt_yes_no( + f"Install your public key on {self._server} with ssh-copy-id?", + default=True, + ): + if self._install_pubkey(pub, sudo_password) and self._test_key_auth(): + ui.success("SSH key installed and verified") + return + + ui.error("SSH key authentication still failing") + sys.stderr.write( + f' Try manually: ssh-copy-id -i "{pub}" "{self._server}"\n' + ) + if pub.is_file(): + sys.stderr.write(f" Public key: {pub.read_text(encoding='utf-8').strip()}\n") + raise SystemExit(1) + + def _verify_sudo_password(self, password: str) -> str: + if not password: + return "" + while True: + chk = subprocess.run( + ssh_argv(self._server, "sudo -S -v"), + input=(password + "\n").encode(), + capture_output=True, + ) + if chk.returncode == 0: + return password + ui.error("Invalid sudo password, please try again") + password = getpass.getpass(" • Sudo password: ") + if not password: + ui.warning("No password provided - assuming passwordless sudo") + return "" diff --git a/scripts/deploy/transfer.py b/scripts/deploy/transfer.py new file mode 100644 index 0000000..189230a --- /dev/null +++ b/scripts/deploy/transfer.py @@ -0,0 +1,289 @@ +"""Image pussh, rsync deployment layout, Firebase cert, remote systemd.""" + +from __future__ import annotations + +import shlex +import subprocess +import sys +from pathlib import Path + +from deploy.paths import ProjectPaths +from deploy.ssh_auth import SshCredentials, scp_argv, ssh_argv, ssh_common_options +import deploy.ui as ui + +UNREGISTRY_IMAGE = "ghcr.io/psviderski/unregistry" + +REMOTE_SYSTEMD_SCRIPT = r"""set -e + +REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}" +REMOTE_DEPLOY_PATH="${DEPLOY_PATH:-}" +export SUDO_PROMPT="" + +sudo_cmd() { + if [ -n "$REMOTE_SUDO_PASS" ]; then + echo "$REMOTE_SUDO_PASS" | sudo -S -p '' "$@" 2>/dev/null + else + sudo "$@" 2>/dev/null + fi +} + +if [ -z "$REMOTE_DEPLOY_PATH" ]; then + echo "❌ DEPLOY_PATH is not set" + exit 1 +fi + +mkdir -p "$REMOTE_DEPLOY_PATH/config" "$REMOTE_DEPLOY_PATH/data/prod" +cd "$REMOTE_DEPLOY_PATH" + +if [ ! -f "$REMOTE_DEPLOY_PATH/.env" ]; then + echo "⚠️ Warning: .env file not found" +fi + +UNIT_SRC="$REMOTE_DEPLOY_PATH/fromchat.service" +if [ -f "$UNIT_SRC" ]; then + # Ensure WorkingDirectory matches deploy path + sed -i.bak "s|^WorkingDirectory=.*|WorkingDirectory=$REMOTE_DEPLOY_PATH|" "$UNIT_SRC" || true + sudo_cmd cp -f "$UNIT_SRC" /etc/systemd/system/fromchat.service +fi + +if systemctl is-active --quiet fromchat; then + sudo_cmd systemctl stop fromchat +fi + +docker compose down --remove-orphans > /dev/null 2>&1 || true + +sudo_cmd systemctl daemon-reload +sudo_cmd systemctl restart fromchat + +sleep 3 +if ! systemctl is-active --quiet fromchat; then + echo "❌ Service failed to start" + sudo_cmd journalctl --no-pager -xeu fromchat -n 30 + exit 1 +fi +""" + + +class DeployTransfer: + def __init__(self, paths: ProjectPaths) -> None: + self._paths = paths + + def ensure_pussh(self) -> None: + if subprocess.run(["docker", "pussh", "--help"], capture_output=True).returncode != 0: + ui.error("docker pussh plugin not installed") + print(" Install: npm run install:pussh (from the backend repo)") + + def ensure_unregistry(self, creds: SshCredentials) -> None: + """Server needs unregistry for pussh; pull locally then transfer if missing.""" + from deploy.util import image_exists_locally + + check = ( + "sudo docker images --format '{{.Repository}}:{{.Tag}}' | " + f"grep -q '^{UNREGISTRY_IMAGE}$'" + ) + if subprocess.run(ssh_argv(creds.server, check), capture_output=True).returncode == 0: + return + import deploy.docker_local as docker_local + + if not image_exists_locally(UNREGISTRY_IMAGE): + docker_local.pull_images([UNREGISTRY_IMAGE]) + ui.substep("Transferring unregistry image to server (one-time setup)…") + if subprocess.run(["docker", "pussh", UNREGISTRY_IMAGE, creds.server]).returncode != 0: + ui.error("Failed to transfer unregistry image to server") + raise SystemExit(1) + + def pussh_images(self, creds: SshCredentials, images: list[str]) -> None: + ui.step("Transferring images to server") + if not images: + ui.success("No images to transfer") + return + self.ensure_unregistry(creds) + for image in images: + ui.substep(f"Transferring {image}...") + if subprocess.run(["docker", "pussh", image, creds.server]).returncode != 0: + ui.error(f"Failed to transfer {image}") + raise SystemExit(1) + print() + + def pull_external_on_server(self, creds: SshCredentials, images: list[str]) -> None: + """Deprecated: classic deploy transfers all images from the local machine.""" + if images: + ui.warning( + "pull_external_on_server is unused; images are built/pulled locally and pussh'd." + ) + + def prepare_remote_dirs(self, creds: SshCredentials, deploy_path: str) -> None: + d_root = shlex.quote(deploy_path) + d_config = shlex.quote(f"{deploy_path}/config") + if creds.sudo_password: + pw = shlex.quote(creds.sudo_password) + script = f"""set -e +echo {pw} | sudo -S -p '' mkdir -p {d_root} {d_config} 2>/dev/null || true +echo {pw} | sudo -S -p '' chown -R $(whoami):$(whoami) {d_root} 2>/dev/null || true +""" + subprocess.run(ssh_argv(creds.server, "bash"), input=script.encode(), capture_output=True) + else: + subprocess.run( + ssh_argv( + creds.server, + f"sudo mkdir -p {d_root} {d_config} && " + f"sudo chown -R $(whoami):$(whoami) {d_root}", + ), + capture_output=True, + ) + + def rsync_staging(self, creds: SshCredentials, deploy_path: str, staging: Path) -> None: + ui.step("Transferring deployment files") + self.prepare_remote_dirs(creds, deploy_path) + ui.substep("Syncing install directory…") + rsync = subprocess.run( + [ + "rsync", + "-avz", + "--delete", + "-e", + "ssh " + " ".join(shlex.quote(o) for o in ssh_common_options()), + "--exclude", + ".env", + "--exclude", + "data/", + "--exclude", + "firebase-cert.json", + "--exclude", + "updater/", + f"{staging}/", + f"{creds.server}:{deploy_path}/", + ], + capture_output=True, + text=True, + ) + if rsync.returncode != 0: + ui.error("Rsync failed. Error output:") + for line in (rsync.stderr or rsync.stdout or "").splitlines(): + print(f" {line}") + raise SystemExit(1) + + def copy_env_prod(self, creds: SshCredentials, deploy_path: str) -> None: + candidates = [] + if self._paths.backend_dir: + candidates.append(self._paths.backend_dir / ".env.prod") + candidates.append(self._paths.deployment_root / ".env.prod") + prod = next((p for p in candidates if p.is_file()), None) + if prod: + ui.substep("Copying .env.prod to .env...") + if ( + subprocess.run( + scp_argv(str(prod), f"{creds.server}:{deploy_path}/.env"), + capture_output=True, + ).returncode + != 0 + ): + ui.warning("Failed to copy .env.prod to .env") + else: + ui.warning(".env.prod not found (looked in backend and deployment roots)") + + def resolve_deploy_path_on_server(self, server: str, deploy_path: str) -> str: + r = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + *ssh_common_options(), + server, + f"eval echo {deploy_path}", + ], + capture_output=True, + text=True, + ) + out = r.stdout.strip() + return out if out else deploy_path + + def firebase_cert_path(self) -> Path: + if self._paths.backend_dir: + return self._paths.backend_dir / "firebase-cert.json" + return self._paths.deployment_root / "firebase-cert.json" + + def cleanup_remote_firebase_dir(self, creds: SshCredentials, deploy_path_resolved: str) -> None: + d = deploy_path_resolved + if creds.sudo_password: + pw = shlex.quote(creds.sudo_password) + script = f"""set -e +D={shlex.quote(d)} +C="$D/firebase-cert.json" +mkdir -p "$D" 2>/dev/null || true +if [ -d "$C" ]; then + echo {pw} | sudo -S -p '' rm -rf "$C" +fi +echo {pw} | sudo -S -p '' chown -R "$(whoami):$(whoami)" "$D" 2>/dev/null || true +""" + subprocess.run(ssh_argv(creds.server, "bash"), input=script.encode(), capture_output=True) + else: + q = shlex.quote(d) + subprocess.run( + ssh_argv( + creds.server, + f"D={q}; C=\"$D/firebase-cert.json\"; mkdir -p \"$D\"; " + f'if [ -d "$C" ]; then sudo rm -rf "$C" 2>/dev/null || rm -rf "$C"; fi; ' + f'sudo chown -R $(whoami):$(whoami) "$D" 2>/dev/null || true', + ), + capture_output=True, + ) + + def _wait_firebase_loop(self, cert: Path) -> None: + while True: + if cert.is_file(): + return + if cert.is_dir(): + print( + f" ⚠ {cert} is a directory. Delete it and save the Firebase " + "service account JSON as a file at that exact path." + ) + elif cert.exists(): + print(f" ⚠ {cert} exists but is not a regular file.") + else: + print(f" ⚠ Missing {cert} (Firebase service account JSON for FCM).") + print(" Fix this, then press Enter to check again (Ctrl+C to abort deploy).") + input() + + def sync_firebase_cert(self, creds: SshCredentials, deploy_path: str) -> str: + ui.substep("Firebase service account (runtime bind-mount: firebase-cert.json)...") + resolved = self.resolve_deploy_path_on_server(creds.server, deploy_path) + self.cleanup_remote_firebase_dir(creds, resolved) + cert = self.firebase_cert_path() + self._wait_firebase_loop(cert) + remote = f"{resolved}/firebase-cert.json" + self.scp_firebase(creds, cert, remote) + return resolved + + def scp_firebase(self, creds: SshCredentials, cert: Path, remote_path: str) -> None: + ui.substep("Copying firebase-cert.json...") + r = subprocess.run( + scp_argv(str(cert), f"{creds.server}:{remote_path}"), + capture_output=True, + text=True, + ) + if r.returncode != 0: + ui.error("Failed to copy firebase-cert.json to server") + print(f" Target: {creds.server}:{remote_path}", file=sys.stderr) + err = (r.stderr or r.stdout or "").strip() + if err: + for line in err.splitlines(): + print(f" {line}", file=sys.stderr) + raise SystemExit(1) + subprocess.run( + ssh_argv(creds.server, f"chmod 600 {shlex.quote(remote_path)}"), + capture_output=True, + ) + + def run_remote_systemd(self, creds: SshCredentials, deploy_path_resolved: str) -> None: + ui.step("Deploying on server") + pw = creds.sudo_password + dp = deploy_path_resolved + remote_cmd = f"SUDO_PASSWORD={shlex.quote(pw)} DEPLOY_PATH={shlex.quote(dp)} bash -s" + r = subprocess.run( + ssh_argv(creds.server, remote_cmd), + input=REMOTE_SYSTEMD_SCRIPT.encode(), + text=False, + ) + if r.returncode != 0: + raise SystemExit(r.returncode) diff --git a/scripts/deploy/ui.py b/scripts/deploy/ui.py new file mode 100644 index 0000000..15fdc2f --- /dev/null +++ b/scripts/deploy/ui.py @@ -0,0 +1,49 @@ +"""Terminal output (Rich), matching previous deploy.sh style.""" + +from __future__ import annotations + +from rich.console import Console +from rich.text import Text + +_console = Console(highlight=False) + +def banner() -> None: + _console.print() + _console.print(Text("🚀 Deployment", style="bold magenta")) + _console.print() + + +def build_banner() -> None: + _console.print() + _console.print(Text("🔨 Building Docker images", style="bold magenta")) + _console.print() + + +def deploy_banner(server: str) -> None: + _console.print() + _console.print(Text(f"🚀 Deploying to {server}", style="bold magenta")) + _console.print() + + +def info(msg: str) -> None: + _console.print(Text("ℹ ", style="blue"), msg, sep="") + + +def success(msg: str) -> None: + _console.print(Text("✓ ", style="green"), msg, sep="") + + +def warning(msg: str) -> None: + _console.print(Text("⚠ ", style="yellow"), msg, sep="") + + +def error(msg: str) -> None: + _console.print(Text("✗ ", style="red"), msg, sep="") + + +def step(msg: str) -> None: + _console.print(Text("→ ", style="bold cyan"), Text(msg, style="bold"), sep="") + + +def substep(msg: str, *, end: str = "\n") -> None: + _console.print(Text(" • ", style="green"), msg, sep="", end=end) diff --git a/scripts/deploy/updater.py b/scripts/deploy/updater.py new file mode 100644 index 0000000..6954b33 --- /dev/null +++ b/scripts/deploy/updater.py @@ -0,0 +1,169 @@ +"""Remote auto-updater install (matches install.sh setup_updater).""" + +from __future__ import annotations + +import getpass +import os +import shlex +import shutil +import subprocess +from pathlib import Path + +from deploy.paths import ProjectPaths +from deploy.ssh_auth import SshCredentials, ssh_argv, ssh_common_options +import deploy.ui as ui + +DEFAULT_BACKEND_REPO = "https://github.com/fromchat-messenger/backend.git" +DEFAULT_WEB_REPO = "https://github.com/fromchat-messenger/web.git" +DEFAULT_APP_REPO = "https://github.com/fromchat-messenger/app.git" +UPDATER_IMAGE = "fromchat/updater:latest" + + +def _repo_urls(paths: ProjectPaths) -> tuple[str, str, str]: + backend = os.environ.get("FROMCHAT_BACKEND_REPO", DEFAULT_BACKEND_REPO) + web = os.environ.get("FROMCHAT_WEB_REPO", DEFAULT_WEB_REPO) + app = os.environ.get("FROMCHAT_APP_REPO", DEFAULT_APP_REPO) + if paths.env_file.is_file(): + for line in paths.env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + if key == "FROMCHAT_BACKEND_REPO" and val: + backend = val + elif key == "FROMCHAT_WEB_REPO" and val: + web = val + elif key == "FROMCHAT_APP_REPO" and val: + app = val + return backend, web, app + + +def resolve_git_token(explicit: str | None) -> str: + if explicit: + return explicit + for key in ("GIT_TOKEN", "GITHUB_TOKEN", "RELEASES_TOKEN"): + val = os.environ.get(key, "").strip() + if val: + return val + ui.step("Git token for the auto-updater (GitHub or Gitea)") + print(" GitHub: https://github.com/settings/tokens/new?scopes=read:packages,repo") + print(" Gitea: Settings → Applications → Generate New Token") + token = getpass.getpass(" Paste token (input hidden): ").strip() + if not token: + ui.error("Git token is required when updater is selected.") + raise SystemExit(1) + return token + + +def _updater_compose_source(paths: ProjectPaths) -> Path: + parent = paths.deployment_root.parent + candidates = [ + paths.deployment_root.parent / "updater" / "compose.yml", + parent / "updater" / "compose.yml", + ] + env_dir = os.environ.get("FROMCHAT_UPDATER_DIR", "") + if env_dir: + candidates.insert(0, Path(env_dir).expanduser() / "compose.yml") + for cand in candidates: + if cand.is_file(): + return cand + ui.error( + "updater/compose.yml not found. Set FROMCHAT_UPDATER_DIR or keep ../updater sibling." + ) + raise SystemExit(1) + + +def write_updater_env( + dest: Path, + *, + token: str, + deploy_path_resolved: str, + components: list[str], + paths: ProjectPaths, +) -> None: + backend, web, app = _repo_urls(paths) + components_csv = ",".join(components) + dest.write_text( + "\n".join( + [ + f"GITHUB_TOKEN={token}", + f"GIT_TOKEN={token}", + f"BACKEND_REPO={backend}", + f"WEB_REPO={web}", + f"DEPLOYMENT_REPO={app}", + f"COMPOSE_PROJECT_DIR={deploy_path_resolved}", + f"FROMCHAT_COMPONENTS={components_csv}", + "CHECK_INTERVAL_SECONDS=60", + "", + ] + ), + encoding="utf-8", + ) + + +def setup_updater_remote( + creds: SshCredentials, + deploy_path: str, + deploy_path_resolved: str, + *, + components: list[str], + paths: ProjectPaths, + git_token: str | None, + sudo_password: str, +) -> None: + ui.step("Setting up auto-updater on server") + token = resolve_git_token(git_token) + + staging = paths.staging_dir / "updater" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + + compose_src = _updater_compose_source(paths) + shutil.copy2(compose_src, staging / "compose.yml") + write_updater_env( + staging / ".env", + token=token, + deploy_path_resolved=deploy_path_resolved, + components=components, + paths=paths, + ) + + remote_updater = f"{deploy_path}/updater" + ui.substep("Syncing updater/ to server…") + subprocess.run( + ssh_argv(creds.server, f"mkdir -p {shlex.quote(remote_updater)}"), + check=True, + capture_output=True, + ) + rsync = subprocess.run( + [ + "rsync", + "-avz", + "-e", + "ssh " + " ".join(shlex.quote(o) for o in ssh_common_options()), + f"{staging}/", + f"{creds.server}:{remote_updater}/", + ], + capture_output=True, + text=True, + ) + if rsync.returncode != 0: + ui.error("Failed to sync updater directory") + for line in (rsync.stderr or rsync.stdout or "").splitlines(): + print(f" {line}") + raise SystemExit(1) + + ui.substep("Starting updater service…") + remote_cmd = ( + f"cd {shlex.quote(remote_updater)} && " + f"COMPOSE_PROJECT_DIR={shlex.quote(deploy_path_resolved)} " + f"docker compose --env-file .env up -d --wait --timeout 120" + ) + if subprocess.run(ssh_argv(creds.server, remote_cmd), capture_output=True).returncode != 0: + ui.error("Failed to start updater on server") + raise SystemExit(1) + + ui.success("Updater service started on server") diff --git a/scripts/deploy/util.py b/scripts/deploy/util.py new file mode 100644 index 0000000..61982ef --- /dev/null +++ b/scripts/deploy/util.py @@ -0,0 +1,99 @@ +"""Small helpers: hashing, dedupe, cache keys.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + + +def sanitize_ref(ref: str) -> str: + s = ref.replace("/", "_").replace(":", "__").replace("@", "__at__") + return s + + +def dedupe_preserve(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for x in items: + if x not in seen: + seen.add(x) + out.append(x) + return out + + +def read_file_if_exists(path: Path) -> str: + if path.is_file(): + return path.read_text(encoding="utf-8", errors="replace") + return "" + + +def local_image_layer_fp(image: str) -> str: + def inspect_layers(ref: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["docker", "image", "inspect", "-f", "{{json .RootFS.Layers}}", ref], + capture_output=True, + text=True, + ) + + p = inspect_layers(image) + if p.returncode != 0: + # Docker Desktop occasionally ends up in a state where repo:tag exists in `docker images` + # but `docker image inspect repo:tag` fails. Inspecting by content-addressed ID works. + id_p = subprocess.run( + ["docker", "images", "--no-trunc", "--format", "{{.ID}}", image], + capture_output=True, + text=True, + ) + image_id = (id_p.stdout or "").strip() + if not image_id: + return "" + p = inspect_layers(image_id) + if p.returncode != 0: + return "" + + return hashlib.sha256(p.stdout.encode()).hexdigest() + + +def compute_inputs_hash( + context: Path, + dockerfile: Path, + *, + hash_script: Path, + python_exe: str | None = None, +) -> str: + exe = python_exe or sys.executable + p = subprocess.run( + [exe, str(hash_script), "--context", str(context), "--dockerfile", str(dockerfile)], + capture_output=True, + text=True, + ) + if p.returncode != 0: + return "" + return p.stdout.strip() + + +def local_docker_image_tags() -> set[str]: + p = subprocess.run( + ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], + capture_output=True, + text=True, + ) + if p.returncode != 0: + return set() + return {line.strip() for line in p.stdout.splitlines() if line.strip()} + + +def image_exists_locally(image: str) -> bool: + return image in local_docker_image_tags() + + +def require_local_images(images: list[str], *, label: str) -> None: + missing = [img for img in dedupe_preserve(images) if not image_exists_locally(img)] + if missing: + raise RuntimeError( + f"{label} not found locally: {', '.join(missing)}\n" + "Build or pull them on this machine first, then deploy offline." + ) + diff --git a/scripts/docker_inputs_hash.py b/scripts/docker_inputs_hash.py new file mode 100644 index 0000000..c1add15 --- /dev/null +++ b/scripts/docker_inputs_hash.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import shlex +import sys +from dataclasses import dataclass +from pathlib import Path + + +def _sha256_bytes(data: bytes) -> str: + h = hashlib.sha256() + h.update(data) + return h.hexdigest() + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +@dataclass(frozen=True) +class DockerIgnoreRule: + pattern: str + negated: bool + anchored: bool + directory_only: bool + + +def _read_dockerignore_rules(context: Path) -> list[DockerIgnoreRule]: + p = context / ".dockerignore" + if not p.exists() or not p.is_file(): + return [] + + rules: list[DockerIgnoreRule] = [] + for raw in _read_text(p).splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + negated = line.startswith("!") + if negated: + line = line[1:].lstrip() + anchored = line.startswith("/") + if anchored: + line = line[1:] + directory_only = line.endswith("/") + if directory_only: + line = line[:-1] + if not line: + continue + rules.append( + DockerIgnoreRule( + pattern=line, + negated=negated, + anchored=anchored, + directory_only=directory_only, + ) + ) + return rules + + +def _dockerignore_matches(rule: DockerIgnoreRule, rel_posix: str, is_dir: bool) -> bool: + if rule.directory_only and not is_dir: + return False + + rel = rel_posix.lstrip("./") + if rule.anchored: + # Anchored to context root. + candidates = [rel] + else: + # Unanchored patterns match anywhere: try both full rel and basename. + base = rel.rsplit("/", 1)[-1] + candidates = [rel, base] + + # Dockerignore supports ** globs; fnmatch handles this well enough for our use. + for c in candidates: + if fnmatch.fnmatch(c, rule.pattern): + return True + # Also allow matching directory prefixes for patterns like "dist" against "foo/dist/bar". + if not rule.anchored and "/" in rel: + if fnmatch.fnmatch(rel, f"*/{rule.pattern}") or fnmatch.fnmatch(rel, f"**/{rule.pattern}"): + return True + return False + + +def _is_ignored_by_dockerignore(rules: list[DockerIgnoreRule], rel_posix: str, is_dir: bool) -> bool: + ignored = False + for r in rules: + if _dockerignore_matches(r, rel_posix=rel_posix, is_dir=is_dir): + ignored = not r.negated + return ignored + + +def _dockerfile_logical_lines(dockerfile_text: str) -> list[str]: + """ + Join backslash-continued lines and drop full-line comments. + """ + out: list[str] = [] + buf: list[str] = [] + for raw in dockerfile_text.splitlines(): + line = raw.rstrip() + if not buf: + stripped = line.lstrip() + if stripped.startswith("#") or stripped == "": + continue + buf.append(line) + if line.endswith("\\"): + buf[-1] = buf[-1][:-1].rstrip() + continue + joined = " ".join(x.strip() for x in buf if x.strip()) + buf = [] + if joined: + out.append(joined) + if buf: + joined = " ".join(x.strip() for x in buf if x.strip()) + if joined: + out.append(joined) + return out + + +@dataclass(frozen=True) +class CopyAdd: + sources: tuple[str, ...] + from_stage: bool + + +def _parse_copy_add_args_shellform(args: list[str]) -> CopyAdd | None: + # flags: --from=, --chown=, --chmod=, --link, --parents, --exclude=... etc. + from_stage = False + rest: list[str] = [] + for a in args: + if a.startswith("--from=") or a == "--from": + from_stage = True + continue + if a.startswith("--"): + continue + rest.append(a) + if len(rest) < 2: + return None + # last is dest + srcs = tuple(rest[:-1]) + return CopyAdd(sources=srcs, from_stage=from_stage) + + +def _parse_copy_add_args_jsonform(json_text: str) -> CopyAdd | None: + try: + arr = json.loads(json_text) + except Exception: + return None + if not isinstance(arr, list) or len(arr) < 2: + return None + # last is dest + srcs = tuple(x for x in arr[:-1] if isinstance(x, str)) + if not srcs: + return None + return CopyAdd(sources=srcs, from_stage=False) + + +def _parse_copy_add(line: str) -> CopyAdd | None: + upper = line.lstrip().upper() + if not (upper.startswith("COPY ") or upper.startswith("ADD ")): + return None + + # Keep original casing for paths. + keyword, rest = line.split(None, 1) + rest = rest.strip() + + # JSON form starts with '[' + if rest.startswith("["): + parsed = _parse_copy_add_args_jsonform(rest) + if parsed: + return parsed + return None + + # shell form + try: + parts = shlex.split(rest, posix=True) + except Exception: + return None + return _parse_copy_add_args_shellform(parts) + + +def _looks_like_remote(src: str) -> bool: + s = src.lower() + return s.startswith("http://") or s.startswith("https://") + + +def _is_glob(p: str) -> bool: + return any(ch in p for ch in ["*", "?", "["]) + + +def _iter_files_under(path: Path) -> list[Path]: + if not path.exists(): + return [] + if path.is_file(): + return [path] + files: list[Path] = [] + for root, _, filenames in os.walk(path): + for name in filenames: + files.append(Path(root) / name) + return files + + +def _collect_sources(context: Path, dockerfile_path: Path) -> list[Path]: + text = _read_text(dockerfile_path) + logical = _dockerfile_logical_lines(text) + dockerignore_rules = _read_dockerignore_rules(context) + + paths: list[Path] = [] + for ln in logical: + parsed = _parse_copy_add(ln) + if not parsed: + continue + if parsed.from_stage: + continue + for src in parsed.sources: + if _looks_like_remote(src): + continue + if src.startswith("/"): + # Absolute COPY sources aren't valid for local context; ignore to avoid surprises. + continue + # Docker allows ".", "./foo", etc. + src_norm = src.lstrip("./") + if src_norm == "": + src_norm = "." + + if _is_glob(src_norm): + # Expand within context + for root, _, filenames in os.walk(context): + root_p = Path(root) + rel_root = root_p.relative_to(context).as_posix() + for fn in filenames: + rel = f"{rel_root}/{fn}" if rel_root != "." else fn + if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=False): + continue + if fnmatch.fnmatch(rel, src_norm) or fnmatch.fnmatch(fn, src_norm): + paths.append(context / rel) + continue + + p = (context / src_norm).resolve() + # Ensure stays within context + try: + p.relative_to(context.resolve()) + except Exception: + continue + for fp in _iter_files_under(p): + try: + rel = fp.resolve().relative_to(context.resolve()).as_posix() + except Exception: + continue + if _is_ignored_by_dockerignore(dockerignore_rules, rel_posix=rel, is_dir=fp.is_dir()): + continue + paths.append(fp) + + # Always include the Dockerfile itself (and preserve stable ordering via sort later) + paths.append(dockerfile_path.resolve()) + return paths + + +def compute_inputs_hash(context: Path, dockerfile_path: Path) -> str: + files = _collect_sources(context=context, dockerfile_path=dockerfile_path) + # Deduplicate by resolved path + uniq: dict[str, Path] = {} + for p in files: + uniq[str(p)] = p + + # Stable sort by path relative to context when possible, else absolute + ctx_resolved = context.resolve() + def sort_key(p: Path) -> str: + try: + return p.resolve().relative_to(ctx_resolved).as_posix() + except Exception: + return p.resolve().as_posix() + + sorted_files = sorted(uniq.values(), key=sort_key) + + h = hashlib.sha256() + for p in sorted_files: + rp: str + try: + rp = p.resolve().relative_to(ctx_resolved).as_posix() + except Exception: + rp = p.resolve().as_posix() + h.update(rp.encode("utf-8", errors="strict")) + h.update(b"\0") + if p.is_file(): + h.update(_sha256_file(p).encode("ascii")) + else: + h.update(b"NONFILE") + h.update(b"\n") + + return h.hexdigest() + + +def compute_inputs_debug(context: Path, dockerfile_path: Path) -> tuple[str, list[tuple[str, str]]]: + """ + Returns (inputs_hash, [(rel_path, sha256_of_file_contents), ...]) with dockerignore applied. + """ + files = _collect_sources(context=context, dockerfile_path=dockerfile_path) + uniq: dict[str, Path] = {} + for p in files: + uniq[str(p.resolve())] = p.resolve() + + ctx_resolved = context.resolve() + + def rel_or_abs(p: Path) -> str: + try: + return p.resolve().relative_to(ctx_resolved).as_posix() + except Exception: + return p.resolve().as_posix() + + sorted_files = sorted(uniq.values(), key=lambda p: rel_or_abs(p)) + + items: list[tuple[str, str]] = [] + for p in sorted_files: + rp = rel_or_abs(p) + if p.is_file(): + items.append((rp, _sha256_file(p))) + else: + items.append((rp, "NONFILE")) + + return compute_inputs_hash(context=context, dockerfile_path=dockerfile_path), items + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--context", required=True, help="Build context directory") + ap.add_argument("--dockerfile", required=True, help="Dockerfile path") + ap.add_argument( + "--debug-list", + action="store_true", + help="Print the included file list (relpath|sha256) to stderr", + ) + args = ap.parse_args() + + context = Path(args.context).resolve() + dockerfile = Path(args.dockerfile).resolve() + + if not context.exists() or not context.is_dir(): + print(f"Context not found or not a directory: {context}", file=sys.stderr) + return 2 + if not dockerfile.exists() or not dockerfile.is_file(): + print(f"Dockerfile not found: {dockerfile}", file=sys.stderr) + return 2 + + if args.debug_list: + h, items = compute_inputs_debug(context=context, dockerfile_path=dockerfile) + for rp, sh in items: + print(f"{rp}|{sh}", file=sys.stderr) + print(h) + else: + print(compute_inputs_hash(context=context, dockerfile_path=dockerfile)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/scripts/generate-compose.py b/scripts/generate-compose.py new file mode 100644 index 0000000..86d1f60 --- /dev/null +++ b/scripts/generate-compose.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Merge backend + frontend (+ optional caddy) compose files for production deploy. +Replaces build: stanzas with image: fromchat/:. +""" +from __future__ import annotations + +import argparse +import copy +import sys +from pathlib import Path +from typing import Any + +import yaml + + +# Services that map to fromchat/* images when they use build: +FROMCHAT_IMAGE_SERVICES = frozenset( + {"main", "messaging", "file_storage", "postgres", "web", "caddy"} +) + +BACKEND_SERVICES = frozenset( + {"main", "messaging", "file_storage", "livekit", "postgres"} +) +FRONTEND_SERVICES = frozenset({"web"}) +CADDY_SERVICES = frozenset({"caddy"}) + +STRIP_KEYS = ("develop",) + + +def load_yaml(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: root must be a mapping") + return data + + +def dump_yaml(data: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + yaml.dump( + data, + fh, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + +def deep_merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]: + out = copy.deepcopy(base) + for key, value in extra.items(): + if key in out and isinstance(out[key], dict) and isinstance(value, dict): + out[key] = deep_merge(out[key], value) + else: + out[key] = copy.deepcopy(value) + return out + + +def strip_dev_keys(service: dict[str, Any]) -> None: + for key in STRIP_KEYS: + service.pop(key, None) + + +def replace_build_with_image( + service_name: str, + service: dict[str, Any], + tag: str, +) -> None: + if "build" not in service: + return + if service_name not in FROMCHAT_IMAGE_SERVICES: + del service["build"] + return + del service["build"] + service["image"] = f"fromchat/{service_name}:{tag}" + + +def patch_livekit_volume(service: dict[str, Any]) -> None: + volumes = service.get("volumes") + if not volumes: + return + patched: list[Any] = [] + for entry in volumes: + if isinstance(entry, str) and "src/livekit/compose.yaml" in entry: + patched.append("./config/livekit.yaml:/etc/livekit.yaml:ro") + else: + patched.append(entry) + service["volumes"] = patched + + +def filter_services( + services: dict[str, Any], + enabled: set[str], +) -> dict[str, Any]: + return {name: cfg for name, cfg in services.items() if name in enabled} + + +def generate( + *, + backend_compose: Path | None, + frontend_compose: Path | None, + caddy_compose: Path | None, + tag: str, + components: set[str], + output: Path, +) -> None: + merged: dict[str, Any] = {"services": {}, "networks": {}, "volumes": {}} + + enabled: set[str] = set() + if "backend" in components: + enabled |= BACKEND_SERVICES + if "frontend" in components: + enabled |= FRONTEND_SERVICES + if "caddy" in components: + enabled |= CADDY_SERVICES + + sources: list[Path] = [] + if backend_compose and backend_compose.is_file(): + sources.append(backend_compose) + if frontend_compose and frontend_compose.is_file(): + sources.append(frontend_compose) + if caddy_compose and caddy_compose.is_file() and "caddy" in components: + sources.append(caddy_compose) + + if not sources: + raise SystemExit("No compose inputs found.") + + for src in sources: + doc = load_yaml(src) + merged = deep_merge(merged, doc) + + services: dict[str, Any] = merged.get("services") or {} + services = filter_services(services, enabled) + + for name, cfg in services.items(): + strip_dev_keys(cfg) + replace_build_with_image(name, cfg, tag) + if name == "livekit": + patch_livekit_volume(cfg) + if name == "caddy": + cfg.setdefault("volumes", []) + vols = cfg["volumes"] + if isinstance(vols, list) and not any("Caddyfile" in str(v) for v in vols): + vols.append("./Caddyfile:/etc/caddy/Caddyfile:ro") + cfg.setdefault("networks", ["public"]) + cfg.setdefault("depends_on", ["web"]) + cfg.setdefault("restart", "unless-stopped") + cfg.setdefault("ports", ["80:80", "443:443"]) + + if "caddy" in components and "web" in services: + web = services["web"] + ports = web.get("ports") + if isinstance(ports, list): + web["ports"] = [p for p in ports if not str(p).startswith("8301:")] + + merged["services"] = services + if not merged.get("networks"): + merged.pop("networks", None) + if not merged.get("volumes"): + merged.pop("volumes", None) + + dump_yaml(merged, output) + print(f"Wrote {output} ({len(services)} services, tag {tag})", file=sys.stderr) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate FromChat production compose.yml") + parser.add_argument("--tag", required=True, help="Image tag, e.g. v1.0.0") + parser.add_argument( + "--components", + required=True, + help="Comma-separated: backend,frontend,caddy", + ) + parser.add_argument("--backend-compose", type=Path, default=None) + parser.add_argument("--frontend-compose", type=Path, default=None) + parser.add_argument("--caddy-compose", type=Path, default=None) + parser.add_argument("--output", type=Path, default=Path("compose.yml")) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + components = {c.strip().lower() for c in args.components.split(",") if c.strip()} + generate( + backend_compose=args.backend_compose, + frontend_compose=args.frontend_compose, + caddy_compose=args.caddy_compose, + tag=args.tag, + components=components, + output=args.output, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/git_remote.py b/scripts/git_remote.py new file mode 100644 index 0000000..26cd0c9 --- /dev/null +++ b/scripts/git_remote.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +GitHub + Gitea (git.fromchat.ru) helpers for FromChat deployment/updater. + +Official GitHub: github.com/fromchat-messenger/{backend,web,app} +Gitea: git.fromchat.ru/FromChat/{same repo name} + +Official GitHub URLs fall back to Gitea when GitHub fails. +URLs already on git.fromchat.ru use Gitea APIs directly. +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +SEMVER = re.compile(r"^v(\d+)\.(\d+)(?:\.(\d+))?$") + +GITHUB_ORG = "fromchat-messenger" +GITEA_ORG = "FromChat" +GITHUB_OFFICIAL_PREFIX = f"https://github.com/{GITHUB_ORG}/" +GITEA_BASE = "https://git.fromchat.ru" + +OFFICIAL_GITHUB_REPOS = frozenset( + { + f"https://github.com/{GITHUB_ORG}/backend.git", + f"https://github.com/{GITHUB_ORG}/web.git", + f"https://github.com/{GITHUB_ORG}/app.git", + } +) + + +def normalize_repo_url(url: str) -> str: + u = url.rstrip("/") + if u.endswith(".git"): + return u + return u + ".git" + + +def is_gitea_repo(url: str) -> bool: + return "git.fromchat.ru" in normalize_repo_url(url) + + +def is_official_github_repo(url: str) -> bool: + normalized = normalize_repo_url(url) + return normalized in OFFICIAL_GITHUB_REPOS or GITHUB_OFFICIAL_PREFIX in normalized + + +def gitea_owner_repo(github_owner: str, repo: str) -> tuple[str, str]: + """Map github.com/fromchat-messenger/{repo} -> git.fromchat.ru/FromChat/{repo}.""" + if github_owner == GITEA_ORG: + return github_owner, repo + if github_owner == GITHUB_ORG or is_official_github_repo( + f"https://github.com/{github_owner}/{repo}.git" + ): + return GITEA_ORG, repo + return github_owner, repo + + +def parse_slug(url: str) -> tuple[str, str]: + u = url.rstrip("/").removesuffix(".git") + for marker in ("github.com/", "git.fromchat.ru/"): + if marker in u: + slug = u.split(marker, 1)[1] + owner, repo = slug.split("/", 1) + return owner, repo + if "/" in u and "://" not in u: + owner, repo = u.split("/", 1) + return owner, repo + raise ValueError(f"Cannot parse repository slug from {url!r}") + + +def _auth_headers(token: str | None, *, gitea: bool = False) -> dict[str, str]: + headers = {"User-Agent": "fromchat-git-remote"} + if not token: + return headers + if gitea: + # Gitea accepts both schemes; token is the documented form. + headers["Authorization"] = f"token {token}" + else: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _http_get(url: str, token: str | None, *, gitea: bool = False) -> bytes: + req = urllib.request.Request( + url, + headers={ + **_auth_headers(token, gitea=gitea), + **({"Accept": "application/vnd.github+json"} if not gitea else {}), + }, + ) + with urllib.request.urlopen(req, timeout=45) as resp: + return resp.read() + + +def github_raw_url(owner: str, repo: str, ref: str, path: str) -> str: + return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path.lstrip('/')}" + + +def gitea_api_raw_url(gitea_owner: str, gitea_repo: str, ref: str, path: str) -> str: + """Gitea API raw file (supports branch/tag/commit via ?ref=).""" + filepath = urllib.parse.quote(path.lstrip("/"), safe="/") + ref_q = urllib.parse.quote(ref, safe="") + return ( + f"{GITEA_BASE}/api/v1/repos/{gitea_owner}/{gitea_repo}/raw/{filepath}?ref={ref_q}" + ) + + +def gitea_web_raw_urls(gitea_owner: str, gitea_repo: str, ref: str, path: str) -> list[str]: + """Legacy/browser raw paths (tried if API raw fails).""" + p = path.lstrip("/") + ref_q = urllib.parse.quote(ref, safe="") + return [ + f"{GITEA_BASE}/{gitea_owner}/{gitea_repo}/raw/{ref_q}/{p}", + f"{GITEA_BASE}/{gitea_owner}/{gitea_repo}/raw/tag/{ref_q}/{p}", + ] + + +def fetch_gitea_raw( + gitea_owner: str, + gitea_repo: str, + ref: str, + path: str, + token: str | None, +) -> bytes: + urls = [gitea_api_raw_url(gitea_owner, gitea_repo, ref, path)] + urls.extend(gitea_web_raw_urls(gitea_owner, gitea_repo, ref, path)) + + last_error: Exception | None = None + for url in urls: + try: + return _http_get(url, token, gitea=True) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc: + last_error = exc + raise RuntimeError( + f"Failed to fetch {path} from git.fromchat.ru/{gitea_owner}/{gitea_repo}@{ref}" + ) from last_error + + +def fetch_raw_file(repo_url: str, ref: str, path: str, token: str | None = None) -> bytes: + owner, repo = parse_slug(repo_url) + + if is_gitea_repo(repo_url): + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + return fetch_gitea_raw(gitea_owner, gitea_repo, ref, path, token) + + try: + return _http_get(github_raw_url(owner, repo, ref, path), token, gitea=False) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as github_err: + if not is_official_github_repo(repo_url): + raise RuntimeError( + f"Failed to fetch {path} from {repo_url}@{ref}" + ) from github_err + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + print( + f"GitHub unavailable for {owner}/{repo}, " + f"trying git.fromchat.ru/{gitea_owner}/{gitea_repo}…", + file=sys.stderr, + ) + return fetch_gitea_raw(gitea_owner, gitea_repo, ref, path, token) + + +def _tags_from_github(owner: str, repo: str, token: str | None) -> set[str]: + data = json.loads( + _http_get( + f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", + token, + gitea=False, + ).decode() + ) + return {item["name"] for item in data if SEMVER.fullmatch(item.get("name", ""))} + + +def _gitea_paginated_json(path: str, token: str | None) -> list[dict]: + items: list[dict] = [] + page = 1 + while True: + sep = "&" if "?" in path else "?" + url = f"{GITEA_BASE}{path}{sep}page={page}&limit=50" + chunk = json.loads(_http_get(url, token, gitea=True).decode()) + if not isinstance(chunk, list): + break + if not chunk: + break + items.extend(chunk) + if len(chunk) < 50: + break + page += 1 + return items + + +def _tags_from_gitea(gitea_owner: str, gitea_repo: str, token: str | None) -> set[str]: + out: set[str] = set() + + for item in _gitea_paginated_json( + f"/api/v1/repos/{gitea_owner}/{gitea_repo}/tags", + token, + ): + name = item.get("name", "") + if SEMVER.fullmatch(name): + out.add(name) + + if out: + return out + + # Fallback: release tags + for item in _gitea_paginated_json( + f"/api/v1/repos/{gitea_owner}/{gitea_repo}/releases", + token, + ): + name = item.get("tag_name") or item.get("tag") or "" + if SEMVER.fullmatch(name): + out.add(name) + + return out + + +def fetch_semver_tags(repo_url: str, token: str | None = None) -> set[str]: + owner, repo = parse_slug(repo_url) + + if is_gitea_repo(repo_url): + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + return _tags_from_gitea(gitea_owner, gitea_repo, token) + + try: + return _tags_from_github(owner, repo, token) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError): + if not is_official_github_repo(repo_url): + raise + gitea_owner, gitea_repo = gitea_owner_repo(owner, repo) + print( + f"GitHub tags unavailable for {owner}/{repo}, " + f"trying git.fromchat.ru/{gitea_owner}/{gitea_repo}…", + file=sys.stderr, + ) + return _tags_from_gitea(gitea_owner, gitea_repo, token) + + +def semver_key(tag: str) -> tuple[int, ...]: + m = SEMVER.match(tag) + if not m: + raise ValueError(tag) + return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)) + + +def resolve_common_tag(repo_urls: list[str], token: str | None = None) -> str: + sets = [fetch_semver_tags(url, token) for url in repo_urls] + common = set.intersection(*sets) if sets else set() + if not common: + raise RuntimeError("No common semver tag (vX.Y or vX.Y.Z) found across repos.") + return max(common, key=semver_key) + + +def _image_tag_matches(needle: str, tag: str, candidates: list[str]) -> bool: + n = needle.lstrip("v") + t = tag.lstrip("v") + for c in candidates: + if not c: + continue + c = str(c) + if c in (tag, needle, t, n, f"v{n}"): + return True + return False + + +def package_exists_github(github_owner: str, package: str, tag: str, token: str) -> bool: + short = package.split("/", 1)[-1] + url = ( + f"https://api.github.com/users/{github_owner}/packages/container/{short}/versions" + "?per_page=20" + ) + try: + data = json.loads(_http_get(url, token, gitea=False).decode()) + except (urllib.error.HTTPError, urllib.error.URLError): + return False + if not isinstance(data, list): + return False + for version in data: + meta = version.get("metadata", {}).get("container", {}) + tags = meta.get("tags") or [] + names = version.get("name") or version.get("version") or "" + if _image_tag_matches(tag, tag, list(tags) + [names]): + return True + return False + + +def package_exists_gitea(gitea_owner: str, package: str, tag: str, token: str) -> bool: + short = package.split("/", 1)[-1] + path = f"/api/v1/packages/{gitea_owner}/container/{short}/versions" + try: + versions = _gitea_paginated_json(path, token) + except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError): + return False + + for version in versions: + names: list[str] = [] + for key in ("version", "name"): + val = version.get(key) + if isinstance(val, str): + names.append(val) + metadata = version.get("metadata") or {} + if isinstance(metadata, dict): + container = metadata.get("container") or {} + if isinstance(container, dict): + names.extend(str(t) for t in (container.get("tags") or [])) + tags_field = metadata.get("tags") + if isinstance(tags_field, list): + names.extend(str(t) for t in tags_field) + if _image_tag_matches(tag, tag, names): + return True + return False + + +def package_version_exists( + github_owner: str, + package: str, + tag: str, + token: str, + *, + official: bool = True, +) -> bool: + gitea_owner, _ = gitea_owner_repo(github_owner, package.split("/", 1)[-1]) + + if github_owner == GITEA_ORG: + return package_exists_gitea(gitea_owner, package, tag, token) + + if package_exists_github(github_owner, package, tag, token): + return True + if not official: + return False + if package_exists_gitea(gitea_owner, package, tag, token): + print( + f"GitHub packages unavailable for {package}, confirmed on git.fromchat.ru", + file=sys.stderr, + ) + return True + return False + + +def resolve_git_token() -> str | None: + import os + + return os.environ.get("GIT_TOKEN") or os.environ.get("GITHUB_TOKEN") or None + + +def main() -> None: + if len(sys.argv) < 2: + sys.exit( + "usage: git_remote.py fetch-raw REPO REF PATH [TOKEN] | " + "resolve-tag [TOKEN] REPO..." + ) + + cmd = sys.argv[1] + args = sys.argv[2:] + token: str | None = None + if args and not args[0].startswith("http"): + token = args[0] + args = args[1:] + token = token or resolve_git_token() + + if cmd == "fetch-raw": + repo, ref, path = args[:3] + sys.stdout.buffer.write(fetch_raw_file(repo, ref, path, token)) + return + + if cmd == "resolve-tag": + print(resolve_common_tag(args, token)) + return + + sys.exit(f"Unknown command: {cmd}") + + +if __name__ == "__main__": + main() diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..470fc48 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Shared helpers for FromChat deployment installer. + +set -euo pipefail + +NC=$'\033[0m' +GRAY=$'\033[38;5;245m' +BLUE=$'\033[38;5;81m' +PURPLE=$'\033[38;5;141m' +RED=$'\033[38;5;203m' +LIME=$'\033[38;5;154m' +ORANGE=$'\033[38;5;208m' +YELLOW=$'\033[38;5;226m' +CHECK=$'\033[38;5;154m' +CROSS=$'\033[38;5;203m' + +info() { printf '%b%s%b\n' "$BLUE" "$*" "$NC"; } +success() { printf '%b✓ %s%b\n' "$LIME" "$*" "$NC"; } +warn() { printf '%b⚠ %s%b\n' "$YELLOW" "$*" "$NC"; } +error() { printf '%b✗ %s%b\n' "$RED" "$*" "$NC" >&2; } +step() { printf '\n%b▸ %s%b\n' "$PURPLE" "$*" "$NC"; } +prompt() { printf '%b%s%b ' "$ORANGE" "$*" "$NC" >&2; } + +die() { + error "$1" + exit "${2:-1}" +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + info "Elevating privileges with sudo…" + exec sudo -E bash "$0" "$@" + fi +} + +is_debian_based() { + [[ -f /etc/debian_version ]] || grep -qiE 'ubuntu|debian|mint|pop|elementary|raspbian' /etc/os-release 2>/dev/null +} + +require_debian() { + if ! is_debian_based; then + die "This installer supports Debian-based Linux only (Debian, Ubuntu, etc.)." + fi + success "Debian-based OS detected" +} + +docker_installed() { + command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 +} + +install_docker_if_needed() { + if docker_installed; then + success "Docker is already installed" + return 0 + fi + + warn "Docker is not installed or not running." + prompt "Install Docker using the official script? [Y/n]:" + local answer + IFS= read -r answer || true + answer="${answer:-Y}" + if [[ ! "${answer}" =~ ^[Yy] ]]; then + die "Docker is required. Install it manually: https://docs.docker.com/engine/install/" + fi + + step "Installing Docker…" + curl -fsSL https://get.docker.com | sh + systemctl enable --now docker 2>/dev/null || true + success "Docker installed" +} + +ensure_python_yaml() { + python3 - <<'PY' >/dev/null 2>&1 && return 0 +import yaml # noqa: F401 +PY + step "Installing python3-yaml…" + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-yaml +} + +ensure_compose_plugin() { + if docker compose version >/dev/null 2>&1; then + success "Docker Compose plugin available" + return 0 + fi + step "Installing docker-compose-plugin…" + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin +} + +repo_slug_from_url() { + local url="$1" + url="${url%.git}" + for prefix in "github.com/" "git.fromchat.ru/" "https://" "http://"; do + url="${url#*${prefix}}" + done + echo "${url%%/*}/${url##*/}" +} + +fetch_raw_file() { + local repo_url="$1" + local tag="$2" + local path="$3" + local dest="$4" + local script="${DEPLOYMENT_ROOT}/scripts/git_remote.py" + local token="${GIT_TOKEN:-${GITHUB_TOKEN:-}}" + if [[ -n "${token}" ]]; then + python3 "${script}" fetch-raw "${repo_url}" "${tag}" "${path}" "${token}" > "${dest}" + else + python3 "${script}" fetch-raw "${repo_url}" "${tag}" "${path}" > "${dest}" + fi +} + +get_latest_semver_tag() { + local repo_url="$1" + local token="${GIT_TOKEN:-${GITHUB_TOKEN:-}}" + if [[ -n "${token}" ]]; then + python3 "${DEPLOYMENT_ROOT}/scripts/resolve-tag.py" "${token}" "${repo_url}" + else + python3 "${DEPLOYMENT_ROOT}/scripts/resolve-tag.py" "${repo_url}" + fi +} + +select_components() { + if command -v whiptail >/dev/null 2>&1; then + local result + result="$(whiptail --title "FromChat components" --checklist \ + "Select components (Space toggles, Enter confirms)" 18 72 4 \ + backend "Backend (API, DB, LiveKit)" ON \ + frontend "Web frontend" ON \ + caddy "Caddy reverse proxy (TLS)" OFF \ + updater "Auto-update service" OFF \ + 3>&1 1>&2 2>&3)" || die "Component selection cancelled." + SELECTED=() + local item + for item in ${result}; do + SELECTED+=("${item//\"/}") + done + ((${#SELECTED[@]} > 0)) || die "Select at least one component." + return 0 + fi + + warn "whiptail not found; using text menu." + local -a options=(backend frontend caddy updater) + local -a labels=( + "Backend (API, DB, LiveKit)" + "Web frontend" + "Caddy reverse proxy" + "Auto-update service" + ) + SELECTED=() + local i choice + for i in "${!options[@]}"; do + prompt "Include ${labels[$i]}? [y/N]:" + IFS= read -r choice || true + if [[ "${choice:-}" =~ ^[Yy] ]]; then + SELECTED+=("${options[$i]}") + fi + done + ((${#SELECTED[@]} > 0)) || die "Select at least one component." +} + +component_selected() { + local needle="$1" + local item + for item in "${SELECTED[@]:-}"; do + [[ "${item}" == "${needle}" ]] && return 0 + done + return 1 +} + +wait_for_compose_healthy() { + local dir="$1" + local timeout="${2:-600}" + step "Waiting for services to become healthy (timeout ${timeout}s)…" + ( + cd "${dir}" + docker compose up -d --wait --timeout "${timeout}" + ) +} + +print_success_banner() { + local dir="$1" + printf '\n%b╔══════════════════════════════════════════════════╗%b\n' "$LIME" "$NC" + printf '%b║ FromChat server installed successfully! ║%b\n' "$LIME" "$NC" + printf '%b╚══════════════════════════════════════════════════╝%b\n\n' "$LIME" "$NC" + info "Install directory: ${dir}" + info "Version tag: ${FROMCHAT_VERSION}" + info "Manage stack: cd ${dir} && docker compose ps" + info "View logs: cd ${dir} && docker compose logs -f" + if component_selected caddy; then + info "Caddy config: ${dir}/Caddyfile" + fi +} + +# Merge component compose files into ${install_dir}/compose.yml (image: fromchat/*:). +# Optional local compose paths (skip remote fetch when set): +# LOCAL_BACKEND_COMPOSE, LOCAL_FRONTEND_COMPOSE +# Requires: DEPLOYMENT_ROOT, BACKEND_REPO, WEB_REPO (for remote fetch). +run_generate_compose() { + local install_dir="$1" + local components_csv="$2" + local tag="$3" + local tmp + tmp="$(mktemp -d)" + + step "Generating compose.yml for tag ${tag} (components: ${components_csv})…" + + local need_backend=false need_frontend=false + [[ ",${components_csv}," == *,backend,* ]] && need_backend=true + [[ ",${components_csv}," == *,frontend,* ]] && need_frontend=true + + if ${need_backend}; then + if [[ -n "${LOCAL_BACKEND_COMPOSE:-}" && -f "${LOCAL_BACKEND_COMPOSE}" ]]; then + cp -f "${LOCAL_BACKEND_COMPOSE}" "${tmp}/backend.compose.yml" + success "Backend compose.yml (local)" + else + fetch_raw_file "${BACKEND_REPO}" "${tag}" "compose.yml" \ + "${tmp}/backend.compose.yml" + success "Backend compose.yml" + fi + fi + if ${need_frontend}; then + if [[ -n "${LOCAL_FRONTEND_COMPOSE:-}" && -f "${LOCAL_FRONTEND_COMPOSE}" ]]; then + cp -f "${LOCAL_FRONTEND_COMPOSE}" "${tmp}/frontend.compose.yml" + success "Frontend compose.yml (local)" + else + fetch_raw_file "${WEB_REPO}" "${tag}" "compose.yml" \ + "${tmp}/frontend.compose.yml" + success "Frontend compose.yml" + fi + fi + + mkdir -p "${install_dir}/config" + cp -f "${DEPLOYMENT_ROOT}/config/livekit.yaml" "${install_dir}/config/livekit.yaml" + + local -a gen_args=( + python3 "${DEPLOYMENT_ROOT}/scripts/generate-compose.py" + --tag "${tag}" + --components "${components_csv}" + --output "${install_dir}/compose.yml" + ) + if ${need_backend}; then + gen_args+=(--backend-compose "${tmp}/backend.compose.yml") + fi + if ${need_frontend}; then + gen_args+=(--frontend-compose "${tmp}/frontend.compose.yml") + fi + if [[ ",${components_csv}," == *,caddy,* ]]; then + gen_args+=(--caddy-compose "${DEPLOYMENT_ROOT}/compose/caddy.compose.yml") + fi + "${gen_args[@]}" + + echo "${tag}" > "${install_dir}/.fromchat-version" + rm -rf "${tmp}" +} diff --git a/scripts/resolve-tag.py b/scripts/resolve-tag.py new file mode 100644 index 0000000..67e2b4e --- /dev/null +++ b/scripts/resolve-tag.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Pick the latest semver tag present in all given repos (GitHub, Gitea fallback).""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from git_remote import resolve_common_tag # noqa: E402 + + +def main() -> None: + args = sys.argv[1:] + token: str | None = None + if args and not args[0].startswith("http"): + token = args[0] + args = args[1:] + token = token or resolve_git_token() + if not args: + sys.exit("usage: resolve-tag.py [TOKEN] REPO_URL ...") + print(resolve_common_tag(args, token)) + + +if __name__ == "__main__": + main() diff --git a/templates/Caddyfile b/templates/Caddyfile new file mode 100644 index 0000000..22f9dd0 --- /dev/null +++ b/templates/Caddyfile @@ -0,0 +1,32 @@ +{ + servers { + listener_wrappers { + proxy_protocol + tls + } + trusted_proxies static 127.0.0.1/32 ::1/128 + } + http_port 8080 + https_port 8443 +} + +# Replace example.com with your domain. Requires web service on the same compose network. +example.com { + reverse_proxy web:80 { + header_up X-Real-IP {remote_host} + } + + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + } +} + +# API on same host (optional second site block) +api.example.com { + reverse_proxy main:8300 { + header_up X-Real-IP {remote_host} + } +} diff --git a/templates/fromchat.service b/templates/fromchat.service new file mode 100644 index 0000000..00c9125 --- /dev/null +++ b/templates/fromchat.service @@ -0,0 +1,33 @@ +[Unit] +Description=FromChat server +After=multi-user.target +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=3 + +[Service] +Type=simple +User=root +Group=root +ExecStart=/bin/bash -c "docker compose up --remove-orphans --force-recreate" +ExecStop=/bin/bash -c "docker compose down --remove-orphans" +WorkingDirectory=/home/fromchat/fromchat-server +Restart=always +RestartSec=10 +StartLimitBurst=3 + +# Security settings +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/log + +# Logging +StandardOutput=journal +StandardError=journal +StandardInput=tty-force +SyslogIdentifier=fromchat + +[Install] +WantedBy=multi-user.target