Files
sless/doc/decisions/vm-install-functions-plan.md
T
Naeel fef441681e examples/VM: sless_job provisioning (packages, nginx, docker)
- 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
2026-03-29 19:54:00 +03:00

19 KiB
Raw Blame History

План-инструкция: 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

Логика:

  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 (скелет)

# 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 файла)

  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 — референсный паттерн

# Этот блок — основа для всех 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. Когда появится внутренний маршрут — заменить externalConnectinternalConnect в locals.

    В sless.tf:

    # 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. Не писать в файл.

  3. apt lock — Если apt уже заблокирован (unattended-upgrades), будет ошибка. Retry с проверкой /var/lib/dpkg/lock-frontend.

  4. depends_on обязателен — ВМ должна быть готова до запуска job. depends_on = [nubes_vc_vm_v3.vm]. Без этого terraform может запустить параллельно.

  5. run_id для повторного запуска — sless_job не перезапускается автоматически. Чтобы перезапустить: install_run_id = 2terraform apply.

  6. wait_timeout_sec — Kaniko-сборка + выполнение. 600с для мелких пакетов, 900с для Docker/PG.

  7. Vault пока нет — Секреты (pg_password, ssh_key) передаются через tfvars. Архитектура готова к замене на Vault data source позже (env_vars заполняются из vault).

  8. Drift не отслеживается — Job-модель ≠ полноценный stateful resource. Если кто-то удалит Docker на ВМ — Terraform не знает. Перезапуск: увеличить run_id.