- Add sless.tf: three sless_job resources with depends_on chain
- vm-install-packages: jq, python3-pip, htop, unzip
- vm-install-nginx: nginx 1.18 (HTTP 200 verified)
- vm-install-docker: Docker CE 29.3.1 + Compose v5.1.1
- Add functions/install-{packages,nginx,docker}/handler.py
- _wait_apt_lock: cloud-init status --wait + systemctl mask
fixes Ubuntu 22.04 first-boot apt lock (unattended-upgrades)
- DPkg::Lock::Timeout=600 on all apt-get calls
- Add variables.tf (install_run_id, vm_public_key, vm_private_key)
- Add outputs.tf (install_*_result)
- Bump nubes provider 5.0.49 -> 5.0.51
- doc/progress.md: document step 6 with bug analysis
501 lines
19 KiB
Markdown
501 lines
19 KiB
Markdown
# План-инструкция: 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@<IP>`.
|
||
|
||
Платформа 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 <packages>`.
|
||
4. Проверить `dpkg -l <package>` для каждого.
|
||
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 <user> -d <db> -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.
|