diff --git a/.wiki/concepts/portainer-stack-management-vds.md b/.wiki/concepts/portainer-stack-management-vds.md new file mode 100644 index 0000000..dc4ad39 --- /dev/null +++ b/.wiki/concepts/portainer-stack-management-vds.md @@ -0,0 +1,119 @@ +--- +title: Portainer stack management on vds-kzntsv — canonical pattern +status: live +tags: [vds, portainer, docker-compose, ops] +related: [[vds-kzntsv]], [[mssql-on-vds]], [[minio-imgproxy-on-vds]] +--- + +# Portainer stack management on vds-kzntsv + +**Canonical rule:** all docker-compose stacks на VDS управляются через Portainer (`https://portainer.vds.kzntsv.site`). Ad-hoc `docker compose up -d` через ssh — **anti-pattern**, ломает гомогенность ops surface (не видно в UI, нет audit, нет one-click revert). + +2026-05-22 retro-migrated 12 ранее ad-hoc стеков (board-viewer, ntfy, registry, verdaccio, gitea, owncloud, postgres, mongo, mariadb, redis, minio-imgproxy, mssql) в Portainer-managed. Skipped `traefik` + `portainer` (management plane — recreate ломает access). + +## Portainer API auth + +Portainer API token из `vds-kzntsv/full-env` `PORTAINER_API_KEY` ранее давал 401 (см. owncloud-vds-deploy follow-up; требует regen через UI). Workaround — JWT через admin password: + +```bash +JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth \ + -H "Content-Type: application/json" \ + -d '{"username":"vitya","password":"Pryakhin9-VDS-2026"}' | jq -r .jwt) +``` + +Pass-store: `pass show vds-kzntsv/full-env` (full env file со всеми creds). + +## Migration script (ad-hoc → Portainer-managed) + +```bash +#!/bin/bash +# /tmp/portainer-migrate.sh — runs on VDS +set -e +STACK="$1" # e.g. "mssql" +DIR="$2" # e.g. "/opt/stacks/databases/mssql" +PORTAINER_URL="https://portainer.vds.kzntsv.site" + +JWT=$(curl -ksS -X POST "$PORTAINER_URL/api/auth" -H "Content-Type: application/json" \ + -d '{"username":"vitya","password":""}' | jq -r .jwt) + +# Transform compose: strip env_file directives (Portainer передаёт env через API array), +# absolutize ./ paths (Portainer-managed working_dir = /data/compose//, не stack dir) +COMPOSE=$(sudo cat "$DIR/docker-compose.yml" | sed \ + -e '/^\s*env_file:/d' \ + -e '/^\s*-\s*\.env\s*$/d' \ + -e "s|\\./|$DIR/|g") + +# Parse .env into array +ENV_ARRAY="[]" +if sudo test -f "$DIR/.env"; then + ENV_ARRAY=$(sudo cat "$DIR/.env" | grep -vE '^\s*(#|$)' | jq -Rs ' + split("\n") | map(select(length>0) | split("=") | {name: .[0], value: (.[1:] | join("="))})') +fi + +PAYLOAD=$(jq -n --arg name "$STACK" --arg compose "$COMPOSE" --argjson env "$ENV_ARRAY" \ + '{name:$name, stackFileContent:$compose, env:$env, fromAppTemplate:false}') + +# Cleanup existing Portainer stack with same name (idempotency) +EXISTING_ID=$(curl -ksS -H "Authorization: Bearer $JWT" "$PORTAINER_URL/api/stacks" | \ + jq -r ".[] | select(.Name==\"$STACK\") | .Id" | head -1) +[ -n "$EXISTING_ID" ] && curl -ksS -X DELETE "$PORTAINER_URL/api/stacks/$EXISTING_ID?endpointId=1" -H "Authorization: Bearer $JWT" >/dev/null + +# Down ad-hoc compose +sudo docker ps -aq --filter "label=com.docker.compose.project=$STACK" | grep -q . && \ + (cd "$DIR" && sudo docker compose down 2>&1 | tail -2) + +# Create via Portainer +curl -ksS -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=1" \ + -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" --data "$PAYLOAD" +``` + +## Gotchas + +1. **`env_file: .env` ломает Portainer string-mode** — Portainer не материализует `.env` файл в `/data/compose//`. Compose pull fails: `env file /data/compose//.env not found`. Fix: `sed '/^\s*env_file:/d'` + передавать env через API `env` array. +2. **Sibling files (`nginx.conf`, certs, scripts) не uploadятся** — string-mode не подхватывает sibling files. `./nginx.conf:/etc/nginx/conf.d/default.conf` Portainer resolves в `/data/compose//nginx.conf` где файла нет → container mount fails. Fix: абсолютизировать bind через sed `s|\./|$DIR/|g`. Файлы остаются на disk в `/opt/stacks//`, Portainer лишь управляет lifecycle. +3. **registry auth для image pull** — VDS docker должен быть `docker login registry.kzntsv.site` до `docker compose pull`. Иначе `pull access denied`. Login persistent в `/root/.docker/config.json` после первого раза. +4. **Container recreate downtime** — Portainer up-d убивает существующий контейнер, создаёт новый. Для prod stacks (mssql, owncloud, gitea) ~30-60s простой. Connections retry прозрачно если клиент resilient (IIS reconnects). +5. **Traefik labels propagate автоматически** — Portainer-managed контейнеры получают те же `traefik.*` labels из compose, traefik docker provider их видит через socket event. Никаких отдельных шагов не нужно. +6. **`com.docker.compose.project.working_dir`** меняется с `/opt/stacks/` (ad-hoc) на `/data/compose/` (Portainer-managed). Сохранённые `/opt/stacks//.env` остаются на disk — лишь для backup/reference, не используются compose runtime. +7. **Volumes preserved across migration** — named volumes (`docker compose down` без `-v`) и binds (`/opt/stacks//data`) сохраняются. Data layer не теряется при ad-hoc→Portainer переезде. +8. **Management plane skip** — `traefik` и `portainer` сами через себя нельзя безопасно recreate (теряется access). Оставлены ad-hoc; их docker-compose.yml в `/opt/stacks/{traefik,portainer}/`. Future: bootstrap script для cold-start ставит их first перед всем остальным. + +## Stack inventory (2026-05-22, Portainer Ids) + +| Id | Name | Dir | Note | +|---|---|---|---| +| 3 | board-viewer | `/opt/stacks/board-viewer` | basicauth Traefik | +| 4 | ntfy | `/opt/stacks/ntfy` | | +| 5 | registry | `/opt/stacks/registry` | + registry-ui | +| 6 | verdaccio | `/opt/stacks/verdaccio` | npm registry | +| 7 | gitea | `/opt/stacks/gitea` | git.kzntsv.site | +| 8 | owncloud | `/opt/stacks/owncloud` | oCIS 7.1, mem_limit 1G | +| 9 | postgres | `/opt/stacks/databases/postgres` | TLS via traefik raw-TCP | +| 10 | mongo | `/opt/stacks/databases/mongo` | TLS via traefik raw-TCP | +| 11 | mariadb | `/opt/stacks/databases/mariadb` | TLS via traefik raw-TCP | +| 12 | redis | `/opt/stacks/databases/redis` | TLS via traefik raw-TCP | +| 13 | minio-imgproxy | `/opt/stacks/storage/minio-imgproxy` | MinIO 2025-09 + imgproxy + nginx | +| 14 | mssql | `/opt/stacks/databases/mssql` | Express 2022, traefik TCP :1433 | + +Non-Portainer (management plane): +- `traefik` — `/opt/stacks/traefik/`, ad-hoc compose +- `portainer` — `/opt/stacks/portainer/`, ad-hoc compose +- `vds-docker-proxy-ro` + `vds-ops-mcp` — ad-hoc из `/tmp` (часть synology/vds-ops setup) + +## Smoke post-migration + +```bash +JWT=$(curl -ksS -X POST https://portainer.vds.kzntsv.site/api/auth -H "Content-Type: application/json" -d '{"username":"vitya","password":"..."}' | jq -r .jwt) + +# All 12 stacks visible in Portainer +curl -ksS -H "Authorization: Bearer $JWT" https://portainer.vds.kzntsv.site/api/stacks | jq -r '.[].Name' + +# Critical service smokes +curl -ksS -o /dev/null -w "%{http_code}\n" https://git.kzntsv.site/ +curl -ksS -o /dev/null -w "%{http_code}\n" https://owncloud.kzntsv.site/ +curl -ksS -u "viewer:..." https://board.kzntsv.site/ -o /dev/null -w "%{http_code}\n" +# MSSQL external (via PowerShell since git-bash mangles path): +docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S 'mssql.kzntsv.site,1433' -U snolla -P '...' -d MoreThenCms -C -No -Q "SELECT 1" +``` + +Все 8 IIS prod hosts (emspb / snolla / on.snolla / pilorama98 / labtools.{ru,pro} / tandemmebel / kupimknigi) → 200 через VDS MSSQL после migration. diff --git a/.wiki/index.md b/.wiki/index.md index 310d8a1..0ed0230 100644 --- a/.wiki/index.md +++ b/.wiki/index.md @@ -29,6 +29,7 @@ Catalog of all wiki pages. One line per page, organized by type. Updated on ever - [mssql-on-vds](concepts/mssql-on-vds.md) — MSSQL Express 2022 Linux на VDS — миграция + login orphan fix + traefik TCP gotchas - [ocis-on-vds-deploy-recipe](concepts/ocis-on-vds-deploy-recipe.md) — oCIS на VDS — deploy recipe + non-obvious gotchas (UID 1001 vs 1000, basic auth, LibreGraph user-create) - [portainer-2.21-admin-password-regression](concepts/portainer-2.21-admin-password-regression.md) — Portainer 2.21 `--admin-password` regression + min 12-char policy +- [portainer-stack-management-vds](concepts/portainer-stack-management-vds.md) — Portainer-managed stacks на VDS — canonical pattern + migration script + gotchas - [recovery-architecture-snapshot](concepts/recovery-architecture-snapshot.md) — текущая recovery architecture (2026-05-19/21, attempt 2) - [registry-gc-mount-and-modify-flag](concepts/registry-gc-mount-and-modify-flag.md) — Docker Registry GC mount layout + `-m` flag - [rusonyx-vps-onboarding-quirks](concepts/rusonyx-vps-onboarding-quirks.md) — Rusonyx VPS onboarding quirks (Astra Облако / myvm.rusonyx.ru) diff --git a/CLAUDE.md b/CLAUDE.md index 4275136..1b9f57f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,3 +11,9 @@ follow project discipline delegate to interns when allowed recommend, don't menu we're on Windows + +# VDS ops rule + +Все docker-compose stacks на VDS управляются через Portainer (`https://portainer.vds.kzntsv.site`). +`ssh + docker compose up -d` на VDS — anti-pattern. См. [`portainer-stack-management-vds`](.wiki/concepts/portainer-stack-management-vds.md) для migration script + gotchas. +Исключения: `traefik` + `portainer` (management plane, ad-hoc compose).