From fef441681e359803fbeef6825a987533bd52ce58 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 29 Mar 2026 19:54:00 +0300 Subject: [PATCH] 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 --- doc/decisions/vm-install-functions-plan.md | 500 ++++++++++++++++++ doc/progress.md | 57 ++ .../VM/functions/install-docker/handler.py | 158 ++++++ .../functions/install-docker/requirements.txt | 2 + .../VM/functions/install-nginx/handler.py | 129 +++++ .../functions/install-nginx/requirements.txt | 2 + .../VM/functions/install-packages/handler.py | 133 +++++ .../install-packages/requirements.txt | 2 + examples/VM/main.tf | 6 +- examples/VM/outputs.tf | 18 + examples/VM/sless.tf | 108 ++++ examples/VM/variables.tf | 37 ++ examples/VM/vm.tf | 16 +- 13 files changed, 1160 insertions(+), 8 deletions(-) create mode 100644 doc/decisions/vm-install-functions-plan.md create mode 100644 examples/VM/functions/install-docker/handler.py create mode 100644 examples/VM/functions/install-docker/requirements.txt create mode 100644 examples/VM/functions/install-nginx/handler.py create mode 100644 examples/VM/functions/install-nginx/requirements.txt create mode 100644 examples/VM/functions/install-packages/handler.py create mode 100644 examples/VM/functions/install-packages/requirements.txt create mode 100644 examples/VM/outputs.tf create mode 100644 examples/VM/sless.tf create mode 100644 examples/VM/variables.tf diff --git a/doc/decisions/vm-install-functions-plan.md b/doc/decisions/vm-install-functions-plan.md new file mode 100644 index 0000000..6808322 --- /dev/null +++ b/doc/decisions/vm-install-functions-plan.md @@ -0,0 +1,500 @@ +# План-инструкция: 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. diff --git a/doc/progress.md b/doc/progress.md index 341d4a1..aa9e3d1 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1417,3 +1417,60 @@ G15 перезапущен → **21/21 PASS ✅** ### Версия оператора `v0.1.51` — задеплоен, работает + +--- + +## Шаг 6: VM provisioning через sless_job (2026-03-29) + +### Цель +Установить пакеты, nginx и docker на свежую Nubes VM через sless_job. + +### Что реализовано +- `examples/VM/sless.tf` — три `sless_job` ресурса с `depends_on` цепочкой: + - `vm-install-packages` → jq, python3-pip, htop, unzip + - `vm-install-nginx` → nginx (depends_on install-packages) + - `vm-install-docker` → Docker CE 29.3.1 + Compose v5.1.1 (depends_on install-nginx) +- `examples/VM/variables.tf` — переменная `install_run_id` для управления rebuildом + +--- + +## Шаг 6: VM provisioning через sless_job (2026-03-29) + +### Цель +Установить пакеты, nginx и docker на свежую Nubes VM через sless_job. + +### Что реализовано +- `examples/VM/sless.tf` — три `sless_job` ресурса с `depends_on` цепочкой: + - `vm-install-packages` → jq, python3-pip, htop, unzip + - `vm-install-nginx` → nginx (depends_on install-packages) + - `vm-install-docker` → Docker CE 29.3.1 + Compose v5.1.1 (depends_on install-nginx) +- `examples/VM/variables.tf` — переменная `install_run_id` для управления rebuild'ом +- `examples/VM/outputs.tf` — вывод результатов job'ов +- `examples/VM/functions/install-packages/handler.py` — SSH установка пакетов +- `examples/VM/functions/install-nginx/handler.py` — SSH установка nginx +- `examples/VM/functions/install-docker/handler.py` — SSH установка Docker CE + +### Баги найдены и исправлены + +#### Баг 1: Ubuntu 22.04 — cloud-init держит apt lock при старте VM +- **Симптом**: `E: Could not get lock /var/lib/dpkg/lock-frontend (held by process 2191)` +- **Причина**: `unattended-upgrades` держит lock через cloud-init 15-20 мин после старта VM +- **Фикс**: `_wait_apt_lock()` начинает с `cloud-init status --wait` (timeout 300s), затем `systemctl mask` сервисов авто-обновления + +#### Баг 2: Оператор запускает СТАРЫЙ образ после upload +- **Симптом**: `image already exists in registry, skipping build` — новый код не применялся +- **Причина**: Go DEFLATE создавал идентичные байты zip для разных версий handler.py, sha256(zip) не менялся +- **Фикс**: изменение requirements.txt гарантирует уникальный zip hash + +#### Баг 3: WaitJobDone мгновенно возвращал ошибку на старый Failed статус +- **Симптом**: terraform apply падает мгновенно без ожидания нового build +- **Причина**: FunctionJob CRD хранил phase=Failed от предыдущего запуска +- **Воркараунд**: удалить FunctionJob CRD перед apply + +### Результат +- vm-install-packages: status=ok, installed=[jq, python3-pip, htop, unzip] +- vm-install-nginx: status=already_installed, version=1.18.0, http_check=200 +- vm-install-docker: status=ok, docker=29.3.1, compose=v5.1.1 + +### nubes провайдер +Обновлён с 5.0.49 до 5.0.51 (fix: findInstanceUidByDisplayNameRefSvc возвращал deleted instance UUID) diff --git a/examples/VM/functions/install-docker/handler.py b/examples/VM/functions/install-docker/handler.py new file mode 100644 index 0000000..c9660d3 --- /dev/null +++ b/examples/VM/functions/install-docker/handler.py @@ -0,0 +1,158 @@ +# 2026-03-29 — handler.py: установка Docker CE на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# Метод установки: официальный Docker apt-репозиторий (best practices). +# НЕ используется curl | sh — небезопасно для продакшена. +# +# event_json: +# compose: true/false — ставить ли docker-compose-plugin (default: true) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +# Команды установки Docker CE через официальный apt-репозиторий. +# Источник: https://docs.docker.com/engine/install/ubuntu/ +_DOCKER_INSTALL_CMDS = [ + # Зависимости для добавления внешнего репозитория + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq ca-certificates curl gnupg", + # Директория для ключей + "sudo install -m 0755 -d /etc/apt/keyrings", + # GPG-ключ Docker + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor --batch --yes -o /etc/apt/keyrings/docker.gpg", + "sudo chmod a+r /etc/apt/keyrings/docker.gpg", + # Docker apt-репозиторий + ( + 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] ' + 'https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" ' + "| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null" + ), + # Обновить индекс с новым репо + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", + # Установить Docker CE + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq docker-ce docker-ce-cli containerd.io", +] + + +def install(event): + """Установить Docker CE. Если уже установлен — вернуть версию.""" + install_compose = event.get("compose", True) + + client = _ssh_connect() + try: + # Проверить: уже установлен? + code, ver_out, _ = _run(client, "docker --version 2>&1", check=False) + if code == 0 and "Docker version" in ver_out: + _, compose_out, _ = _run(client, "docker compose version 2>&1", check=False) + return { + "status": "already_installed", + "docker_version": ver_out, + "compose_version": compose_out if "Docker Compose" in compose_out else None, + } + + _wait_apt_lock(client) + + for cmd in _DOCKER_INSTALL_CMDS: + _run(client, cmd, timeout=180) + + if install_compose: + _run( + client, + "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin", + timeout=120, + ) + + # Добавить пользователя в группу docker (чтобы запускать без sudo) + ssh_user = os.environ["SSH_USER"] + _run(client, f"sudo usermod -aG docker {ssh_user}", check=False) + + # Проверка: запустить hello-world + # Используем sudo т.к. usermod не применится до переподключения + _run(client, "sudo docker run --rm hello-world", timeout=120) + + _, ver_out, _ = _run(client, "docker --version", check=False) + _, compose_out, _ = _run(client, "docker compose version 2>&1", check=False) + + return { + "status": "ok", + "docker_version": ver_out, + "compose_version": compose_out if "Docker Compose" in compose_out else None, + "note": f"user '{ssh_user}' added to docker group (reconnect to use without sudo)", + } + finally: + client.close() diff --git a/examples/VM/functions/install-docker/requirements.txt b/examples/VM/functions/install-docker/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/examples/VM/functions/install-docker/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/examples/VM/functions/install-nginx/handler.py b/examples/VM/functions/install-nginx/handler.py new file mode 100644 index 0000000..70214eb --- /dev/null +++ b/examples/VM/functions/install-nginx/handler.py @@ -0,0 +1,129 @@ +# 2026-03-29 — handler.py: установка nginx на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# event_json: {} (параметров нет — nginx ставится с дефолтной конфигурацией) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +def install(event): + """Установить nginx. Если уже установлен — проверить что запущен.""" + client = _ssh_connect() + try: + # Проверить: уже установлен? + code, ver_out, _ = _run(client, "nginx -v 2>&1", check=False) + already_installed = "nginx version" in ver_out + + if already_installed: + # Убедиться что сервис запущен + _run(client, "sudo systemctl start nginx", check=False) + version = ver_out.replace("nginx version: nginx/", "").strip() + _, http_code, _ = _run( + client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False + ) + return { + "status": "already_installed", + "version": version, + "http_check": http_code, + } + + _wait_apt_lock(client) + _run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420) + _run( + client, + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq nginx", + timeout=300, + ) + _run(client, "sudo systemctl enable nginx") + _run(client, "sudo systemctl start nginx") + + # Проверить HTTP-ответ на localhost + _, http_code, _ = _run( + client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False + ) + _, ver_out, _ = _run(client, "nginx -v 2>&1", check=False) + version = ver_out.replace("nginx version: nginx/", "").strip() + + return { + "status": "ok", + "version": version, + "http_check": http_code, + } + finally: + client.close() diff --git a/examples/VM/functions/install-nginx/requirements.txt b/examples/VM/functions/install-nginx/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/examples/VM/functions/install-nginx/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/examples/VM/functions/install-packages/handler.py b/examples/VM/functions/install-packages/handler.py new file mode 100644 index 0000000..cf47ff7 --- /dev/null +++ b/examples/VM/functions/install-packages/handler.py @@ -0,0 +1,133 @@ +# 2026-03-29 — handler.py: установка apt-пакетов на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# event_json: +# packages: ["git", "curl", ...] — список пакетов (обязательно) +# update: true/false — apt-get update перед install (default: true) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + """Загрузить SSH-ключ (Ed25519 / RSA / ECDSA).""" + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + """Подключение к ВМ с retry — ВМ может ещё загружаться.""" + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + """Выполнить команду, вернуть (exit_code, stdout, stderr).""" + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = [ + "/var/lib/dpkg/lock-frontend", + "/var/lib/dpkg/lock", + "/var/lib/apt/lists/lock", + ] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + # Повторить убийство процессов удерживающих lock + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +def install(event): + """Установить apt-пакеты. Идемпотентно — повторный запуск безопасен.""" + packages = event.get("packages", []) + if not packages: + return {"status": "skipped", "reason": "packages list is empty"} + + do_update = event.get("update", True) + + client = _ssh_connect() + try: + _wait_apt_lock(client) + + if do_update: + _run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420) + + pkg_str = " ".join(packages) + _run( + client, + f"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq {pkg_str}", + timeout=300, + ) + + # Проверить что установилось + installed, missing = [], [] + for pkg in packages: + code, _, _ = _run( + client, + f"dpkg -l {pkg} 2>/dev/null | grep -q '^ii'", + check=False, + ) + (installed if code == 0 else missing).append(pkg) + + return { + "status": "ok" if not missing else "partial", + "installed": installed, + "missing": missing, + } + finally: + client.close() diff --git a/examples/VM/functions/install-packages/requirements.txt b/examples/VM/functions/install-packages/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/examples/VM/functions/install-packages/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/examples/VM/main.tf b/examples/VM/main.tf index 05769cc..866902e 100644 --- a/examples/VM/main.tf +++ b/examples/VM/main.tf @@ -5,7 +5,11 @@ terraform { required_providers { nubes = { source = "terra.k8c.ru/nubes/nubes" - version = "5.0.49" + version = "5.0.51" + } + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1" } } } diff --git a/examples/VM/outputs.tf b/examples/VM/outputs.tf new file mode 100644 index 0000000..cda642a --- /dev/null +++ b/examples/VM/outputs.tf @@ -0,0 +1,18 @@ +# 2026-03-29 — outputs.tf: результаты установки ПО на ВМ. +# phase: Pending / Building / Running / Succeeded / Failed +# message: JSON с деталями (что установлено) или traceback при ошибке + +output "install_packages_result" { + description = "Результат установки базовых пакетов" + value = var.install_packages ? sless_job.install_packages[0].message : "skipped" +} + +output "install_nginx_result" { + description = "Результат установки nginx" + value = var.install_nginx ? sless_job.install_nginx[0].message : "skipped" +} + +output "install_docker_result" { + description = "Результат установки Docker" + value = var.install_docker ? sless_job.install_docker[0].message : "skipped" +} diff --git a/examples/VM/sless.tf b/examples/VM/sless.tf new file mode 100644 index 0000000..e120857 --- /dev/null +++ b/examples/VM/sless.tf @@ -0,0 +1,108 @@ +# 2026-03-29 — sless.tf: провайдер sless и sless_job ресурсы для установки ПО на ВМ. +# +# Схема работы: +# 1. terraform apply создаёт FunctionJob CR в k8s +# 2. Провайдер загружает код из source_dir в S3 +# 3. Оператор собирает Docker-образ (kaniko) и запускает Job +# 4. Job подключается к ВМ по SSH и устанавливает ПО +# 5. terraform apply завершается: outputs содержат статус каждого шага +# +# Для повторного запуска: увеличь install_run_id в terraform.tfvars → terraform apply + +# --------------------------------------------------------------------------- +# Провайдер +# --------------------------------------------------------------------------- + +provider "sless" { + endpoint = "https://sless.kube5s.ru" + token = var.api_token # тот же JWT что и в provider "nubes" + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} + +# --------------------------------------------------------------------------- +# Общие locals: SSH-параметры для подключения к ВМ +# --------------------------------------------------------------------------- + +locals { + # TODO: заменить externalConnect → internalConnect когда DevOps настроят + # сеть между k8s кластером и Nubes vDC (сейчас только внешний IP доступен). + vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] + + ssh_env = { + VM_IP = local.vm_ip + SSH_USER = "ubuntu" + # TODO(vault): заменить на чтение из Vault когда сервис заработает; пока тестовый стенд — прямой файл. + SSH_KEY = file("${path.module}/vm_key") + } +} + +# --------------------------------------------------------------------------- +# Job 1: базовые пакеты (jq, pip3 и др.) +# --------------------------------------------------------------------------- + +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" + memory_mb = 128 + + env_vars = local.ssh_env + event_json = jsonencode({ + packages = var.base_packages + update = true + }) + + run_id = var.install_run_id + wait_timeout_sec = 600 + + depends_on = [nubes_vc_vm_v3.vm] +} + +# --------------------------------------------------------------------------- +# Job 2: nginx +# --------------------------------------------------------------------------- + +resource "sless_job" "install_nginx" { + count = var.install_nginx ? 1 : 0 + + name = "vm-install-nginx" + runtime = "python3.11" + entrypoint = "handler.install" + source_dir = "${path.module}/functions/install-nginx" + memory_mb = 128 + + env_vars = local.ssh_env + event_json = jsonencode({}) + + run_id = var.install_run_id + wait_timeout_sec = 600 + + depends_on = [nubes_vc_vm_v3.vm, sless_job.install_packages] +} + +# --------------------------------------------------------------------------- +# Job 3: Docker CE +# --------------------------------------------------------------------------- + +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" + memory_mb = 128 + + 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, sless_job.install_nginx] +} diff --git a/examples/VM/variables.tf b/examples/VM/variables.tf new file mode 100644 index 0000000..de95a50 --- /dev/null +++ b/examples/VM/variables.tf @@ -0,0 +1,37 @@ +# 2026-03-29 — variables.tf: переменные для sless и установки ПО на ВМ. +# Переменные nubes (api_token, vm_public_key) остаются в main.tf. +# sless использует тот же api_token — отдельной переменной не нужно. + +# ---- Флаги: что устанавливать на ВМ -------------------------------------- + +variable "install_packages" { + type = bool + default = true + description = "Установить базовые apt-пакеты (jq и др.)" +} + +variable "install_nginx" { + type = bool + default = false + description = "Установить nginx" +} + +variable "install_docker" { + type = bool + default = false + description = "Установить Docker CE + docker-compose-plugin" +} + +# ---- Параметры ------------------------------------------------------------ + +variable "base_packages" { + type = list(string) + default = ["jq", "python3-pip", "htop", "unzip"] + description = "Список apt-пакетов для install-packages" +} + +variable "install_run_id" { + type = number + default = 1 + description = "Увеличь на 1 чтобы запустить все install-джобы заново" +} diff --git a/examples/VM/vm.tf b/examples/VM/vm.tf index 81775b9..a9110fa 100644 --- a/examples/VM/vm.tf +++ b/examples/VM/vm.tf @@ -3,19 +3,21 @@ // image_vm, vapp_uid, user_public_key не изменяются после создания. resource "nubes_vc_vm_v3" "vm" { - resource_name = "vm-sless" + resource_name = "vm-sless-1" + vm_name = "web02" # Имя ВМ в Nubes vCD. Не изменяется после создания. + # Определяет имя NSX-T IP Set: {vapp_name}-{vm_name} - vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания. - image_vm = "Ubuntu_22-20G" # Не изменяется после создания. -# image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания. + vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания. + image_vm = "Ubuntu_22-20G" # Не изменяется после создания. + # image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания. ip_space_name = "internet-ipv4-v1" user_login = "ubuntu" - user_public_key = var.vm_public_key # задаётся в terraform.tfvars + user_public_key = var.vm_public_key # задаётся в terraform.tfvars vm_cpu = 2 - vm_ram = 2 # GB - vm_disk = 20 # GB + vm_ram = 2 # GB + vm_disk = 20 # GB adopt_existing_on_create = true operation_timeout = "15m"