# План-инструкция: sless_job функции для установки ПО в ВМ > 2026-03-29 — Инструкция для AI-агента (GPT/Claude/Codex). > Цель: демонстрация заказчику связки Terraform + Serverless. > Один `terraform apply` — поднимается vApp/VM + автоматически устанавливается ПО. --- ## Контекст: что уже есть ``` examples/VM/ ├── main.tf # provайдер nubes, переменные (api_token, vm_public_key) ├── vapp.tf # nubes_vapp.vapp — контейнер ВМ ├── vm.tf # nubes_vc_vm_v3.vm — Ubuntu 22.04, 2CPU/2GB/20GB ├── terraform.tfvars # токены (gitignored) ├── vm_key / vm_key.pub # SSH-ключ для ВМ └── .terraform/ # init уже выполнен ``` **ВМ поднята и работает.** IP: `nubes_vc_vm_v3.vm.state_out_flat["externalConnect"]`. SSH: `ssh -i vm_key ubuntu@`. Платформа sless поднята: оператор v0.1.62, API `https://sless.kube5s.ru`. --- ## Что нужно сделать Добавить в `examples/VM/` sless-провайдер и набор `sless_job` ресурсов, которые по SSH устанавливают ПО на ВМ. Пользователь шаблона включает нужные флагами. ### Целевой результат для заказчика ```bash cd examples/VM # пользователь выставляет флаги: # install_docker = true # install_postgres = true terraform apply # → vApp + VM создаются (или уже есть) # → джобы подключаются по SSH и ставят Docker, PostgreSQL и т.д. # → outputs показывают статус каждого шага ``` --- ## Архитектура: СТРОГО sless_job - Каждая установка = отдельный `sless_job` (one-shot, execution task). - НЕ создавать `sless_service` (у функций-установщиков нет постоянного URL). - НЕ создавать новый Terraform resource — только `sless_job`. - Передача параметров: `env_vars` — для подключения (IP, ключ), `event_json` — для бизнес-логики. --- ## Файловая структура (целевая) ``` examples/VM/ ├── main.tf # + добавить provider "sless" ├── vapp.tf # без изменений ├── vm.tf # без изменений ├── variables.tf # NEW — все переменные (включая флаги install_*) ├── sless.tf # NEW — provider sless + sless_job ресурсы ├── outputs.tf # NEW — outputs статусов джобов ├── terraform.tfvars # + добавить sless_token, флаги │ ├── functions/ # NEW — код функций-установщиков │ ├── install-packages/ │ │ ├── handler.py │ │ └── requirements.txt # paramiko │ ├── install-docker/ │ │ ├── handler.py │ │ └── requirements.txt │ └── install-postgres/ │ ├── handler.py │ └── requirements.txt │ ├── vm_key / vm_key.pub └── .terraform/ ``` --- ## Контракт Python handler для sless_job ```python # handler.py — загружается в контейнер как /app/function/handler.py # Рантайм: python3.11 # Вызывается один раз, результат = JSON → записывается в job.message import os def install(event): """ event — dict из event_json Terraform-ресурса. os.environ — содержит env_vars из Terraform-ресурса. Возврат: dict/list → JSON (phase=Succeeded, message=json) raise Exception → phase=Failed, message=traceback """ vm_ip = os.environ["VM_IP"] ssh_user = os.environ["SSH_USER"] ssh_key = os.environ["SSH_KEY"] # содержимое приватного ключа (PEM) packages = event.get("packages", []) # ... SSH + установка ... return {"status": "ok", "installed": packages} ``` --- ## Спецификация каждой функции ### 1. install-packages (Этап A — первый) **Назначение:** Универсальный apt-установщик. Ставит произвольный список пакетов. **handler.py** — entrypoint: `handler.install` ``` event_json: packages: ["git", "curl", "htop", "..."] # обязательно — список имён пакетов apt update: true # опционально — apt update перед install (default: true) env_vars: VM_IP: nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] SSH_USER: "ubuntu" SSH_KEY: file("${path.module}/vm_key") requirements.txt: paramiko ``` **Логика:** 1. Подключиться по SSH через paramiko (ключ из env var, не из файла на диске). 2. `sudo apt-get update` (если event.update != false). 3. `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y `. 4. Проверить `dpkg -l ` для каждого. 5. Вернуть `{"status": "ok", "installed": [...], "already_installed": [...], "failed": [...]}`. **Обработка ошибок:** - SSH connection refused → retry 3 раза с шагом 10 сек (ВМ может ещё грузиться). - apt lock → retry 5 раз с шагом 15 сек. - Частичный фейл (2 из 5 пакетов не найдены) → status="partial", failed=[...]. - Полный фейл → raise Exception с читаемым сообщением. **Идемпотентность:** Повторный запуск безопасен — apt-get install -y ничего не ломает. ### 2. install-docker (Этап A) **Назначение:** Docker CE + docker-compose plugin по официальной инструкции. **handler.py** — entrypoint: `handler.install` ``` event_json: compose: true # опционально — ставить ли docker-compose plugin (default: true) env_vars: VM_IP, SSH_USER, SSH_KEY — те же requirements.txt: paramiko ``` **Логика:** 1. SSH → проверить `docker --version`. Если уже есть — вернуть `{"status": "already_installed", ...}`. 2. Добавить Docker apt-репозиторий (GPG ключ + sources.list). 3. `apt-get install docker-ce docker-ce-cli containerd.io`. 4. Если compose=true → `apt-get install docker-compose-plugin`. 5. `sudo usermod -aG docker $SSH_USER`. 6. Проверить: `docker run hello-world`. 7. Вернуть `{"status": "ok", "docker_version": "...", "compose": true/false}`. **Идемпотентность:** Проверяет наличие перед установкой. ### 3. install-postgres (Этап B) **Назначение:** PostgreSQL сервер + создание БД и пользователя. **handler.py** — entrypoint: `handler.install` ``` event_json: pg_version: "14" # опционально (default: "14") db_name: "myapp" # обязательно — имя БД db_user: "app_user" # обязательно — имя пользователя db_password: "secure_pass" # обязательно — пароль listen_addresses: "*" # опционально (default: "localhost") allow_remote: true # опционально — добавлять ли в pg_hba.conf (default: false) env_vars: VM_IP, SSH_USER, SSH_KEY — те же requirements.txt: paramiko ``` **Логика:** 1. SSH → проверить `psql --version`. Если нет: - `apt-get install postgresql postgresql-contrib postgresql-client`. 2. `systemctl is-active postgresql` — убедиться что запущен. 3. Создать пользователя: `sudo -u postgres psql -c "CREATE USER ... PASSWORD ..."` (IF NOT EXISTS). 4. Создать БД: `sudo -u postgres psql -c "CREATE DATABASE ... OWNER ..."` (IF NOT EXISTS). 5. Если allow_remote: настроить `listen_addresses` в postgresql.conf + запись в pg_hba.conf. 6. `systemctl restart postgresql` (если конфиг менялся). 7. Проверить подключение: `psql -h localhost -U -d -c "SELECT 1"`. 8. Вернуть `{"status": "ok", "pg_version": "14.x", "db_name": "myapp", ...}`. **Идемпотентность:** IF NOT EXISTS для пользователя и БД. Config-записи — grep перед append. --- ## Terraform: sless.tf (скелет) ```hcl # 2026-03-29 — sless.tf: sless_job функции для установки ПО на ВМ. # Каждый job подключается по SSH и ставит ПО. provider "sless" { endpoint = "https://sless.kube5s.ru" token = var.sless_token } # --- Общие локальные переменные --- locals { vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] ssh_user = "ubuntu" ssh_key = file("${path.module}/vm_key") # Общий набор env_vars для SSH-подключения к ВМ ssh_env = { VM_IP = local.vm_ip SSH_USER = local.ssh_user SSH_KEY = local.ssh_key } } # --- 1. Базовые пакеты --- resource "sless_job" "install_packages" { count = var.install_packages ? 1 : 0 name = "vm-install-packages" runtime = "python3.11" entrypoint = "handler.install" source_dir = "${path.module}/functions/install-packages" env_vars = local.ssh_env event_json = jsonencode({ packages = var.base_packages }) run_id = var.install_run_id wait_timeout_sec = 600 depends_on = [nubes_vc_vm_v3.vm] } # --- 2. Docker --- resource "sless_job" "install_docker" { count = var.install_docker ? 1 : 0 name = "vm-install-docker" runtime = "python3.11" entrypoint = "handler.install" source_dir = "${path.module}/functions/install-docker" env_vars = local.ssh_env event_json = jsonencode({ compose = true }) run_id = var.install_run_id wait_timeout_sec = 900 depends_on = [ nubes_vc_vm_v3.vm, sless_job.install_packages # пакеты первыми ] } # --- 3. PostgreSQL --- resource "sless_job" "install_postgres" { count = var.install_postgres ? 1 : 0 name = "vm-install-postgres" runtime = "python3.11" entrypoint = "handler.install" source_dir = "${path.module}/functions/install-postgres" env_vars = local.ssh_env event_json = jsonencode({ pg_version = var.pg_version db_name = var.pg_db_name db_user = var.pg_db_user db_password = var.pg_db_password allow_remote = var.pg_allow_remote }) run_id = var.install_run_id wait_timeout_sec = 900 depends_on = [ nubes_vc_vm_v3.vm, sless_job.install_packages # базовые пакеты первыми ] } ``` ## Terraform: variables.tf (скелет) ```hcl # 2026-03-29 — variables.tf: все переменные для examples/VM. # --- Nubes (уже есть, перенести из main.tf) --- variable "api_token" { type = string; sensitive = true } variable "vm_public_key" { type = string; sensitive = true } # --- Sless --- variable "sless_token" { type = string sensitive = true description = "JWT-токен для sless API" } # --- Флаги установки --- variable "install_packages" { type = bool default = true description = "Установить базовые apt-пакеты" } variable "install_docker" { type = bool default = false description = "Установить Docker CE" } variable "install_postgres" { type = bool default = false description = "Установить PostgreSQL" } # --- Параметры --- variable "base_packages" { type = list(string) default = ["git", "curl", "htop", "jq", "unzip"] description = "Список apt-пакетов для install-packages" } variable "install_run_id" { type = number default = 1 description = "Увеличить для повторного запуска всех install-джобов" } # --- PostgreSQL --- variable "pg_version" { type = string; default = "14" } variable "pg_db_name" { type = string; default = "myapp" } variable "pg_db_user" { type = string; default = "app_user" } variable "pg_db_password" { type = string; sensitive = true; default = "" } variable "pg_allow_remote" { type = bool; default = false } ``` ## Terraform: outputs.tf (скелет) ```hcl # 2026-03-29 — outputs.tf: статусы установки. output "install_packages_result" { value = var.install_packages ? sless_job.install_packages[0].message : "skipped" } output "install_docker_result" { value = var.install_docker ? sless_job.install_docker[0].message : "skipped" } output "install_postgres_result" { value = var.install_postgres ? sless_job.install_postgres[0].message : "skipped" } ``` --- ## Порядок выполнения для агента ### Фаза 1: Инфраструктура Terraform (4 файла) 1. Создать `variables.tf` — все переменные (перенести из main.tf + новые). 2. Создать `sless.tf` — провайдер sless + 3 ресурса sless_job. 3. Создать `outputs.tf` — статусы. 4. Обновить `main.tf` — вынести переменные в variables.tf, добавить required_providers sless. > **Проверка:** `terraform validate` должен пройти. ### Фаза 2: Функция install-packages (1 функция, полный E2E) 1. Создать `functions/install-packages/handler.py`. 2. Создать `functions/install-packages/requirements.txt` (paramiko). 3. Добавить `sless_token` в `terraform.tfvars`. 4. `terraform apply` с `install_packages = true`. 5. Убедиться: `phase = Succeeded`, пакеты установлены на ВМ. > **Это ключевой момент.** Если install-packages прошёл E2E — паттерн работает, остальные функции аналогичны. ### Фаза 3: Функции install-docker и install-postgres 1. Создать `functions/install-docker/handler.py` + `requirements.txt`. 2. Создать `functions/install-postgres/handler.py` + `requirements.txt`. 3. `terraform apply` с `install_docker = true, install_postgres = true`. 4. Проверить: Docker установлен, PostgreSQL работает, БД создана. ### Фаза 4: Полировка и README 1. Обновить `README.md` — как использовать шаблон с sless. 2. `terraform apply` с нуля (destroy + apply) — весь цикл. --- ## SSH через paramiko — референсный паттерн ```python # Этот блок — основа для всех handler.py. Копировать и адаптировать. import os, io, time import paramiko def _ssh_connect(retries=3, delay=10): """Подключение к ВМ по SSH. Retry при connection refused (ВМ грузится).""" key = paramiko.Ed25519Key.from_private_key(io.StringIO(os.environ["SSH_KEY"])) for attempt in range(retries): try: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( hostname=os.environ["VM_IP"], username=os.environ["SSH_USER"], pkey=key, timeout=15, ) return client except Exception as e: if attempt == retries - 1: raise RuntimeError(f"SSH connection failed after {retries} attempts: {e}") time.sleep(delay) def _ssh_run(client, cmd, check=True): """Выполнить команду. При check=True — бросить ошибку если exit_code != 0.""" stdin, stdout, stderr = client.exec_command(cmd, timeout=300) exit_code = stdout.channel.recv_exit_status() out = stdout.read().decode().strip() err = stderr.read().decode().strip() if check and exit_code != 0: raise RuntimeError(f"Command failed (exit {exit_code}): {cmd}\nstderr: {err}") return exit_code, out, err ``` --- ## Ограничения и подводные камни 1. **SSH через внешний IP** — Sless-поды находятся в k8s кластере `kube5s.ru`, VM — в Nubes vDC. Эти сети **не связаны напрямую**. Используем `externalConnect` (публичный IP). `internalConnect` (`10.x.x.x`) доступен только внутри Nubes vDC — из sless-подов он недостижим. > **TODO для DevOps облака Nubes:** обсудить организацию внутреннего трафика между > k8s кластером и Nubes vDC — VPN/peering/dedicated link. До решения — только внешний IP. > Когда появится внутренний маршрут — заменить `externalConnect` → `internalConnect` в locals. В `sless.tf`: ```hcl # TODO: заменить на internalConnect когда DevOps настроят сеть между кластером и vDC vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] ``` 2. **SSH_KEY в env_var** — Содержимое приватного ключа передаётся как env var (строка). paramiko умеет читать из `io.StringIO`. Не писать в файл. 2. **apt lock** — Если apt уже заблокирован (unattended-upgrades), будет ошибка. Retry с проверкой `/var/lib/dpkg/lock-frontend`. 3. **depends_on обязателен** — ВМ должна быть готова до запуска job. `depends_on = [nubes_vc_vm_v3.vm]`. Без этого terraform может запустить параллельно. 4. **run_id для повторного запуска** — sless_job не перезапускается автоматически. Чтобы перезапустить: `install_run_id = 2` → `terraform apply`. 5. **wait_timeout_sec** — Kaniko-сборка + выполнение. 600с для мелких пакетов, 900с для Docker/PG. 6. **Vault пока нет** — Секреты (pg_password, ssh_key) передаются через tfvars. Архитектура готова к замене на Vault data source позже (env_vars заполняются из vault). 7. **Drift не отслеживается** — Job-модель ≠ полноценный stateful resource. Если кто-то удалит Docker на ВМ — Terraform не знает. Перезапуск: увеличить run_id.