- 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
19 KiB
План-инструкция: 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
устанавливают ПО на ВМ. Пользователь шаблона включает нужные флагами.
Целевой результат для заказчика
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
# 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
Логика:
- Подключиться по SSH через paramiko (ключ из env var, не из файла на диске).
sudo apt-get update(если event.update != false).sudo DEBIAN_FRONTEND=noninteractive apt-get install -y <packages>.- Проверить
dpkg -l <package>для каждого. - Вернуть
{"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
Логика:
- SSH → проверить
docker --version. Если уже есть — вернуть{"status": "already_installed", ...}. - Добавить Docker apt-репозиторий (GPG ключ + sources.list).
apt-get install docker-ce docker-ce-cli containerd.io.- Если compose=true →
apt-get install docker-compose-plugin. sudo usermod -aG docker $SSH_USER.- Проверить:
docker run hello-world. - Вернуть
{"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
Логика:
- SSH → проверить
psql --version. Если нет:apt-get install postgresql postgresql-contrib postgresql-client.
systemctl is-active postgresql— убедиться что запущен.- Создать пользователя:
sudo -u postgres psql -c "CREATE USER ... PASSWORD ..."(IF NOT EXISTS). - Создать БД:
sudo -u postgres psql -c "CREATE DATABASE ... OWNER ..."(IF NOT EXISTS). - Если allow_remote: настроить
listen_addressesв postgresql.conf + запись в pg_hba.conf. systemctl restart postgresql(если конфиг менялся).- Проверить подключение:
psql -h localhost -U <user> -d <db> -c "SELECT 1". - Вернуть
{"status": "ok", "pg_version": "14.x", "db_name": "myapp", ...}.
Идемпотентность: IF NOT EXISTS для пользователя и БД. Config-записи — grep перед append.
Terraform: sless.tf (скелет)
# 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 (скелет)
# 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 (скелет)
# 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 файла)
- Создать
variables.tf— все переменные (перенести из main.tf + новые). - Создать
sless.tf— провайдер sless + 3 ресурса sless_job. - Создать
outputs.tf— статусы. - Обновить
main.tf— вынести переменные в variables.tf, добавить required_providers sless.
Проверка:
terraform validateдолжен пройти.
Фаза 2: Функция install-packages (1 функция, полный E2E)
- Создать
functions/install-packages/handler.py. - Создать
functions/install-packages/requirements.txt(paramiko). - Добавить
sless_tokenвterraform.tfvars. terraform applyсinstall_packages = true.- Убедиться:
phase = Succeeded, пакеты установлены на ВМ.
Это ключевой момент. Если install-packages прошёл E2E — паттерн работает, остальные функции аналогичны.
Фаза 3: Функции install-docker и install-postgres
- Создать
functions/install-docker/handler.py+requirements.txt. - Создать
functions/install-postgres/handler.py+requirements.txt. terraform applyсinstall_docker = true, install_postgres = true.- Проверить: Docker установлен, PostgreSQL работает, БД создана.
Фаза 4: Полировка и README
- Обновить
README.md— как использовать шаблон с sless. terraform applyс нуля (destroy + apply) — весь цикл.
SSH через paramiko — референсный паттерн
# Этот блок — основа для всех 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
Ограничения и подводные камни
-
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:# TODO: заменить на internalConnect когда DevOps настроят сеть между кластером и vDC vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] -
SSH_KEY в env_var — Содержимое приватного ключа передаётся как env var (строка). paramiko умеет читать из
io.StringIO. Не писать в файл. -
apt lock — Если apt уже заблокирован (unattended-upgrades), будет ошибка. Retry с проверкой
/var/lib/dpkg/lock-frontend. -
depends_on обязателен — ВМ должна быть готова до запуска job.
depends_on = [nubes_vc_vm_v3.vm]. Без этого terraform может запустить параллельно. -
run_id для повторного запуска — sless_job не перезапускается автоматически. Чтобы перезапустить:
install_run_id = 2→terraform apply. -
wait_timeout_sec — Kaniko-сборка + выполнение. 600с для мелких пакетов, 900с для Docker/PG.
-
Vault пока нет — Секреты (pg_password, ssh_key) передаются через tfvars. Архитектура готова к замене на Vault data source позже (env_vars заполняются из vault).
-
Drift не отслеживается — Job-модель ≠ полноценный stateful resource. Если кто-то удалит Docker на ВМ — Terraform не знает. Перезапуск: увеличить run_id.