Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4764e983f7 | ||
|
|
f869b7986e | ||
|
|
89d698fa47 | ||
|
|
e737f2687d | ||
|
|
56e6446310 | ||
|
|
fef441681e | ||
|
|
b4c2e7f6b1 | ||
|
|
f8f95d7147 | ||
|
|
547994dc55 | ||
|
|
2b03ba520e | ||
|
|
bc0e00acca | ||
|
|
3404af578b | ||
|
|
8051870208 | ||
|
|
cad89fdbb6 | ||
|
|
6e73729c46 | ||
|
|
470039f6d6 | ||
|
|
f68b601484 | ||
|
|
442ba8bc2f | ||
|
|
b7fa8acf76 |
@@ -69,3 +69,5 @@ event-dispatcher
|
||||
# build artifacts
|
||||
/sless
|
||||
examples/POSTGRES/stress_log*.txt
|
||||
examples/VM/vm_key
|
||||
examples/VM/vm_key.pub
|
||||
|
||||
@@ -2,6 +2,70 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 — Переход на READ-ONLY подход в тестах (v2)
|
||||
|
||||
### Решение
|
||||
Полностью переписан `vm_stress_test.sh`. Убраны все функции записи в файлы
|
||||
(`write_tfvars`, `backup_tfvars`, `restore_tfvars`). Переопределения переменных
|
||||
теперь через `-var` в terraform CLI. Добавлена проверка md5sum terraform.tfvars.
|
||||
|
||||
### Почему
|
||||
Функция `write_tfvars()` в v1 уничтожила `terraform.tfvars`, потеряв JWT-токен
|
||||
`api_token`. Пайплайн `grep | cut | xargs | sed` не смог корректно обработать
|
||||
JWT строку длиной 1200+ символов. Восстановление потребовало ручного вмешательства.
|
||||
|
||||
### Ключевые принципы v2:
|
||||
- Скрипт **НИКОГДА** не пишет в файлы (read-only)
|
||||
- Все переопределения — через `terraform apply -var "key=value"`
|
||||
- md5sum проверка после каждой фазы, аварийный стоп при изменении
|
||||
- `terraform.tfvars` содержит секреты (api_token) → нельзя трогать
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 — Автономный тестовый фреймворк для VM
|
||||
|
||||
### Решение
|
||||
Внедрить bash-скрипт `vm_stress_test.sh` как основной инструмент для долгого, самовосстанавливающегося тестирования примера `examples/VM`.
|
||||
|
||||
### Почему
|
||||
- Предыдущие ручные тесты подтвердили корректность логики, но для выявления редких race conditions и обеспечения преемственности между разными сессиями агентов нужен воспроизводимый сценарий.
|
||||
- Скрипт инкапсулирует все "знания" о VM (IP, ключи, логика очистки `apt`), позволяя любому агенту запустить тест одной командой.
|
||||
- Использование `timeout` на уровне команд `terraform` и `ssh` внутри скрипта предотвращает зависание автоматизации.
|
||||
|
||||
## 2026-03-29 — Матрица тестов VM example подтверждена прогоном
|
||||
|
||||
### Решение
|
||||
|
||||
Оставить текущую модель тестирования [examples/VM](examples/VM) как комбинацию из ручного cleanup, destroy/apply цикла, частичного отключения job-ресурсов и короткого stress loop.
|
||||
|
||||
### Почему
|
||||
|
||||
- Эта матрица проверяет и lifecycle VM, и идемпотентность job-ресурсов, и реакцию на изменение количества/порядка установок.
|
||||
- Отдельный destroy/apply прогон подтвердил suspend/wake поведение без необходимости писать новый тестовый фреймворк.
|
||||
- Stress loop из двух циклов дал полезную нагрузку без чрезмерного времени прогона.
|
||||
|
||||
## 2026-03-29 — Матрица тестов для VM example
|
||||
|
||||
### Решение
|
||||
|
||||
Для проверки поведения [examples/VM](examples/VM) использовать не один прогон, а набор сценариев:
|
||||
|
||||
1. обычный `apply` как базовый контроль;
|
||||
2. удаление всего установленного ПО внутри ВМ перед `destroy`;
|
||||
3. `destroy` с проверкой перехода ВМ в `suspend`;
|
||||
4. повторный `apply` с проверкой wake-up и повторной установки;
|
||||
5. изменение количества и порядка установок;
|
||||
6. стресс-прогоны с несколькими повторениями.
|
||||
|
||||
### Почему так
|
||||
|
||||
- Один проход не показывает идемпотентность и не ловит проблемы порядка ресурсов.
|
||||
- Сценарий с `destroy` проверяет, что инфраструктура не удаляет ВМ физически, а переводит её в `suspend`.
|
||||
- Повторный `apply` после `suspend` проверяет восстановление состояния без ручного вмешательства.
|
||||
- Перестановки и изменение количества установок нужны, чтобы проверить устойчивость к дрейфу и к разным графам зависимостей.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-21 — Оценка трудозатрат на проект
|
||||
|
||||
| Компонент | Оценка |
|
||||
|
||||
@@ -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@<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.
|
||||
@@ -4,6 +4,80 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-29 — SSH timeout после destroy VM example
|
||||
|
||||
### Симптом
|
||||
|
||||
После `terraform destroy` для [examples/VM](examples/VM) попытка зайти по SSH на target VM завершилась таймаутом:
|
||||
|
||||
- `ssh: connect to host 185.247.187.154 port 22: Connection timed out`
|
||||
|
||||
### Причина
|
||||
|
||||
Это ожидаемое поведение для сценария suspend: VM перестаёт отвечать по SSH после destroy, а затем поднимается обратно на `terraform apply`.
|
||||
|
||||
### Что сделали
|
||||
|
||||
- Подтвердили, что `terraform apply` после destroy восстанавливает доступ и повторно запускает install jobs.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-26 — БАГ ГЕНЕРАТОРА: modify-only поля помечаются Required в schema ресурса
|
||||
|
||||
```
|
||||
Error: Missing required argument
|
||||
on vc_org.tf line 5, in resource "nubes_vc_org" "dev_org":
|
||||
5: resource "nubes_vc_org" "dev_org" {
|
||||
The argument "v_i_p_configure" is required, but no definition was found.
|
||||
The argument "resource_name" is required, but no definition was found.
|
||||
```
|
||||
|
||||
### Причина
|
||||
|
||||
Генератор (`~/terra/terraform/devops/`) при создании Go-кода ресурса (`19_vc_org_resource.go`)
|
||||
помечает **все операции ресурса** как `Required` в схеме, включая поля,
|
||||
которые нужны только для операции `modify` (не для `create`).
|
||||
|
||||
Конкретный пример: `vIPConfigure` (код параметра 662) — это поле операции `modify`,
|
||||
но попадает в schema с `Required: true`:
|
||||
|
||||
```go
|
||||
"v_i_p_configure": schema.StringAttribute{Required: true},
|
||||
```
|
||||
|
||||
В YAML-описании сервиса (19_vc_org.yaml) `vIPConfigure` объявлен только под
|
||||
`operations.modify.params`, а не под `operations.create.params`.
|
||||
|
||||
### Что нужно исправить в генераторе
|
||||
|
||||
В `~/terra/terraform/devops/` (файлы `02_generate_resources_and_docs*.go/sh`):
|
||||
|
||||
Поля, принадлежащие только операции `modify` (или другим не-create операциям),
|
||||
должны генерироваться как **`Optional: true, Computed: true`**, а не `Required: true`.
|
||||
|
||||
Логика:
|
||||
- поле в `operations[create].params` → `Required: true`
|
||||
- поле только в `operations[modify].params` → `Optional: true, Computed: true`
|
||||
- поле только в state (read-only) → `Computed: true`
|
||||
|
||||
### Временный workaround (действующий)
|
||||
|
||||
В `terraform.tf`-манифесте указывать пустую строку:
|
||||
|
||||
```hcl
|
||||
v_i_p_configure = "" # modify-only поле; при create не отправляется в API
|
||||
```
|
||||
|
||||
Провайдер при Create не передаёт это поле в API (строка 418/556 params),
|
||||
но schema.Required требует non-null значение в плане.
|
||||
|
||||
### Файлы для правки
|
||||
|
||||
- `~/terra/terraform/devops/profiles/test/generated/go/19_vc_org_resource.go` — сгенерированный, не менять вручную
|
||||
- **Править нужно шаблоны/генераторы** в `~/terra/terraform/devops/`
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-22 — БАГ: CreateService/CreateFunction возвращает 409 при `terraform apply -replace` (ИСПРАВЛЕН)
|
||||
|
||||
### Симптом
|
||||
|
||||
+184
-1
@@ -1,6 +1,189 @@
|
||||
# Прогресс разработки
|
||||
|
||||
Последнее обновление: 2026-03-23
|
||||
Последнее обновление: 2026-03-30
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 — Автоматизация VM stress-тестирования
|
||||
|
||||
### v2 (READ-ONLY) — переписан после инцидента с потерей tfvars
|
||||
|
||||
**Инцидент:** v1 скрипта содержал `write_tfvars()` которая перезаписывала `terraform.tfvars`.
|
||||
Функция использовала `grep | cut | xargs | sed` для извлечения JWT-токена api_token.
|
||||
Пайплайн не справился с длинным JWT (1200+ символов) и токен был потерян.
|
||||
Это сломало весь terraform и потребовало ручного восстановления.
|
||||
|
||||
**Решение (v2):**
|
||||
- Полностью убраны `write_tfvars()`, `backup_tfvars()`, `restore_tfvars()`, `ensure_baseline()`
|
||||
- Все переопределения переменных — через `-var` в terraform CLI
|
||||
- Файл `terraform.tfvars` НИКОГДА не модифицируется
|
||||
- Добавлена проверка md5sum terraform.tfvars после каждой фазы
|
||||
- Если файл изменился — АВАРИЙНАЯ ОСТАНОВКА (exit 99)
|
||||
|
||||
### Что сделано
|
||||
- Переписан скрипт [examples/VM/vm_stress_test.sh](examples/VM/vm_stress_test.sh) (v2, 847 строк, 10 фаз)
|
||||
- Создана инструкция [examples/VM/VM_TEST_README.md](examples/VM/VM_TEST_README.md)
|
||||
- Старая версия сохранена как `.vm_stress_test.sh.OLD`
|
||||
|
||||
### Сценарии (10 фаз):
|
||||
1. **Baseline**: apply с полным набором (packages+nginx+docker)
|
||||
2. **Idempotent**: plan → "No changes"
|
||||
3. **Partial Disable**: выключить nginx+docker через `-var`
|
||||
4. **Partial Enable**: включить обратно
|
||||
5. **Reorder Packages**: изменить base_packages через `-var`
|
||||
6. **Manual Purge**: удалить пакеты с VM по SSH → переустановить
|
||||
7. **Destroy**: terraform destroy → VM suspend
|
||||
8. **Resurrect**: apply после destroy
|
||||
9. **Stress Cycles**: N циклов destroy/apply
|
||||
10. **Final Sanity**: проверка VM + пакеты + plan
|
||||
|
||||
### Текущий статус
|
||||
- Скрипт проверен на синтаксис (`bash -n`): OK
|
||||
- Ожидает запуска первой итерации
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-29 — VM stress/chaos matrix: результаты
|
||||
|
||||
### Что прогнали
|
||||
|
||||
1. Ручной cleanup на целевой VM: удаление `jq`, `python3-pip`, `htop`, `unzip`, `nginx`, `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-compose-plugin`.
|
||||
2. `terraform destroy` на [examples/VM](examples/VM).
|
||||
3. Проверка SSH-доступа после destroy.
|
||||
4. `terraform apply` после destroy с восстановлением VM и jobs.
|
||||
5. Повторный `terraform apply` без изменений для проверки идемпотентности.
|
||||
6. Частичный сценарий с изменением количества ресурсов: `install_nginx=false`, `install_docker=false`, `base_packages=["htop", "jq"]`, `install_run_id=7`.
|
||||
7. Возврат к полной матрице с другим порядком пакетов: `base_packages=["unzip", "python3-pip", "jq", "htop"]`, `install_run_id=8`.
|
||||
8. Stress loop из 2 подряд идущих циклов `destroy -> apply`.
|
||||
|
||||
### Результаты
|
||||
|
||||
- Ручной cleanup на VM прошёл: пакеты и бинарники `docker`/`nginx` исчезли.
|
||||
- `terraform destroy` завершился успешно и вывел `Destroy complete! Resources: 5 destroyed.`
|
||||
- SSH после destroy не поднялся и ушёл в `Connection timed out`, что соответствует suspend-поведению.
|
||||
- `terraform apply` после destroy восстановил `vApp + VM + 3 job` и вернул прежние IDs ресурсов.
|
||||
- Повторный `apply` без изменений дал `No changes`.
|
||||
- Частичный сценарий с двумя jobs (`install_packages` only) прошёл: лишние job-ресурсы были уничтожены, `install_packages` пересоздан.
|
||||
- Полная матрица с перестановкой пакетов прошла: `install_packages`, `install_nginx`, `install_docker` восстановились.
|
||||
- Stress loop из 2 циклов `destroy -> apply` завершился без ошибок.
|
||||
|
||||
### Наблюдения
|
||||
|
||||
- `install_packages_result` отражает уже установленные пакеты на VM; после частичного сценария он вернул только текущий состав списка из `base_packages`.
|
||||
- `install_nginx_result` и `install_docker_result` при повторном применении в полном состоянии показывают `already_installed`, что подтверждает идемпотентность джобов.
|
||||
- IDs `vapp_id` и `vm_id` остались прежними после destroy/apply, что согласуется с adopt/suspend моделью.
|
||||
|
||||
## 2026-03-29 — VM stress/chaos matrix: destroy, suspend, reapply, reorder
|
||||
|
||||
### Что планируется
|
||||
|
||||
- Прогнать серию разных тестов на [examples/VM](examples/VM) через `terraform` на удалённой VM.
|
||||
- Проверить сценарий с удалением всего установленного ПО внутри ВМ, затем `destroy`, после чего убедиться, что ВМ уходит в `suspend`, а не удаляется.
|
||||
- Проверить `apply` после `destroy`: ВМ должна проснуться, а приложения должны установиться заново.
|
||||
- Прогнать вариации порядка и количества установок, чтобы увидеть поведение при перестановках ресурсов и изменении состава.
|
||||
- Отдельно запустить стресс-прогоны и документировать все результаты, включая ошибки и нестабильности.
|
||||
|
||||
### Что будет фиксироваться
|
||||
|
||||
- Команды и их итоговый статус.
|
||||
- Любые расхождения между планом и фактическим состоянием ВМ.
|
||||
- Ошибки `terraform`, `ssh` и установки пакетов.
|
||||
- Поведение suspend/resume и повторной установки после `apply`.
|
||||
|
||||
## 2026-03-28 — Handoff: функции-джобы для установки ПО в ВМ (курс на PostgreSQL)
|
||||
|
||||
### Контекст и цель
|
||||
|
||||
- Пример [examples/VM](examples/VM) рассматривается как пользовательский Terraform-шаблон.
|
||||
- Пользовательский сценарий: скачать шаблон, подставить токены/ключи, выбрать нужные пакеты, выполнить `terraform apply`.
|
||||
- Целевое поведение: один apply поднимает `vApp + VM` и запускает автоматическую установку ПО в VM.
|
||||
- Vault в облаке пока недоступен, но архитектура должна быть ready для последующего перехода на Vault без переписывания логики функций.
|
||||
|
||||
### Что выяснили по текущей платформе (sless)
|
||||
|
||||
- Для one-shot действий в sless уже есть подходящая сущность: `sless_job`.
|
||||
- Модель запуска:
|
||||
1. Создаётся job-ресурс.
|
||||
2. Загружается исходник функции (`/upload`).
|
||||
3. Оператор собирает образ (kaniko).
|
||||
4. Job запускается в k8s, статус виден как `Pending/Building/Running/Succeeded/Failed`.
|
||||
- Передача входных параметров:
|
||||
1. `event_json` -> payload в `handle(event)`.
|
||||
2. `env_vars` -> переменные окружения внутри контейнера.
|
||||
- Ошибки установки отслеживаются на нескольких уровнях:
|
||||
1. Terraform apply (resource fail).
|
||||
2. Статус job (`Phase`, `Message`).
|
||||
3. Логи пода/контейнера (детали SSH/apt/команд).
|
||||
|
||||
### Архитектурное решение на сейчас
|
||||
|
||||
- Не делать отдельный новый Terraform resource под установку пакетов на текущем этапе.
|
||||
- Использовать `sless_job` как основной механизм выполнения.
|
||||
- Причина: установка ПО по SSH в VM - это execution-задача (one-shot), а не устойчивый ресурс со сложной моделью state/drift.
|
||||
- Отдельный resource рассматривать позже, когда стабилизируется контракт (semantics ensure-present/absent/version + read/drift).
|
||||
|
||||
### Принятый целевой дизайн (этап 1)
|
||||
|
||||
1. Универсальная функция-джоб `install-packages`.
|
||||
2. Отдельные специализированные функции-джобы: `install-docker`, `install-git`, далее по необходимости.
|
||||
3. В Terraform-шаблоне флаги/переменные включают нужные джобы.
|
||||
4. Общая схема параметров:
|
||||
: `event_json` для бизнес-параметров (список пакетов, режимы).
|
||||
: `env_vars` для подключения к VM (`VM_IP`, `SSH_USER`, `SSH_KEY`).
|
||||
|
||||
### План по PostgreSQL-направлению (что делать дальше)
|
||||
|
||||
#### Этап A: Базовый VM bootstrap
|
||||
|
||||
1. Реализовать `install-packages` (apt update/install, идемпотентность, явные коды ошибок).
|
||||
2. Реализовать `install-git` как отдельный job-шаблон.
|
||||
3. Реализовать `install-docker` как отдельный job-шаблон (репозиторий/GPG, проверка `docker --version`).
|
||||
|
||||
#### Этап B: PostgreSQL-specific функции
|
||||
|
||||
1. Добавить `install-postgres` job:
|
||||
: установка `postgresql`, `postgresql-contrib`, `postgresql-client`.
|
||||
: проверка статуса `systemctl is-active postgresql`.
|
||||
2. Добавить `configure-postgres` job:
|
||||
: создание БД/пользователя.
|
||||
: настройка доступа (минимально безопасная, через параметры).
|
||||
: проверка подключения `psql`.
|
||||
3. Добавить `seed-postgres` job (опционально):
|
||||
: создание таблиц/базовых данных для демо.
|
||||
|
||||
#### Этап C: Terraform UX для пользователя шаблона
|
||||
|
||||
1. Вынести управляемые параметры в `terraform.tfvars`:
|
||||
: `install_packages`, `install_docker`, `install_git`, `install_postgres`.
|
||||
: `postgres_db`, `postgres_user` и др. параметры.
|
||||
2. Обеспечить зависимости:
|
||||
: VM должна быть готова до старта job.
|
||||
: Postgres-конфиг запускается после установки postgres.
|
||||
3. Добавить outputs с итоговым статусом job-ов для быстрого контроля.
|
||||
|
||||
#### Этап D: Переход на Vault (когда сервис появится)
|
||||
|
||||
1. Не менять код функций.
|
||||
2. Заменить только источник секретов в Terraform (`env_vars` заполняются из Vault data source).
|
||||
3. Сохранить обратную совместимость с текущим режимом (секрет в tfvars) для dev/demo.
|
||||
|
||||
### Риски и ограничения, зафиксированные заранее
|
||||
|
||||
1. SSH/apt операции подвержены временным сетевым сбоям и lock-файлам apt -> нужны retries и читаемые сообщения об ошибках.
|
||||
2. Job-модель не равна полноценному stateful resource: drift пакетов в VM не отслеживается автоматически Terraform-ом.
|
||||
3. Для production-пути позже потребуется отдельный контракт безопасности по секретам и ротации ключей.
|
||||
|
||||
### Что уже сделано в этой ветке перед handoff
|
||||
|
||||
1. Обновлён пример VM по nubes provider `5.0.49`.
|
||||
2. Переименованы имена VM/vApp ресурсов в более короткий формат (`vm-sless`, `vapp-sless`).
|
||||
3. Изменения закоммичены и отправлены в ветку `examples/dev-from-ground`.
|
||||
|
||||
### Рекомендация для нового чата
|
||||
|
||||
1. Стартовать реализацию с `install-packages` + Terraform wiring в [examples/VM](examples/VM).
|
||||
2. После успешного E2E добавить `install-postgres` и `configure-postgres`.
|
||||
3. Держать код максимально идемпотентным, чтобы повторный apply не ломал VM.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// 2026-03-26 — main.tf: провайдер Nubes для DEV-стенда.
|
||||
// DEV API endpoint: https://deck-api-dev.ngcloud.ru/api/v1
|
||||
// Токен: secrets/dev.token (tazet@narod.ru)
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "terra.k8c.ru/nubes/nubes"
|
||||
version = "5.0.31"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "Nubes API токен (DEV-стенд). Значение — в terraform.tfvars."
|
||||
}
|
||||
|
||||
variable "resource_realm" {
|
||||
type = string
|
||||
description = "Платформа развёртывания (например k8s-3.ext.nubes.ru). Уточнить у сервис-менеджера."
|
||||
}
|
||||
|
||||
provider "nubes" {
|
||||
api_token = var.api_token
|
||||
api_endpoint = "https://deck-api-dev.ngcloud.ru/api/v1/index.cfm"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 2026-03-26 — vc_org.tf: ресурс «Организация в Cloud Director» для DEV-стенда.
|
||||
// nubes_vc_org — тенант vCloud Director (organization_type = "iaas").
|
||||
// resource_realm задаётся через переменную (terraform.tfvars или -var).
|
||||
|
||||
resource "nubes_vc_org" "dev_org" {
|
||||
resource_name = "vcOrg-2"
|
||||
resource_realm = var.resource_realm
|
||||
|
||||
# organization_type "iaas" — единственный вариант с доступом к организации.
|
||||
# Значение по умолчанию "iaas", явно прописано для читаемости.
|
||||
organization_type = "iaas"
|
||||
|
||||
# v_i_p_configure — JSON-список ipSpaces для операции modify.
|
||||
# При create провайдер не передаёт его в API, но требует non-null значение в плане.
|
||||
v_i_p_configure = ""
|
||||
|
||||
# adopt_existing_on_create = true — берёт существующий инстанс (dev-org-sless-demo уже создан с null realm от предыдущей попытки).
|
||||
adopt_existing_on_create = true
|
||||
|
||||
# suspend_on_destroy = true (по умолчанию) — при destroy инстанс уходит в Suspend, не удаляется.
|
||||
suspend_on_destroy = true
|
||||
}
|
||||
|
||||
# ─── Outputs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
output "dev_org_id" {
|
||||
description = "ID созданной организации (используется в зависимых ресурсах)"
|
||||
value = nubes_vc_org.dev_org.id
|
||||
}
|
||||
|
||||
output "dev_org_state_flat" {
|
||||
description = "Плоский state организации — endpoints, статусы"
|
||||
value = nubes_vc_org.dev_org.state_out_flat
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Создано: 2026-03-23
|
||||
// main.tf — провайдер Nubes + переменные для примера NODEJS.
|
||||
// Ресурс nubes_nodejs: managed Node.js приложение в облаке (не sless-функция).
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "terra.k8c.ru/nubes/nubes"
|
||||
version = "5.0.19"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "realm" {
|
||||
type = string
|
||||
description = "resource_realm — зона размещения ресурса (например: k8s-3-sandbox-nubes-ru)"
|
||||
}
|
||||
|
||||
variable "git_path" {
|
||||
type = string
|
||||
description = "URL git-репозитория с кодом приложения"
|
||||
}
|
||||
|
||||
provider "nubes" {
|
||||
api_token = var.api_token
|
||||
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Создано: 2026-03-23
|
||||
# nodejs.tf — ресурс nubes_nodejs: managed Node.js приложение.
|
||||
# Параметры взяты из документации terra.k8c.ru/docs/nubes/nubes/5.0.19/30_registry/resources/nodejs_params_create/
|
||||
|
||||
resource "nubes_nodejs" "app" {
|
||||
resource_name = "nodejsdemo1"
|
||||
domain = "domma"
|
||||
resource_realm = var.realm
|
||||
git_path = var.git_path
|
||||
app_version = "23"
|
||||
resource_c_p_u = 500
|
||||
resource_memory = 1024
|
||||
resource_instances = 1
|
||||
json_env = jsonencode({})
|
||||
adopt_existing_on_create = true
|
||||
# health_path не задан — используется дефолтный /
|
||||
}
|
||||
|
||||
output "nodejs_domain" {
|
||||
description = "Домен развёрнутого Node.js приложения"
|
||||
value = nubes_nodejs.app.domain
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// 2026-03-21 — chaos_marathon.tf: 15 новых сервисов для часового хаос-марафона.
|
||||
// Два рантайма: python3.11 (9), nodejs20 (2).
|
||||
// Все зависят от sless_job.postgres_table_init_job.
|
||||
|
||||
# ── Python: работа с таблицей ─────────────────────────────────────────────────
|
||||
|
||||
# Считает строки по prefix — тест concurrent reads + COUNT агрегации.
|
||||
resource "sless_service" "pg_counter" {
|
||||
name = "pg-counter"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_counter.count"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-counter"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# DELETE дублей по title — идемпотентный, повторный вызов безопасен.
|
||||
resource "sless_service" "pg_dedup" {
|
||||
name = "pg-dedup"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_dedup.dedup"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-dedup"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Поиск по title с ILIKE + пагинация — тест спецсимволов и SQL injection safety.
|
||||
resource "sless_service" "pg_search" {
|
||||
name = "pg-search"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_search.search"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-search"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Bulk INSERT через execute_values — до 500 строк за раз.
|
||||
resource "sless_service" "pg_bulk_insert" {
|
||||
name = "pg-bulk-insert"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_bulk_insert.bulk_insert"
|
||||
memory_mb = 256
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-bulk-insert"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# DELETE строк старше N минут — идемпотентный.
|
||||
resource "sless_service" "pg_delete_old" {
|
||||
name = "pg-delete-old"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_delete_old.delete_old"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-delete-old"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# INSERT ON CONFLICT DO UPDATE — повторный вызов с тем же title безопасен.
|
||||
resource "sless_service" "pg_upsert" {
|
||||
name = "pg-upsert"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_upsert.upsert"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-upsert"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python: chaos ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Echo: принимает любой ввод и отражает обратно — проверка на мусорный input.
|
||||
resource "sless_service" "chaos_echo" {
|
||||
name = "chaos-echo"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_echo.echo"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
|
||||
source_dir = "${path.module}/code/chaos-echo"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Валидация плохих параметров — тупой юзер не может уронить сервис.
|
||||
resource "sless_service" "chaos_badparams" {
|
||||
name = "chaos-badparams"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_badparams.validate"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
|
||||
source_dir = "${path.module}/code/chaos-badparams"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Медленный pg_sleep — тест timeout enforcement.
|
||||
resource "sless_service" "chaos_slowquery" {
|
||||
name = "chaos-slowquery"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_slowquery.slowquery"
|
||||
memory_mb = 128
|
||||
timeout_sec = 12
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/chaos-slowquery"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Большой JSON response — тест памяти и серилизации.
|
||||
resource "sless_service" "chaos_bigpayload" {
|
||||
name = "chaos-bigpayload"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "chaos_bigpayload.bigpayload"
|
||||
memory_mb = 256
|
||||
timeout_sec = 15
|
||||
|
||||
source_dir = "${path.module}/code/chaos-bigpayload"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Bulk INSERT через параметризованный multi-value query.
|
||||
resource "sless_service" "js_pg_batch" {
|
||||
name = "js-pg-batch"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "js_pg_batch.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/js-pg-batch"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Идемпотентный INSERT — повторный вызов с тем же key = existing, не дубль.
|
||||
resource "sless_service" "js_idempotent" {
|
||||
name = "js-idempotent"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "js_idempotent.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/js-idempotent"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python: retry ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Запись с retry при transient PG error — тест устойчивости к сбоям.
|
||||
resource "sless_service" "py_retry_writer" {
|
||||
name = "py-retry-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "py_retry_writer.retry_write"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/py-retry-writer"
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Создано: 2026-04-10
|
||||
// Демо-функция: возвращает текущее время сервера.
|
||||
// Юзер меняет код под себя и перебилдит через terraform apply.
|
||||
|
||||
'use strict';
|
||||
|
||||
module.exports.handler = function handler(event) {
|
||||
return `Текущее время: ${new Date().toISOString()}`;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Создано: 2026-04-10
|
||||
# Изменено: 2026-03-23 — упрощён до поля ввода выражения (демонстрация деплоя).
|
||||
# Принимает произвольное математическое выражение: "2+2*(3-1)", "(10/3)**2" и т.д.
|
||||
# GET → HTML страница с формой; POST с {expr} → вычисление через безопасный eval.
|
||||
# Безопасность eval: __builtins__=None, только math-функции в locals.
|
||||
|
||||
import math
|
||||
|
||||
_PAGE = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Калькулятор — Python 3.11</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #0f172a; color: #e2e8f0;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
|
||||
.box { background: #1e293b; border-radius: 12px; padding: 32px; width: 420px; box-shadow: 0 8px 32px #0005; }
|
||||
h2 { margin: 0 0 4px; font-size: 20px; color: #7dd3fc; }
|
||||
.sub { color: #475569; font-size: 12px; margin-bottom: 24px; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 10px 14px; font-size: 18px; font-family: monospace;
|
||||
background: #0f172a; border: 1px solid #334155; border-radius: 8px; color: #f1f5f9; outline: none; }
|
||||
input:focus { border-color: #38bdf8; }
|
||||
button { margin-top: 12px; width: 100%; padding: 12px; font-size: 16px; background: #0369a1;
|
||||
color: #fff; border: none; border-radius: 8px; cursor: pointer; }
|
||||
button:hover { background: #0284c7; }
|
||||
button:disabled { background: #1e3a5f; color: #475569; cursor: default; }
|
||||
.result { margin-top: 20px; padding: 14px; border-radius: 8px; font-size: 22px; text-align: center; display: none; }
|
||||
.ok { background: #064e3b; color: #6ee7b7; display: block; }
|
||||
.err { background: #450a0a; color: #fca5a5; font-size: 14px; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>Калькулятор</h2>
|
||||
<div class="sub">Python 3.11 · runtime: sless</div>
|
||||
<input id="expr" autofocus placeholder="например: 2 + 2 * (3 - 1)">
|
||||
<button id="btn" onclick="calc()">Вычислить</button>
|
||||
<div id="result" class="result"></div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('expr').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') calc();
|
||||
});
|
||||
async function calc() {
|
||||
const expr = document.getElementById('expr').value.trim();
|
||||
if (!expr) return;
|
||||
const btn = document.getElementById('btn');
|
||||
const res = document.getElementById('result');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch('', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({expr: expr})
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) {
|
||||
res.className = 'result err';
|
||||
res.textContent = data.error;
|
||||
} else {
|
||||
res.className = 'result ok';
|
||||
res.textContent = expr + ' = ' + data.result;
|
||||
}
|
||||
} catch(e) {
|
||||
res.className = 'result err';
|
||||
res.textContent = 'Ошибка сети: ' + e.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Вычислить';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# Разрешённые math-функции в eval — без __builtins__ нет доступа к exec/open/etc.
|
||||
_MATH_LOCALS = {k: getattr(math, k) for k in dir(math) if not k.startswith('_')}
|
||||
|
||||
|
||||
def handler(event):
|
||||
if event.get('_method') == 'POST':
|
||||
expr = str(event.get('expr', '')).strip()
|
||||
return _compute(expr)
|
||||
# GET → HTML страница
|
||||
return _PAGE
|
||||
|
||||
|
||||
def _compute(expr):
|
||||
if not expr:
|
||||
return {'error': 'Введите выражение'}
|
||||
try:
|
||||
result = eval(expr, {'__builtins__': None}, _MATH_LOCALS) # noqa: S307
|
||||
if not isinstance(result, (int, float)):
|
||||
return {'error': 'Результат не является числом'}
|
||||
return {'expr': expr, 'result': result}
|
||||
except ZeroDivisionError:
|
||||
return {'error': 'Деление на ноль'}
|
||||
except Exception as exc:
|
||||
return {'error': f'Ошибка: {exc}'}
|
||||
|
||||
|
||||
def _esc(s):
|
||||
# Экранируем HTML-спецсимволы — безопасный вывод в атрибут и тело.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
@@ -0,0 +1 @@
|
||||
# нет внешних зависимостей
|
||||
@@ -1,40 +0,0 @@
|
||||
# 2026-03-21 — chaos-badparams: проверяет что функция не падает на мусорных входных данных.
|
||||
# Принимает type=missing|wrong_type|huge|negative|zero и возвращает safe-ответ.
|
||||
# Тестирует: устойчивость к "тупому юзеру" — никакого 500 на плохих входных данных.
|
||||
import json
|
||||
|
||||
_MAX_N = 10_000
|
||||
|
||||
def validate(event):
|
||||
errors = []
|
||||
results = {}
|
||||
|
||||
# n: должно быть int от 1 до MAX_N
|
||||
raw_n = event.get("n")
|
||||
try:
|
||||
n = int(raw_n)
|
||||
if n <= 0:
|
||||
errors.append(f"n must be > 0, got {n}")
|
||||
n = 1
|
||||
elif n > _MAX_N:
|
||||
errors.append(f"n capped from {n} to {_MAX_N}")
|
||||
n = _MAX_N
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"n is not a valid int: {repr(raw_n)}, using default 1")
|
||||
n = 1
|
||||
results["n"] = n
|
||||
|
||||
# name: обрезаем до 100 символов
|
||||
raw_name = event.get("name", "")
|
||||
if not isinstance(raw_name, str):
|
||||
raw_name = str(raw_name)
|
||||
errors.append("name was not a string, converted")
|
||||
name = raw_name[:100]
|
||||
results["name"] = name
|
||||
|
||||
# flag: любое "truthy" значение
|
||||
raw_flag = event.get("flag", False)
|
||||
flag = raw_flag in (True, "true", "1", 1, "yes")
|
||||
results["flag"] = flag
|
||||
|
||||
return {"ok": len(errors) == 0, "errors": errors, "results": results}
|
||||
@@ -1,27 +0,0 @@
|
||||
# 2026-03-21 — chaos-bigpayload: генерирует/принимает большой JSON.
|
||||
# Тестирует: большие ответы (64KB+), память рантайма.
|
||||
import json, time
|
||||
|
||||
def bigpayload(event):
|
||||
size_kb = min(int(event.get("size_kb", 16)), 256) # cap 256KB
|
||||
word = str(event.get("word", "x"))[:32]
|
||||
|
||||
# Генерируем список строк нужного размера
|
||||
chunk = word * 32 # ~32+ байт на запись
|
||||
items = []
|
||||
total = 0
|
||||
target = size_kb * 1024
|
||||
i = 0
|
||||
while total < target:
|
||||
entry = f"{chunk}-{i}"
|
||||
items.append(entry)
|
||||
total += len(entry) + 3 # 3 байта JSON overhead
|
||||
i += 1
|
||||
|
||||
return {
|
||||
"items_count": len(items),
|
||||
"size_kb_approx": round(total / 1024, 1),
|
||||
"first": items[0] if items else "",
|
||||
"last": items[-1] if items else "",
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-echo: отражает входные данные обратно.
|
||||
# Тестирует: большие payload, unicode, null, вложенные структуры, спецсимволы.
|
||||
# "Тупой юзер" шлёт всё что угодно — функция должна вернуть это обратно без падения.
|
||||
import json
|
||||
|
||||
def echo(event):
|
||||
# Пытаемся сериализовать обратно — выловит непериализуемые типы
|
||||
try:
|
||||
size = len(json.dumps(event))
|
||||
except Exception:
|
||||
size = -1
|
||||
|
||||
keys = list(event.keys()) if isinstance(event, dict) else []
|
||||
return {
|
||||
"echo": event,
|
||||
"keys": keys,
|
||||
"size_bytes": size,
|
||||
"type": type(event).__name__,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-slowquery: намеренно медленный запрос через pg_sleep.
|
||||
# Тестирует: timeout enforcement — платформа должна прервать запрос если > timeout_sec.
|
||||
# sleep_sec cap = 8 (меньше timeout_sec=10 сервиса → успех; >10 → таймаут платформы).
|
||||
import os, psycopg2
|
||||
|
||||
def slowquery(event):
|
||||
sleep_sec = min(float(event.get("sleep_sec", 2.0)), 8.0)
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_sleep(%s), now()::text", (sleep_sec,))
|
||||
result = cur.fetchone()
|
||||
return {"slept_sec": sleep_sec, "pg_now": result[1]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,94 +0,0 @@
|
||||
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
|
||||
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
|
||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
|
||||
#
|
||||
# Env vars:
|
||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
|
||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
|
||||
|
||||
import os
|
||||
import requests
|
||||
|
||||
SEP = "─" * 52
|
||||
|
||||
|
||||
def _comment(fn, http_trigs, cron_trigs):
|
||||
phase = fn.get("phase", "?")
|
||||
runtime = fn.get("runtime", "?")
|
||||
if http_trigs:
|
||||
active = "активна" if http_trigs[0].get("active") else "неактивна"
|
||||
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
|
||||
elif cron_trigs:
|
||||
schedule = cron_trigs[0].get("schedule", "?")
|
||||
active = "активна" if cron_trigs[0].get("active") else "неактивна"
|
||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
|
||||
else:
|
||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
||||
|
||||
|
||||
def list_all(event):
|
||||
api_url = os.environ["SLESS_API_URL"].rstrip("/")
|
||||
namespace = os.environ["SLESS_NAMESPACE"]
|
||||
token = os.environ["SLESS_TOKEN"]
|
||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
||||
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
|
||||
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
|
||||
fns.raise_for_status()
|
||||
trs.raise_for_status()
|
||||
|
||||
trig_idx = {}
|
||||
for tr in trs.json():
|
||||
fn_name = tr.get("function") or tr.get("functionRef")
|
||||
if fn_name:
|
||||
trig_idx.setdefault(fn_name, []).append(tr)
|
||||
|
||||
items = []
|
||||
for fn in fns.json():
|
||||
name = fn["name"]
|
||||
if name in exclude:
|
||||
continue
|
||||
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
|
||||
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
|
||||
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
|
||||
items.append((fn, http_t, cron_t, is_active))
|
||||
|
||||
# Сортировка: активные вверх, затем по имени
|
||||
items.sort(key=lambda x: (not x[3], x[0]["name"]))
|
||||
|
||||
lines = []
|
||||
for fn, http_t, cron_t, is_active in items:
|
||||
name = fn["name"]
|
||||
lines.append(SEP)
|
||||
lines.append(f" {_comment(fn, http_t, cron_t)}")
|
||||
lines.append(f" name: {name}")
|
||||
lines.append(f" runtime: {fn.get('runtime', '?')}")
|
||||
lines.append(f" phase: {fn.get('phase', '?')}")
|
||||
lines.append(f" active: {'да' if is_active else 'нет'}")
|
||||
|
||||
if http_t:
|
||||
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
|
||||
lines.append(f" url: {url}")
|
||||
if cron_t:
|
||||
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
|
||||
if fn.get("created_at"):
|
||||
lines.append(f" created: {fn['created_at']}")
|
||||
if fn.get("last_built_at"):
|
||||
lines.append(f" built: {fn['last_built_at']}")
|
||||
if fn.get("message"):
|
||||
lines.append(f" message: {fn['message']}")
|
||||
|
||||
lines.append(SEP)
|
||||
lines.append(f" namespace: {namespace} | total: {len(items)}")
|
||||
lines.append(SEP)
|
||||
|
||||
# Возвращаем str — python runtime отдаст text/plain напрямую
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
requests==2.31.0
|
||||
@@ -1,43 +0,0 @@
|
||||
// 2026-03-21 — js-pg-batch: вставляет N строк через parameterized bulk query.
|
||||
// Тестирует: async/await PG с пакетной вставкой, Node.js под нагрузкой.
|
||||
const { Client } = require('pg');
|
||||
|
||||
async function run(event) {
|
||||
const n = Math.min(parseInt(event.n ?? 20, 10) || 20, 200);
|
||||
const prefix = String(event.prefix ?? 'js-batch').slice(0, 40);
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT ?? '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const ts = Date.now();
|
||||
// Строим multi-value INSERT: INSERT INTO ... VALUES ($1), ($2), ...
|
||||
const placeholders = [];
|
||||
const values = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
placeholders.push(`($${i + 1})`);
|
||||
values.push(`${prefix}-${ts}-${i}`);
|
||||
}
|
||||
const sql = `INSERT INTO terraform_demo_table (title) VALUES ${placeholders.join(',')} RETURNING id`;
|
||||
const t0 = Date.now();
|
||||
const res = await client.query(sql, values);
|
||||
const elapsed = (Date.now() - t0) / 1000;
|
||||
|
||||
return {
|
||||
inserted: res.rowCount,
|
||||
first_id: res.rows[0]?.id ?? null,
|
||||
elapsed_sec: elapsed,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "js-pg-batch",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.3"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-bulk-insert: bulk INSERT через execute_values.
|
||||
# Тестирует: большие батчи (до 500 строк), производительность, память.
|
||||
import os, time, psycopg2, psycopg2.extras
|
||||
|
||||
def bulk_insert(event):
|
||||
try:
|
||||
n = max(0, min(int(event.get("n", 50)), 500)) # cap 500, min 0
|
||||
except (TypeError, ValueError):
|
||||
n = 50
|
||||
prefix = str(event.get("prefix", "bulk"))[:50]
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
# n=0 — граничный случай: вернуть сразу без обращения к PG.
|
||||
if n == 0:
|
||||
return {"inserted": 0, "first_id": None, "elapsed_sec": 0.0}
|
||||
|
||||
rows = [(f"{prefix}-{ts}-{i}",) for i in range(n)]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
t0 = time.time()
|
||||
with conn.cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur,
|
||||
"INSERT INTO terraform_demo_table (title) VALUES %s RETURNING id",
|
||||
rows,
|
||||
page_size=100,
|
||||
)
|
||||
ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
elapsed = round(time.time() - t0, 3)
|
||||
return {"inserted": len(ids), "first_id": ids[0] if ids else None, "elapsed_sec": elapsed}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-dedup: удаляет дубликаты по title, оставляет первый (min id).
|
||||
# Тестирует: DELETE с subquery, CTE, idempotency (повторный вызов безопасен).
|
||||
import os, psycopg2
|
||||
|
||||
def dedup(event):
|
||||
dry_run = str(event.get("dry_run", "false")).lower() in ("true", "1", "yes")
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Считаем сколько дублей есть
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM terraform_demo_table t1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM terraform_demo_table t2
|
||||
WHERE t2.title = t1.title AND t2.id < t1.id
|
||||
)
|
||||
""")
|
||||
dupes_count = cur.fetchone()[0]
|
||||
|
||||
if not dry_run and dupes_count > 0:
|
||||
cur.execute("""
|
||||
DELETE FROM terraform_demo_table
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM terraform_demo_table GROUP BY title
|
||||
)
|
||||
""")
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
else:
|
||||
deleted = 0
|
||||
|
||||
return {"duplicates_found": dupes_count, "deleted": deleted, "dry_run": dry_run}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,33 +0,0 @@
|
||||
# 2026-03-21 — pg-delete-old: удаляет строки старше N минут (default 60).
|
||||
# Тестирует: DELETE с RETURNING, идемпотентность (повторный вызов = 0 удалений если нет старых).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def delete_old(event):
|
||||
older_than_min = max(int(event.get("older_than_min", 60)), 1)
|
||||
prefix_filter = event.get("prefix", "")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
if prefix_filter:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s "
|
||||
"AND title LIKE %s RETURNING id, title",
|
||||
(older_than_min, f"{prefix_filter}%"),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s RETURNING id, title",
|
||||
(older_than_min,),
|
||||
)
|
||||
deleted = [dict(r) for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
return {"deleted": len(deleted), "older_than_min": older_than_min, "sample": deleted[:5]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-search: полнотекстовый поиск по title через ILIKE + LIMIT/OFFSET.
|
||||
# Тестирует: пагинацию, спецсимволы в input (XSS, SQL injection attempt → безопасно через параметры).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def search(event):
|
||||
# «query» — основной параметр (user-friendly), «q» — алиас для совместимости.
|
||||
query = str(event.get("query") or event.get("q") or "")[:200]
|
||||
# int() может упасть если юзер прислал строку — защищаем try/except.
|
||||
try:
|
||||
limit = max(1, min(int(event.get("limit", 20)), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
try:
|
||||
offset = max(0, int(event.get("offset", 0)))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
pattern = f"%{query}%" if query else "%"
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table "
|
||||
"WHERE title ILIKE %s ORDER BY id DESC LIMIT %s OFFSET %s",
|
||||
(pattern, limit, offset),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title ILIKE %s", (pattern,))
|
||||
total = cur.fetchone()["count"]
|
||||
return {"rows": rows, "count": len(rows), "total": total, "q": query, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-upsert: INSERT ... ON CONFLICT (title) DO UPDATE.
|
||||
# Тестирует: идемпотентность вставки — один и тот же title можно вызывать 100 раз подряд.
|
||||
# Требует уникального индекса на title — создаётся при первом вызове (CREATE UNIQUE INDEX IF NOT EXISTS).
|
||||
import os, psycopg2
|
||||
|
||||
def upsert(event):
|
||||
title = str(event.get("title", "upsert-default"))[:255]
|
||||
payload = str(event.get("payload", ""))[:500]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Создаём уникальный индекс если нет — для поддержки ON CONFLICT
|
||||
cur.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS terraform_demo_table_title_uniq "
|
||||
"ON terraform_demo_table (title)"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) "
|
||||
"ON CONFLICT (title) DO UPDATE SET created_at = now() "
|
||||
"RETURNING id, title, created_at::text, xmax",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
was_insert = row[3] == 0 # xmax=0 означает INSERT, иначе UPDATE
|
||||
conn.commit()
|
||||
return {
|
||||
"id": row[0], "title": row[1], "created_at": row[2],
|
||||
"action": "inserted" if was_insert else "updated",
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,54 +0,0 @@
|
||||
# 2026-03-21 — py-retry-writer: пишет N строк с retry при PG ошибке.
|
||||
# Тестирует: устойчивость к transient PG errors (simulate_error=true), retry logic,
|
||||
# корректный rollback при частичном сбое.
|
||||
import os, time, psycopg2, random
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
def retry_write(event):
|
||||
n = min(int(event.get("n", 5)), 100)
|
||||
prefix = str(event.get("prefix", "retry"))[:40]
|
||||
# simulate_error: с вероятностью 30% кидает OperationalError на 2-й попытке
|
||||
simulate = str(event.get("simulate_error", "false")).lower() in ("true", "1")
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt < _MAX_RETRIES:
|
||||
attempt += 1
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
# Симуляция: на первой попытке падаем с вероятностью 50%
|
||||
if simulate and attempt == 1 and i == n // 2:
|
||||
raise psycopg2.OperationalError("simulated transient error")
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}-a{attempt}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
inserted.append(cur.fetchone()[0])
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True, "inserted": len(inserted),
|
||||
"attempts": attempt, "first_id": inserted[0] if inserted else None,
|
||||
}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
except psycopg2.OperationalError as e:
|
||||
last_err = str(e)
|
||||
if attempt < _MAX_RETRIES:
|
||||
time.sleep(0.3 * attempt) # exponential backoff
|
||||
continue
|
||||
|
||||
return {"ok": False, "attempts": attempt, "last_error": last_err}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,3 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# requirements.txt — зависимости для функции запуска SQL.
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
|
||||
def run_sql(event):
|
||||
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
|
||||
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
|
||||
pg_host = os.environ["PGHOST"]
|
||||
pg_port = os.environ.get("PGPORT", "5432")
|
||||
pg_database = os.environ["PGDATABASE"]
|
||||
pg_user = os.environ["PGUSER"]
|
||||
pg_password = os.environ["PGPASSWORD"]
|
||||
pg_sslmode = os.environ.get("PGSSLMODE", "require")
|
||||
statements = event.get("statements", [])
|
||||
|
||||
if not statements:
|
||||
return {"error": "no statements provided"}
|
||||
|
||||
connection = psycopg2.connect(
|
||||
host=pg_host,
|
||||
port=pg_port,
|
||||
dbname=pg_database,
|
||||
user=pg_user,
|
||||
password=pg_password,
|
||||
sslmode=pg_sslmode,
|
||||
)
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
for statement in statements:
|
||||
cursor.execute(statement)
|
||||
connection.commit()
|
||||
return {"ok": True, "executed": len(statements)}
|
||||
except Exception as error:
|
||||
connection.rollback()
|
||||
return {"error": str(error)}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1,20 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел.
|
||||
# Проверяет поведение под нагрузкой (большая и средняя итерация).
|
||||
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("n", 500_000))
|
||||
start = time.monotonic()
|
||||
total = sum(i * i for i in range(n))
|
||||
elapsed = round(time.monotonic() - start, 4)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"n": n,
|
||||
"sum_of_squares": total,
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError).
|
||||
# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под.
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
numerator = int(event.get("n", 42))
|
||||
denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш
|
||||
# ZeroDivisionError: проверяем что платформа обрабатывает исключения
|
||||
result = numerator / denominator
|
||||
return {"version": _VERSION, "result": result}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-async",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all.
|
||||
// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await.
|
||||
//
|
||||
// Entrypoint: stress_js_async.run
|
||||
|
||||
'use strict';
|
||||
|
||||
const { Client } = require('pg');
|
||||
|
||||
exports.run = async (event) => {
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT || '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const [ver, cnt, max] = await Promise.all([
|
||||
client.query('SELECT version() AS v'),
|
||||
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
|
||||
client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'),
|
||||
]);
|
||||
return {
|
||||
runtime: 'nodejs20',
|
||||
version: 'v1',
|
||||
pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '),
|
||||
total_rows: parseInt(cnt.rows[0].cnt, 10),
|
||||
max_id: max.rows[0].max_id,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-badenv",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_badenv.js — читает несуществующую переменную env и падает.
|
||||
// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500.
|
||||
//
|
||||
// Entrypoint: stress_js_badenv.run
|
||||
|
||||
'use strict';
|
||||
|
||||
exports.run = async (event) => {
|
||||
const crash = event.crash !== false; // по умолчанию crash=true
|
||||
if (crash) {
|
||||
// Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined
|
||||
const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL;
|
||||
return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined
|
||||
}
|
||||
return { runtime: 'nodejs20', version: 'v1', crashed: false };
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8).
|
||||
# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга.
|
||||
|
||||
import time
|
||||
import os
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
secs = int(event.get("sleep", 8))
|
||||
time.sleep(secs)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"slept_sec": secs,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5).
|
||||
# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке.
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("rows", 5))
|
||||
prefix = event.get("prefix", "stress")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=int(os.environ.get("PGPORT", "5432")),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
inserted.append({"id": row[0], "title": title})
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"version": _VERSION, "inserted": inserted, "count": len(inserted)}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,133 +0,0 @@
|
||||
# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода
|
||||
# table_rw.py — чтение и запись строк в terraform_demo_table.
|
||||
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
|
||||
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
_CODE_VERSION = "v2-with-hostname"
|
||||
|
||||
|
||||
def _connect():
|
||||
return psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=os.environ.get("PGPORT", "5432"),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
|
||||
|
||||
def list_rows(event):
|
||||
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_page(rows, message=""):
|
||||
# HTML-страница с формой ввода и таблицей строк.
|
||||
# message — статус последней операции (успех / ошибка).
|
||||
rows_html = "".join(
|
||||
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
|
||||
for r in rows
|
||||
)
|
||||
msg_html = f'<p class="msg">{message}</p>' if message else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>pg-table-writer</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
|
||||
h1 {{ color: #7dd3fc; }}
|
||||
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
|
||||
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
|
||||
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
|
||||
button:hover {{ background: #1d4ed8; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
|
||||
th {{ color: #7dd3fc; }}
|
||||
.msg {{ color: #4ade80; margin-bottom: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pg-table-writer</h1>
|
||||
<form method="POST">
|
||||
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
{msg_html}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def add_row(event):
|
||||
# GET → HTML-страница с формой и списком строк.
|
||||
# POST → вставляет строку из form-поля title или JSON-поля title,
|
||||
# затем возвращает обновлённую HTML-страницу.
|
||||
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
|
||||
method = event.get("_method", "GET")
|
||||
|
||||
if method == "GET":
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return _render_page(rows)
|
||||
|
||||
# POST — вставка строки
|
||||
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
|
||||
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
|
||||
title = event.get("title", "").strip()
|
||||
if not title:
|
||||
# Попытка распарсить form-encoded body (браузерная форма)
|
||||
body = event.get("body", "")
|
||||
if body.startswith("title="):
|
||||
from urllib.parse import unquote_plus
|
||||
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
|
||||
|
||||
if not title:
|
||||
return {"ok": False, "error": "title is required"}
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
|
||||
(title,),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
|
||||
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
|
||||
# Если из curl/API — возвращаем JSON.
|
||||
accept = event.get("_accept", "")
|
||||
if "application/json" in accept:
|
||||
return {"ok": True, "row": row}
|
||||
|
||||
# Перечитываем все строки для обновлённой страницы
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,105 +1,36 @@
|
||||
// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job)
|
||||
// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function.
|
||||
// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек).
|
||||
# Создано: 2026-04-10
|
||||
# functions.tf — sless_service ресурсы для примера POSTGRES.
|
||||
# Здесь: два калькуляторa — Python и Node.js.
|
||||
# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function).
|
||||
|
||||
# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается.
|
||||
# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job.
|
||||
resource "sless_job" "postgres_table_init_job" {
|
||||
name = "pg-create-table-job-main-v13"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "sql_runner.run_sql"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
source_dir = "${path.module}/code/sql-runner"
|
||||
wait_timeout_sec = 900
|
||||
run_id = 13
|
||||
# ─── Python-калькулятор ──────────────────────────────────────────────────────
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
event_json = jsonencode({
|
||||
statements = [
|
||||
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
|
||||
]
|
||||
})
|
||||
|
||||
depends_on = [nubes_postgres_database.db]
|
||||
}
|
||||
|
||||
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
|
||||
resource "sless_service" "pg_info" {
|
||||
name = "pg-info"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "pg_info.info"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/pg-info"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
resource "sless_service" "postgres_table_reader" {
|
||||
name = "pg-table-reader"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "table_rw.list_rows"
|
||||
resource "sless_service" "calc_python" {
|
||||
name = "calc-python"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "handler.handler"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/table-rw"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
source_dir = "${path.module}/code/calc-python"
|
||||
}
|
||||
|
||||
output "table_reader_url" {
|
||||
value = sless_service.postgres_table_reader.url
|
||||
output "calc_python_url" {
|
||||
description = "URL Python-калькулятора"
|
||||
value = sless_service.calc_python.url
|
||||
}
|
||||
|
||||
resource "sless_service" "postgres_table_writer" {
|
||||
name = "pg-table-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "table_rw.add_row"
|
||||
memory_mb = 256
|
||||
timeout_sec = 45
|
||||
# ─── Node.js-калькулятор ─────────────────────────────────────────────────────
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
source_dir = "${path.module}/code/table-rw"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
resource "sless_service" "calc_node" {
|
||||
name = "calc-node"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "handler.handler"
|
||||
memory_mb = 128
|
||||
timeout_sec = 30
|
||||
source_dir = "${path.module}/code/calc-node"
|
||||
}
|
||||
|
||||
output "table_writer_url" {
|
||||
value = sless_service.postgres_table_writer.url
|
||||
output "calc_node_url" {
|
||||
description = "URL Node.js-калькулятора"
|
||||
value = sless_service.calc_node.url
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "terra.k8c.ru/nubes/nubes"
|
||||
version = "5.0.19"
|
||||
version = "5.0.31"
|
||||
}
|
||||
sless = {
|
||||
source = "terra.k8c.ru/naeel/sless"
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// 2026-03-21 — stress.tf: все стресс-сервисы для комплексного тестирования.
|
||||
// Два рантайма: nodejs20 (2), python3.11 (5).
|
||||
// Все depends_on = [sless_job.postgres_table_init_job] — таблица должна существовать.
|
||||
|
||||
|
||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
||||
|
||||
# 3 параллельных PG-запроса через Promise.all. Проверяет async/await + nodejs20.
|
||||
resource "sless_service" "stress_js_async" {
|
||||
name = "stress-js-async"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "stress_js_async.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 20
|
||||
source_dir = "${path.module}/code/stress-js-async"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# TypeError через undefined.toUpperCase(). Без PG. Проверяет перехват JS-ошибок.
|
||||
resource "sless_service" "stress_js_badenv" {
|
||||
name = "stress-js-badenv"
|
||||
runtime = "nodejs20"
|
||||
entrypoint = "stress_js_badenv.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
source_dir = "${path.module}/code/stress-js-badenv"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ── Python 3.11 ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Спит N секунд. Без PG. Проверяет timeout и сосуществование долгих запросов.
|
||||
resource "sless_service" "stress_slow" {
|
||||
name = "stress-slow"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_slow.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 35
|
||||
source_dir = "${path.module}/code/stress-slow"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# CPU-нагрузка: сумма квадратов N чисел. Без PG. Проверяет compute-bound задачи.
|
||||
resource "sless_service" "stress_bigloop" {
|
||||
name = "stress-bigloop"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_bigloop.run"
|
||||
memory_mb = 256
|
||||
timeout_sec = 60
|
||||
source_dir = "${path.module}/code/stress-bigloop"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# ZeroDivisionError. Без PG. Проверяет перехват Python-исключений → HTTP 500.
|
||||
resource "sless_service" "stress_divzero" {
|
||||
name = "stress-divzero"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_divzero.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 10
|
||||
source_dir = "${path.module}/code/stress-divzero"
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Параллельный INSERT в terraform_demo_table через psycopg2. Проверяет PG-write.
|
||||
resource "sless_service" "stress_writer" {
|
||||
name = "stress-writer"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "stress_writer.run"
|
||||
memory_mb = 128
|
||||
timeout_sec = 60
|
||||
source_dir = "${path.module}/code/stress-writer"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
|
||||
# Агрегированная статистика terraform_demo_table (COUNT/MIN/MAX). Для мониторинга.
|
||||
resource "sless_service" "pg_stats" {
|
||||
name = "pg-stats"
|
||||
runtime = "python3.11"
|
||||
entrypoint = "pg_stats.get_stats"
|
||||
memory_mb = 128
|
||||
timeout_sec = 15
|
||||
source_dir = "${path.module}/code/pg-stats"
|
||||
|
||||
env_vars = {
|
||||
PGHOST = local.pg_host
|
||||
PGPORT = "5432"
|
||||
PGDATABASE = local.pg_database
|
||||
PGUSER = local.pg_username
|
||||
PGPASSWORD = local.pg_password
|
||||
PGSSLMODE = "require"
|
||||
}
|
||||
|
||||
depends_on = [sless_job.postgres_table_init_job]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# Пример: Виртуальная машина (vApp + VM) в Nubes vDC
|
||||
|
||||
Создаёт:
|
||||
- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC)
|
||||
- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk
|
||||
- **Serverless-джобы** — устанавливают ПО на ВМ по SSH после создания
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cp terraform.tfvars.template terraform.tfvars
|
||||
# Заполни terraform.tfvars (инструкция ниже)
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1 — Получить данные из Личного Кабинета
|
||||
|
||||
### API-токен
|
||||
|
||||
> Личный Кабинет → правый верхний угол → **«Профиль»** → **«API-токены»** → **«Создать токен»**
|
||||
|
||||
Скопируйте JWT-строку целиком (`eyJhbGciOiJS...`).
|
||||
Один токен работает для обоих провайдеров — nubes (облако) и sless (serverless).
|
||||
|
||||
### UUID сервисов (vdc_uid и nsxt_uid)
|
||||
|
||||
> Личный Кабинет → **«Мои сервисы»** → нужный сервис → **«Параметры инстанса»** → поле UUID
|
||||
|
||||
| Параметр | Что искать в ЛК |
|
||||
|---|---|
|
||||
| `vdc_uid` | Сервис **«Виртуальный датацентр (vDC)»** → UUID |
|
||||
| `nsxt_uid` | Сервис **«Сетевой шлюз периметра (Edge)»** → UUID |
|
||||
|
||||
UUID выглядит так: `e3c9e4f1-24da-4992-a003-f8a2a803a5f0`
|
||||
|
||||
> **Важно:** `vdc_uid` и `nsxt_uid` **не изменяются после первого `terraform apply`**.
|
||||
> Менять их нельзя — сломается terraform state.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2 — Сгенерировать SSH-ключ для ВМ
|
||||
|
||||
Публичный ключ прописывается в ВМ при создании — это **единственный** способ зайти по SSH.
|
||||
|
||||
```bash
|
||||
# Выполнить в папке examples/VM/
|
||||
ssh-keygen -t ed25519 -f ./vm_key -N "" -C "sless-demo-vm"
|
||||
```
|
||||
|
||||
Создаст два файла: `vm_key` (приватный) и `vm_key.pub` (публичный).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3 — Заполнить terraform.tfvars
|
||||
|
||||
```bash
|
||||
cp terraform.tfvars.template terraform.tfvars
|
||||
```
|
||||
|
||||
Открыть `terraform.tfvars` и заполнить:
|
||||
|
||||
```hcl
|
||||
api_token = "eyJhbGciOiJS..." # из ЛК (шаг 1)
|
||||
vm_public_key = "ssh-ed25519 AAAA..." # содержимое vm_key.pub (шаг 2)
|
||||
vdc_uid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # из ЛК (шаг 1)
|
||||
nsxt_uid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # из ЛК (шаг 1)
|
||||
```
|
||||
|
||||
Остальные параметры (`install_packages`, `base_packages` и т.д.) можно менять в любое время.
|
||||
|
||||
---
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
После успешного `apply` Terraform выведет:
|
||||
|
||||
```
|
||||
Outputs:
|
||||
|
||||
vm_id = "..."
|
||||
vm_state = {
|
||||
"externalIp" = "1.2.3.4"
|
||||
...
|
||||
}
|
||||
vapp_id = "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Подключение по SSH
|
||||
|
||||
```bash
|
||||
ssh -i ./vm_key ubuntu@<externalIp из vm_state>
|
||||
```
|
||||
|
||||
Логин всегда `ubuntu`.
|
||||
|
||||
---
|
||||
|
||||
## Управление установкой ПО
|
||||
|
||||
Установка выполняется через serverless-джобы — Terraform запускает k8s Job, который подключается к ВМ по SSH и устанавливает пакеты.
|
||||
|
||||
### Флаги установки (в terraform.tfvars)
|
||||
|
||||
| Переменная | Что делает | По умолчанию |
|
||||
|---|---|---|
|
||||
| `install_packages` | Устанавливает пакеты из `base_packages` | `true` |
|
||||
| `install_nginx` | Устанавливает nginx | `true` |
|
||||
| `install_docker` | Устанавливает Docker CE + docker-compose-plugin | `true` |
|
||||
|
||||
### Как изменить список пакетов
|
||||
|
||||
В `terraform.tfvars`:
|
||||
|
||||
```hcl
|
||||
base_packages = ["jq", "htop", "curl", "git", "python3-pip"]
|
||||
```
|
||||
|
||||
Любые стандартные apt-пакеты Ubuntu 22.04.
|
||||
После изменения — увеличьте `install_run_id` и выполните `terraform apply`.
|
||||
|
||||
### Как перезапустить установку
|
||||
|
||||
sless_job — разовый джоб. При повторном `apply` Terraform не перезапускает его если ничего не изменилось.
|
||||
Чтобы запустить все install-джобы заново — увеличьте `install_run_id` на 1:
|
||||
|
||||
```hcl
|
||||
# было:
|
||||
install_run_id = 3
|
||||
# стало:
|
||||
install_run_id = 4
|
||||
```
|
||||
|
||||
Затем `terraform apply`. Установка идемпотентна — повторное выполнение не ломает систему.
|
||||
|
||||
### Как отключить отдельный компонент
|
||||
|
||||
```hcl
|
||||
install_docker = false # не устанавливать Docker
|
||||
```
|
||||
|
||||
После `apply` ресурс `sless_job.install_docker` будет удалён из state.
|
||||
Docker на уже созданной ВМ останется — Terraform не удаляет пакеты.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
|
||||
```bash
|
||||
terraform destroy
|
||||
```
|
||||
|
||||
Порядок автоматический: сначала suspend → потом delete.
|
||||
Параметр `suspend_on_destroy = true` решает это — без него удаление упадёт с ошибкой Nubes _«Услуга не остановлена»_.
|
||||
|
||||
---
|
||||
|
||||
## Справочник параметров
|
||||
|
||||
### Можно менять в любое время
|
||||
|
||||
| Параметр | Файл | Эффект |
|
||||
|---|---|---|
|
||||
| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | ВМ будет изменена |
|
||||
| `install_packages/nginx/docker` | `terraform.tfvars` | Джоб добавится или удалится |
|
||||
| `base_packages` | `terraform.tfvars` | Пакеты изменятся — увеличить `install_run_id` + apply |
|
||||
| `install_run_id` | `terraform.tfvars` | Перезапускает все install-джобы |
|
||||
|
||||
### Нельзя менять после первого apply
|
||||
|
||||
| Параметр | Файл | Причина |
|
||||
|---|---|---|
|
||||
| `vdc_uid`, `nsxt_uid` | `terraform.tfvars` | Идентифицируют сервисы в terraform state |
|
||||
| `resource_name`, `vapp_name` | `vapp.tf` | Уникальные имена ресурсов в Nubes |
|
||||
| `image_vm`, `user_login` | `vm.tf` | Неизменяемые параметры ВМ |
|
||||
| `vm_public_key` | `terraform.tfvars` | Прописывается в ВМ один раз при создании |
|
||||
|
||||
---
|
||||
|
||||
## Файлы проекта
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `terraform.tfvars.template` | **Шаблон** — скопировать в `terraform.tfvars` и заполнить |
|
||||
| `terraform.tfvars` | Ваши значения (не в git — содержит секреты) |
|
||||
| `main.tf` | Провайдеры + переменные `api_token` и `vm_public_key` |
|
||||
| `variables.tf` | Все остальные переменные с описаниями |
|
||||
| `vapp.tf` | Ресурс vApp (контейнер ВМ) |
|
||||
| `vm.tf` | Ресурс ВМ (Ubuntu 22.04) |
|
||||
| `sless.tf` | Serverless-джобы для установки ПО |
|
||||
| `outputs.tf` | Вывод IP-адреса и ID ресурсов |
|
||||
| `vm_key` / `vm_key.pub` | SSH-ключ для доступа к ВМ |
|
||||
| `functions/` | Код Python-функций для install-джобов |
|
||||
@@ -0,0 +1,106 @@
|
||||
# VM Stress Test — Инструкция по запуску
|
||||
# 2026-03-30
|
||||
|
||||
## ⛔⛔⛔ КРИТИЧЕСКИЕ ПРАВИЛА ⛔⛔⛔
|
||||
|
||||
### ЗАПРЕЩЕНО (без исключений):
|
||||
- **НЕ РЕДАКТИРОВАТЬ** `terraform.tfvars` — там JWT-токен, потеря = катастрофа
|
||||
- **НЕ РЕДАКТИРОВАТЬ** `*.tf` файлы
|
||||
- **НЕ РЕДАКТИРОВАТЬ** `vm_stress_test.sh`
|
||||
- **НЕ ЗАПУСКАТЬ** `terraform` напрямую — только через скрипт
|
||||
- **НЕ СОЗДАВАТЬ** новые файлы в этой директории
|
||||
- **НЕ ДЕЛАТЬ** `sed`, `awk`, `cat >`, `tee` в terraform.tfvars
|
||||
|
||||
### ПОЧЕМУ:
|
||||
Предыдущая версия скрипта содержала функцию `write_tfvars()` которая
|
||||
перезаписывала `terraform.tfvars`. В процессе перезаписи был потерян
|
||||
JWT-токен `api_token` (1200+ символов). Это привело к полному отказу
|
||||
terraform и потере рабочего состояния. Восстановление заняло час.
|
||||
|
||||
### КАК РАБОТАЕТ НОВЫЙ СКРИПТ:
|
||||
Переменные переопределяются через `-var` в terraform CLI.
|
||||
Файл `terraform.tfvars` читается terraform автоматически,
|
||||
но **НИКОГДА не перезаписывается** скриптом.
|
||||
|
||||
После каждой фазы проверяется md5sum terraform.tfvars.
|
||||
Если файл изменился — **АВАРИЙНАЯ ОСТАНОВКА** (exit code 99).
|
||||
|
||||
---
|
||||
|
||||
## Запуск
|
||||
|
||||
### На VM (naeel@5.172.178.213):
|
||||
|
||||
```bash
|
||||
cd ~/terra/sless/examples/VM
|
||||
bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log
|
||||
```
|
||||
|
||||
### Быстрый прогон (без destroy/resurrect — фазы 7-9 пропускаются):
|
||||
|
||||
```bash
|
||||
SKIP_DESTROY=1 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log
|
||||
```
|
||||
|
||||
### Количество stress-циклов (default: 2):
|
||||
|
||||
```bash
|
||||
STRESS_CYCLES=3 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Анализ результатов
|
||||
|
||||
### Быстрый обзор:
|
||||
```bash
|
||||
grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log
|
||||
```
|
||||
|
||||
### Только ошибки:
|
||||
```bash
|
||||
grep '\[FAIL\]' /tmp/vm_stress.log
|
||||
```
|
||||
|
||||
### Итоговая сводка — последние 20 строк лога:
|
||||
```bash
|
||||
tail -20 /tmp/vm_stress.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Фазы теста
|
||||
|
||||
| # | Имя | Что делает |
|
||||
|---|-----------------|---------------------------------------------------|
|
||||
| 1 | BASELINE | apply с полным набором (packages+nginx+docker) |
|
||||
| 2 | IDEMPOTENT | plan → "No changes" (проверка идемпотентности) |
|
||||
| 3 | PARTIAL_DISABLE | отключить nginx + docker через -var |
|
||||
| 4 | PARTIAL_ENABLE | включить обратно nginx + docker |
|
||||
| 5 | REORDER_PACKAGES| изменить набор base_packages через -var |
|
||||
| 6 | MANUAL_PURGE | удалить пакеты с VM по SSH → переустановить |
|
||||
| 7 | DESTROY | terraform destroy → VM в suspend |
|
||||
| 8 | RESURRECT | apply после destroy → VM просыпается |
|
||||
| 9 | STRESS_CYCLES | N циклов destroy/apply подряд |
|
||||
|10 | FINAL_SANITY | финальная проверка VM + пакеты + plan |
|
||||
|
||||
---
|
||||
|
||||
## Текущее состояние (baseline)
|
||||
|
||||
5 ресурсов в state:
|
||||
- `nubes_vapp.vapp`
|
||||
- `nubes_vc_vm_v3.vm`
|
||||
- `sless_job.install_packages[0]`
|
||||
- `sless_job.install_nginx[0]`
|
||||
- `sless_job.install_docker[0]`
|
||||
|
||||
---
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Значение |
|
||||
|------|---------------------------------------------|
|
||||
| 0 | Все тесты PASS |
|
||||
| 1 | Есть FAIL (см. лог) |
|
||||
| 99 | terraform.tfvars был изменён — АВАРИЙНЫЙ СТОП |
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
paramiko
|
||||
# v6
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
paramiko
|
||||
# v6
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
paramiko
|
||||
# v6
|
||||
@@ -0,0 +1,42 @@
|
||||
// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes).
|
||||
// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный.
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "terra.k8c.ru/nubes/nubes"
|
||||
version = "5.0.51"
|
||||
}
|
||||
sless = {
|
||||
source = "terra.k8c.ru/naeel/sless"
|
||||
version = "~> 0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Переменные
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
variable "vm_public_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "Публичный SSH-ключ для ВМ. Приватный ключ: ~/terra/sless/examples/VM/vm_key"
|
||||
}
|
||||
|
||||
variable "api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "Nubes API token"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Провайдер
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm
|
||||
# UI облака (только браузер): https://deck-test.ngcloud.ru/
|
||||
provider "nubes" {
|
||||
api_token = var.api_token
|
||||
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# =============================================================================
|
||||
# terraform.tfvars.template — шаблон конфигурации примера «ВМ в Nubes vDC»
|
||||
# =============================================================================
|
||||
#
|
||||
# Скопируйте этот файл в terraform.tfvars и заполните все значения:
|
||||
#
|
||||
# cp terraform.tfvars.template terraform.tfvars
|
||||
#
|
||||
# terraform.tfvars НЕ коммитится в git (защищён .gitignore) —
|
||||
# он содержит секретные данные (API-токен, SSH-ключ).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. API-ТОКЕН
|
||||
# =============================================================================
|
||||
#
|
||||
# Один токен для обоих провайдеров: nubes (облако) и sless (serverless).
|
||||
#
|
||||
# Где взять:
|
||||
# Личный Кабинет Nubes → правый верхний угол → «Профиль» → «API-токены»
|
||||
# → кнопка «Создать токен» → скопируйте JWT-строку целиком.
|
||||
#
|
||||
# Токен выглядит так: eyJhbGciOiJS...длинная строка...
|
||||
# Вставьте в кавычки целиком, не разбивая на строки.
|
||||
#
|
||||
api_token = "ВСТАВИТЬ_API_ТОКЕН"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. SSH-КЛЮЧ ДЛЯ ВМ
|
||||
# =============================================================================
|
||||
#
|
||||
# Публичный ключ прописывается в ВМ при создании.
|
||||
# Приватный ключ нужен для SSH-подключения к ВМ.
|
||||
#
|
||||
# Как сгенерировать:
|
||||
# ssh-keygen -t ed25519 -f ./vm_key -N "" -C "sless-demo-vm"
|
||||
# # Создаст два файла: vm_key (приватный) и vm_key.pub (публичный)
|
||||
#
|
||||
# vm_key.pub уже есть в папке — скопируйте его содержимое сюда.
|
||||
# Строка выглядит так: ssh-ed25519 AAAA... имя-ключа
|
||||
#
|
||||
vm_public_key = "ВСТАВИТЬ_ПУБЛИЧНЫЙ_SSH_КЛЮЧ"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. UUID СЕРВИСОВ NUBES (вdc_uid и nsxt_uid)
|
||||
# =============================================================================
|
||||
#
|
||||
# Где взять:
|
||||
# Личный Кабинет → «Мои сервисы» → найдите нужный сервис → раздел
|
||||
# «Параметры инстанса» или «Технические параметры» → UUID.
|
||||
#
|
||||
# vdc_uid — это UUID услуги «Виртуальный датацентр (vDC)»
|
||||
# Пример раздела ЛК: Мои сервисы → vDC → [ваш vDC] → UUID
|
||||
#
|
||||
# nsxt_uid — это UUID услуги «Сетевой шлюз периметра (Edge)»
|
||||
# Пример раздела ЛК: Мои сервисы → Edge → [ваш Edge] → UUID
|
||||
#
|
||||
# Формат: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (UUID v4)
|
||||
#
|
||||
# ВАЖНО: эти значения не изменяются после создания vApp.
|
||||
# После первого terraform apply менять их нельзя — сломает state.
|
||||
#
|
||||
vdc_uid = "ВСТАВИТЬ_UUID_VDC"
|
||||
nsxt_uid = "ВСТАВИТЬ_UUID_NSXT"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. ФЛАГИ УСТАНОВКИ ПО НА ВМ
|
||||
# =============================================================================
|
||||
#
|
||||
# Что устанавливать при terraform apply.
|
||||
# Установка выполняется через serverless-джобы (sless_job) по SSH на ВМ.
|
||||
# Каждый флаг — отдельный джоб, они выполняются независимо.
|
||||
#
|
||||
# true = установить
|
||||
# false = не устанавливать (ресурс не создаётся вовсе)
|
||||
#
|
||||
install_packages = true # базовые apt-пакеты из списка base_packages ниже
|
||||
install_nginx = true # nginx (веб-сервер)
|
||||
install_docker = true # Docker CE + docker-compose-plugin
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. СПИСОК БАЗОВЫХ ПАКЕТОВ
|
||||
# =============================================================================
|
||||
#
|
||||
# Эти пакеты устанавливаются когда install_packages = true.
|
||||
# Любые стандартные apt-пакеты Ubuntu 22.04.
|
||||
#
|
||||
# Как изменить список:
|
||||
# - Добавьте пакет: base_packages = ["jq", "htop", "curl", "git"]
|
||||
# - Удалите пакет: уберите его из списка
|
||||
# - После изменения: увеличьте install_run_id (см. ниже) и terraform apply
|
||||
#
|
||||
base_packages = ["jq", "python3-pip", "htop", "unzip"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. RUN_ID — триггер повторного запуска джобов
|
||||
# =============================================================================
|
||||
#
|
||||
# sless_job — это разовые джобы (k8s Job). Terraform не перезапускает их
|
||||
# автоматически если код не изменился. Чтобы запустить ВСЕ install-джобы
|
||||
# заново (например, после изменения base_packages) — увеличьте это число на 1
|
||||
# и выполните terraform apply.
|
||||
#
|
||||
# Например: было install_run_id = 3 → стало install_run_id = 4 → apply
|
||||
#
|
||||
install_run_id = 1
|
||||
@@ -0,0 +1,28 @@
|
||||
// 2026-03-25 — vapp.tf: виртуальный каталог ВМ (vApp) в Nubes vDC.
|
||||
// nubes_vapp — контейнер для ВМ внутри Виртуального датацентра.
|
||||
// Обязательные поля: vdc_uid, nsxt_uid, vapp_name, resource_name.
|
||||
|
||||
resource "nubes_vapp" "vapp" {
|
||||
resource_name = "vm-sless-vapp"
|
||||
vapp_name = "vapp-sless" # Уникальное в рамках организации. Не изменяется после создания.
|
||||
vdc_uid = var.vdc_uid # UUID Услуги «Виртуальный датацентр (vDC)». В terraform.tfvars.
|
||||
nsxt_uid = var.nsxt_uid # UUID Услуги «Сетевой шлюз периметра (Edge)». В terraform.tfvars.
|
||||
|
||||
adopt_existing_on_create = true
|
||||
operation_timeout = "15m"
|
||||
|
||||
# ВАЖНО: delete без предварительного suspend завершается ошибкой
|
||||
# "Невозможно выполнить операцию удаления услуги. Услуга не остановлена"
|
||||
# suspend_on_destroy гарантирует правильный порядок: suspend → delete.
|
||||
suspend_on_destroy = true
|
||||
}
|
||||
|
||||
output "vapp_id" {
|
||||
value = nubes_vapp.vapp.id
|
||||
description = "ID созданного vApp (используется как vapp_uid при создании ВМ)"
|
||||
}
|
||||
|
||||
output "vapp_state" {
|
||||
value = nubes_vapp.vapp.state_out_flat
|
||||
description = "Плоский state vApp — адреса, статусы сети и т.д."
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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-джобы заново"
|
||||
}
|
||||
|
||||
# ---- Идентификаторы сервисов Nubes ----------------------------------------
|
||||
# Берутся из Личного Кабинета → «Мои сервисы» → нужный сервис → параметры инстанса.
|
||||
# Не изменяются после создания vApp.
|
||||
|
||||
variable "vdc_uid" {
|
||||
type = string
|
||||
description = "UUID услуги «Виртуальный датацентр (vDC)». Личный Кабинет → Мои сервисы → vDC → UUID."
|
||||
}
|
||||
|
||||
variable "nsxt_uid" {
|
||||
type = string
|
||||
description = "UUID услуги «Сетевой шлюз периметра (Edge / NSX-T)». Личный Кабинет → Мои сервисы → Edge → UUID."
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 2026-03-25 — vm.tf: виртуальная машина (nubes_vc_vm_v3) внутри vApp.
|
||||
// Зависит от nubes_vapp.vapp — создаётся после vApp.
|
||||
// image_vm, vapp_uid, user_public_key не изменяются после создания.
|
||||
|
||||
resource "nubes_vc_vm_v3" "vm" {
|
||||
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" # Не изменяется после создания.
|
||||
ip_space_name = "internet-ipv4-v1"
|
||||
|
||||
user_login = "ubuntu"
|
||||
user_public_key = var.vm_public_key # задаётся в terraform.tfvars
|
||||
|
||||
vm_cpu = 2
|
||||
vm_ram = 2 # GB
|
||||
vm_disk = 20 # GB
|
||||
|
||||
adopt_existing_on_create = true
|
||||
operation_timeout = "15m"
|
||||
|
||||
# delete без предварительного suspend завершается ошибкой (аналогично vApp).
|
||||
suspend_on_destroy = true
|
||||
}
|
||||
|
||||
output "vm_id" {
|
||||
value = nubes_vc_vm_v3.vm.id
|
||||
description = "ID созданной ВМ"
|
||||
}
|
||||
|
||||
output "vm_state" {
|
||||
value = nubes_vc_vm_v3.vm.state_out_flat
|
||||
description = "Плоский state ВМ — IP-адреса, статус и т.д."
|
||||
}
|
||||
Executable
+931
@@ -0,0 +1,931 @@
|
||||
#!/usr/bin/env bash
|
||||
# 2026-03-30 — vm_stress_test.sh (v2 — READ-ONLY)
|
||||
# Автономный READ-ONLY stress/chaos тест для examples/VM.
|
||||
#
|
||||
# ⛔⛔⛔ КРИТИЧЕСКОЕ ПРАВИЛО — ДЛЯ AI-АГЕНТОВ И ЛЮДЕЙ ⛔⛔⛔
|
||||
#
|
||||
# Этот скрипт НИКОГДА НЕ МОДИФИЦИРУЕТ terraform.tfvars и НИКАКИЕ ДРУГИЕ ФАЙЛЫ.
|
||||
# Все переопределения переменных — ТОЛЬКО через terraform CLI опцию -var.
|
||||
# Файл terraform.tfvars ЧИТАЕТСЯ, но НИКОГДА НЕ ПЕРЕЗАПИСЫВАЕТСЯ.
|
||||
#
|
||||
# ЗАПРЕЩЕНО:
|
||||
# - Редактировать этот скрипт
|
||||
# - Редактировать terraform.tfvars
|
||||
# - Редактировать любые .tf файлы
|
||||
# - Запускать terraform напрямую — только через этот скрипт
|
||||
#
|
||||
# ПРИЧИНА: предыдущая версия скрипта уничтожила terraform.tfvars
|
||||
# через write_tfvars() — потерян JWT-токен api_token. Это НЕДОПУСТИМО.
|
||||
#
|
||||
# ФАЗЫ:
|
||||
# 1 BASELINE — apply с текущим tfvars (packages + nginx + docker)
|
||||
# 2 IDEMPOTENT — повторный plan → "No changes"
|
||||
# 3 PARTIAL_DISABLE — apply с -var install_nginx=false -var install_docker=false
|
||||
# 4 PARTIAL_ENABLE — apply с -var install_nginx=true -var install_docker=true (run_id+1)
|
||||
# 5 REORDER_PACKAGES — apply с -var 'base_packages=["htop","jq"]' (run_id+1)
|
||||
# 6 MANUAL_PURGE — удалить пакеты с VM по SSH → apply (run_id+1)
|
||||
# 7 DESTROY — terraform destroy → VM уходит в suspend
|
||||
# 8 RESURRECT — apply после destroy → VM просыпается
|
||||
# 9 STRESS_CYCLES — N подряд destroy/apply циклов
|
||||
# 10 FINAL_SANITY — проверить доступность VM и пакеты
|
||||
#
|
||||
# ЗАПУСК:
|
||||
# cd ~/terra/sless/examples/VM
|
||||
# bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log
|
||||
#
|
||||
# ПАРАМЕТРЫ (env):
|
||||
# STRESS_CYCLES=2 — количество destroy/apply циклов в фазе 9 (default: 2)
|
||||
# SKIP_DESTROY=1 — пропустить фазы 7-9 (для быстрого прогона)
|
||||
#
|
||||
# АНАЛИЗ РЕЗУЛЬТАТОВ:
|
||||
# grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log
|
||||
# Итоговая сводка печатается в конце лога.
|
||||
#
|
||||
# ТРЕБОВАНИЯ: terraform, ssh, python3 — всё на VM naeel@5.172.178.213
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── CONFIG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VM_KEY="$SCRIPT_DIR/vm_key"
|
||||
STRESS_CYCLES="${STRESS_CYCLES:-2}"
|
||||
SKIP_DESTROY="${SKIP_DESTROY:-0}"
|
||||
|
||||
# Читаем текущий run_id из terraform.tfvars (ТОЛЬКО ЧТЕНИЕ, не запись)
|
||||
RUN_ID=$(grep 'install_run_id' terraform.tfvars | grep -oP '\d+' | head -1)
|
||||
|
||||
# ── СТАТИСТИКА ────────────────────────────────────────────────────────────────
|
||||
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
PHASE_RESULTS=()
|
||||
START_TIME=$SECONDS
|
||||
|
||||
# ── ЦВЕТА ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
# ── HELPERS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pass() { echo -e " ${GREEN}[PASS]${RESET} $1"; ((PASS++)); }
|
||||
fail() { echo -e " ${RED}[FAIL]${RESET} $1"; ((FAIL++)); }
|
||||
skip() { echo -e " ${YELLOW}[SKIP]${RESET} $1"; ((SKIP++)); }
|
||||
info() { echo -e " ${CYAN}[INFO]${RESET} $1"; }
|
||||
warn() { echo -e " ${YELLOW}[WARN]${RESET} $1"; }
|
||||
|
||||
phase_header() {
|
||||
local num="$1" name="$2"
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e "${BOLD}${CYAN} ФАЗА $num: $name${RESET}"
|
||||
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e " ${CYAN}[TIME]${RESET} $(date '+%H:%M:%S')"
|
||||
}
|
||||
|
||||
phase_result() {
|
||||
local name="$1" result="$2"
|
||||
PHASE_RESULTS+=("$name:$result")
|
||||
echo -e " ${CYAN}[PHASE]${RESET} $name → ${result}"
|
||||
}
|
||||
|
||||
# ── TERRAFORM HELPERS ─────────────────────────────────────────────────────────
|
||||
# ⛔ НЕ ТРОГАЕМ terraform.tfvars. Переопределения — ТОЛЬКО через -var.
|
||||
# terraform.tfvars подхватывается автоматически (лежит в рабочей директории).
|
||||
# Доп. -var аргументы передаются во все tf_* функции и ПЕРЕОПРЕДЕЛЯЮТ tfvars.
|
||||
|
||||
# tf_apply: apply с retry при сетевых ошибках.
|
||||
# Аргументы: любые доп. -var (опционально).
|
||||
# Пример: tf_apply -var install_nginx=false -var install_docker=false
|
||||
tf_apply() {
|
||||
local attempt=1 max=3
|
||||
while [[ $attempt -le $max ]]; do
|
||||
info "terraform apply (попытка $attempt/$max)..."
|
||||
if terraform apply -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_apply.log; then
|
||||
return 0
|
||||
fi
|
||||
if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_apply.log && [[ $attempt -lt $max ]]; then
|
||||
warn "сетевой сбой, retry через $((attempt * 5))s..."
|
||||
sleep $((attempt * 5))
|
||||
((attempt++))
|
||||
continue
|
||||
fi
|
||||
return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# tf_destroy: destroy с retry.
|
||||
# Аргументы: любые доп. -var (опционально).
|
||||
tf_destroy() {
|
||||
local attempt=1 max=3
|
||||
while [[ $attempt -le $max ]]; do
|
||||
info "terraform destroy (попытка $attempt/$max)..."
|
||||
if terraform destroy -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_destroy.log; then
|
||||
return 0
|
||||
fi
|
||||
if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_destroy.log && [[ $attempt -lt $max ]]; then
|
||||
warn "сетевой сбой, retry через $((attempt * 5))s..."
|
||||
sleep $((attempt * 5))
|
||||
((attempt++))
|
||||
continue
|
||||
fi
|
||||
return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# tf_plan_no_changes: проверить plan → "No changes".
|
||||
# Аргументы: любые доп. -var (опционально).
|
||||
tf_plan_no_changes() {
|
||||
terraform plan -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_plan.log
|
||||
grep -q 'No changes' /tmp/vm_tf_plan.log
|
||||
}
|
||||
|
||||
# tf_state_count: количество ресурсов в state.
|
||||
tf_state_count() {
|
||||
terraform state list 2>/dev/null | wc -l
|
||||
}
|
||||
|
||||
# next_run_id: инкрементировать внутренний счётчик RUN_ID и вернуть новое значение.
|
||||
# Используется для -var install_run_id=N чтобы форсировать пересоздание jobs.
|
||||
next_run_id() {
|
||||
RUN_ID=$((RUN_ID + 1))
|
||||
echo "$RUN_ID"
|
||||
}
|
||||
|
||||
# ── VM SSH HELPERS ────────────────────────────────────────────────────────────
|
||||
|
||||
# get_vm_ip: получить внешний IP VM из terraform output.
|
||||
get_vm_ip() {
|
||||
terraform output -json vm_state 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('externalConnect',''))" 2>/dev/null
|
||||
}
|
||||
|
||||
# vm_ssh: выполнить команду на VM. Возвращает exit code команды.
|
||||
vm_ssh() {
|
||||
local ip="$1"; shift
|
||||
ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
|
||||
-o ServerAliveInterval=5 -o ServerAliveCountMax=3 \
|
||||
"ubuntu@$ip" "$@"
|
||||
}
|
||||
|
||||
# vm_alive: проверить доступность VM по SSH.
|
||||
vm_alive() {
|
||||
local ip="$1"
|
||||
vm_ssh "$ip" 'echo alive' &>/dev/null
|
||||
}
|
||||
|
||||
# vm_wait_alive: ждать пока VM ответит по SSH (до timeout_sec).
|
||||
vm_wait_alive() {
|
||||
local ip="$1" timeout_sec="${2:-120}"
|
||||
local deadline=$((SECONDS + timeout_sec))
|
||||
while [[ $SECONDS -lt $deadline ]]; do
|
||||
if vm_alive "$ip"; then return 0; fi
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# vm_check_package: проверить что пакет установлен.
|
||||
vm_check_package() {
|
||||
local ip="$1" pkg="$2"
|
||||
vm_ssh "$ip" "dpkg -l $pkg 2>/dev/null | grep -q '^ii'" 2>/dev/null
|
||||
}
|
||||
|
||||
# vm_check_binary: проверить что бинарник доступен.
|
||||
vm_check_binary() {
|
||||
local ip="$1" bin="$2"
|
||||
vm_ssh "$ip" "command -v $bin" &>/dev/null
|
||||
}
|
||||
|
||||
# vm_wait_binary: ждать пока бинарник появится на VM (sless_job работает асинхронно).
|
||||
# Нужен потому что sless_job запускает kubernetes Job, который сначала собирает образ,
|
||||
# затем стартует pod, и только потом SSH-устанавливает пакеты на VM — это занимает 1-3 мин.
|
||||
vm_wait_binary() {
|
||||
local ip="$1" bin="$2" timeout_sec="${3:-180}"
|
||||
local deadline=$((SECONDS + timeout_sec))
|
||||
info "жду появления '$bin' на VM (до ${timeout_sec}s)..."
|
||||
while [[ $SECONDS -lt $deadline ]]; do
|
||||
if vm_check_binary "$ip" "$bin"; then return 0; fi
|
||||
sleep 15
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# vm_purge_all: удалить все установленные пакеты с VM.
|
||||
vm_purge_all() {
|
||||
local ip="$1"
|
||||
info "удаляю пакеты с VM $ip..."
|
||||
vm_ssh "$ip" 'sudo systemctl stop nginx docker 2>/dev/null; sudo apt-get purge -y jq python3-pip htop unzip nginx docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null; sudo apt-get autoremove -y 2>/dev/null; sudo rm -rf /var/lib/docker /var/lib/containerd; echo purge_done' 2>/dev/null
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 1: BASELINE — apply с текущим tfvars (всё включено)
|
||||
# Используем -var install_run_id=N чтобы гарантировать свежий запуск.
|
||||
# terraform.tfvars НЕ ТРОГАЕМ — берём как есть.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_1_baseline() {
|
||||
phase_header 1 "BASELINE — apply с полным набором"
|
||||
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
|
||||
# Все флаги = true (как в tfvars), но run_id инкрементирован
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid"; then
|
||||
pass "1.1 terraform apply завершился успешно"
|
||||
else
|
||||
fail "1.1 terraform apply упал"
|
||||
phase_result "BASELINE" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Проверить количество ресурсов
|
||||
local count
|
||||
count=$(tf_state_count)
|
||||
if [[ $count -ge 5 ]]; then
|
||||
pass "1.2 state содержит $count ресурсов (ожидали ≥5)"
|
||||
else
|
||||
fail "1.2 state содержит $count ресурсов (ожидали ≥5)"
|
||||
fi
|
||||
|
||||
# Проверить outputs
|
||||
local out
|
||||
out=$(terraform output -json 2>/dev/null)
|
||||
|
||||
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'ok' in d['install_packages_result']['value'] or 'already' in d['install_packages_result']['value']" 2>/dev/null; then
|
||||
pass "1.3 install_packages_result содержит ok/already_installed"
|
||||
else
|
||||
fail "1.3 install_packages_result не содержит ok"
|
||||
fi
|
||||
|
||||
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_nginx_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then
|
||||
pass "1.4 install_nginx_result содержит ok/already"
|
||||
else
|
||||
fail "1.4 install_nginx_result не содержит ok"
|
||||
fi
|
||||
|
||||
if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_docker_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then
|
||||
pass "1.5 install_docker_result содержит ok/already"
|
||||
else
|
||||
fail "1.5 install_docker_result не содержит ok"
|
||||
fi
|
||||
|
||||
# Проверить VM по SSH
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
if [[ -n "$ip" ]] && vm_alive "$ip"; then
|
||||
pass "1.6 VM $ip доступна по SSH"
|
||||
else
|
||||
fail "1.6 VM не доступна по SSH (ip=$ip)"
|
||||
fi
|
||||
|
||||
phase_result "BASELINE" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 2: IDEMPOTENT — повторный apply с теми же аргументами → 0 changed
|
||||
# Используем apply (не plan) — это надёжнее: некоторые sless-провайдеры показывают
|
||||
# ложный drift в plan, но apply при этом возвращает 0 changed. Apply — canonical way.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_2_idempotent() {
|
||||
phase_header 2 "IDEMPOTENT — повторный apply без изменений"
|
||||
|
||||
# Тот же run_id что применялся в фазе 1 → apply должен вернуть 0 changed
|
||||
# sless_job — эфемерный ресурс, каждый apply пересоздаёт job-ресурсы (add+destroy).
|
||||
# Идемпотентность = "0 changed" (ноль in-place изменений), а не "0 add/destroy".
|
||||
# VM и vApp не должны изменяться никогда.
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$RUN_ID"; then
|
||||
if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then
|
||||
pass "2.1 повторный apply → 0 changed (sless_job пересоздан — ожидаемо)"
|
||||
grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do
|
||||
info " $line"
|
||||
done
|
||||
else
|
||||
fail "2.1 повторный apply изменил persistent ресурсы (ожидали 0 changed)"
|
||||
grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do
|
||||
info " $line"
|
||||
done
|
||||
fi
|
||||
else
|
||||
fail "2.1 повторный apply упал"
|
||||
fi
|
||||
|
||||
phase_result "IDEMPOTENT" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 3: PARTIAL_DISABLE — выключить nginx + docker
|
||||
# Используем -var install_nginx=false -var install_docker=false
|
||||
# terraform.tfvars по-прежнему НЕ ТРОГАЕМ.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_3_partial_disable() {
|
||||
phase_header 3 "PARTIAL_DISABLE — выключить nginx + docker"
|
||||
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=false" \
|
||||
-var "install_docker=false" \
|
||||
-var "install_run_id=$RUN_ID"; then
|
||||
pass "3.1 apply с partial disable завершился"
|
||||
else
|
||||
fail "3.1 apply с partial disable упал"
|
||||
phase_result "PARTIAL_DISABLE" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Проверить что nginx и docker job пропали из state
|
||||
local state_list
|
||||
state_list=$(terraform state list 2>/dev/null)
|
||||
|
||||
if echo "$state_list" | grep -q 'install_nginx'; then
|
||||
fail "3.2 install_nginx всё ещё в state"
|
||||
else
|
||||
pass "3.2 install_nginx убран из state"
|
||||
fi
|
||||
|
||||
if echo "$state_list" | grep -q 'install_docker'; then
|
||||
fail "3.3 install_docker всё ещё в state"
|
||||
else
|
||||
pass "3.3 install_docker убран из state"
|
||||
fi
|
||||
|
||||
if echo "$state_list" | grep -q 'install_packages'; then
|
||||
pass "3.4 install_packages остался в state"
|
||||
else
|
||||
fail "3.4 install_packages пропал из state"
|
||||
fi
|
||||
|
||||
# Проверить outputs
|
||||
local nginx_out docker_out
|
||||
nginx_out=$(terraform output -raw install_nginx_result 2>/dev/null)
|
||||
docker_out=$(terraform output -raw install_docker_result 2>/dev/null)
|
||||
|
||||
if [[ "$nginx_out" == "skipped" ]]; then
|
||||
pass "3.5 install_nginx_result = skipped"
|
||||
else
|
||||
fail "3.5 install_nginx_result = '$nginx_out' (ожидали skipped)"
|
||||
fi
|
||||
|
||||
if [[ "$docker_out" == "skipped" ]]; then
|
||||
pass "3.6 install_docker_result = skipped"
|
||||
else
|
||||
fail "3.6 install_docker_result = '$docker_out' (ожидали skipped)"
|
||||
fi
|
||||
|
||||
phase_result "PARTIAL_DISABLE" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 4: PARTIAL_ENABLE — включить обратно всё
|
||||
# Инкрементируем run_id чтобы jobs пересоздались.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_4_partial_enable() {
|
||||
phase_header 4 "PARTIAL_ENABLE — включить обратно всё"
|
||||
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid"; then
|
||||
pass "4.1 apply с полным набором завершился"
|
||||
else
|
||||
fail "4.1 apply с полным набором упал"
|
||||
phase_result "PARTIAL_ENABLE" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(tf_state_count)
|
||||
if [[ $count -ge 5 ]]; then
|
||||
pass "4.2 state содержит $count ресурсов (ожидали ≥5)"
|
||||
else
|
||||
fail "4.2 state содержит $count ресурсов (ожидали ≥5)"
|
||||
fi
|
||||
|
||||
phase_result "PARTIAL_ENABLE" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 5: REORDER_PACKAGES — изменить порядок и состав base_packages
|
||||
# Через -var 'base_packages=["htop","jq"]' — terraform.tfvars не трогаем.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_5_reorder() {
|
||||
phase_header 5 "REORDER_PACKAGES — изменить порядок и состав пакетов"
|
||||
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
|
||||
# Сокращённый набор пакетов через -var
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid" \
|
||||
-var 'base_packages=["htop","jq"]'; then
|
||||
pass "5.1 apply с изменённым набором пакетов завершился"
|
||||
else
|
||||
fail "5.1 apply с изменённым набором пакетов упал"
|
||||
phase_result "REORDER_PACKAGES" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Проверить output
|
||||
local pkg_out
|
||||
pkg_out=$(terraform output -raw install_packages_result 2>/dev/null)
|
||||
if echo "$pkg_out" | grep -qE '"status"|ok|already'; then
|
||||
pass "5.2 install_packages вернул ожидаемый результат"
|
||||
else
|
||||
fail "5.2 install_packages output неожиданный: $pkg_out"
|
||||
fi
|
||||
|
||||
# Вернуть полный набор пакетов
|
||||
rid=$(next_run_id)
|
||||
tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid" \
|
||||
|| warn "5.3 восстановление полного набора не удалось"
|
||||
|
||||
phase_result "REORDER_PACKAGES" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 6: MANUAL_PURGE — удалить пакеты с VM вручную, заново установить
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_6_manual_purge() {
|
||||
phase_header 6 "MANUAL_PURGE — удалить пакеты с VM, заново установить"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
if [[ -z "$ip" ]]; then
|
||||
fail "6.0 не удалось получить IP VM"
|
||||
phase_result "MANUAL_PURGE" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Удалить всё с VM
|
||||
vm_purge_all "$ip"
|
||||
|
||||
# Проверить что пакеты действительно удалены
|
||||
if vm_check_binary "$ip" "docker"; then
|
||||
fail "6.1 docker всё ещё на VM после purge"
|
||||
else
|
||||
pass "6.1 docker удалён с VM"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "nginx"; then
|
||||
fail "6.2 nginx всё ещё на VM после purge"
|
||||
else
|
||||
pass "6.2 nginx удалён с VM"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "jq"; then
|
||||
fail "6.3 jq всё ещё на VM после purge"
|
||||
else
|
||||
pass "6.3 jq удалён с VM"
|
||||
fi
|
||||
|
||||
# Пересоздать jobs (bump run_id)
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
|
||||
info "запускаю переустановку через sless_job..."
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid"; then
|
||||
pass "6.4 apply после purge завершился"
|
||||
else
|
||||
fail "6.4 apply после purge упал"
|
||||
phase_result "MANUAL_PURGE" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ждать пока sless_job отработает: docker самый долгий (k8s Job + образ + apt-install ~200MB).
|
||||
# docker-ce требует до 5 минут на первой установке — ставим 360s.
|
||||
if vm_wait_binary "$ip" "docker" 360; then
|
||||
pass "6.5 docker установлен заново"
|
||||
else
|
||||
fail "6.5 docker НЕ установлен после re-apply (таймаут 360s)"
|
||||
fi
|
||||
|
||||
if vm_wait_binary "$ip" "nginx" 240; then
|
||||
pass "6.6 nginx установлен заново"
|
||||
else
|
||||
fail "6.6 nginx НЕ установлен после re-apply (таймаут 240s)"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "jq"; then
|
||||
pass "6.7 jq установлен заново"
|
||||
else
|
||||
fail "6.7 jq НЕ установлен после re-apply"
|
||||
fi
|
||||
|
||||
phase_result "MANUAL_PURGE" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 7: DESTROY — terraform destroy, VM уходит в suspend
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_7_destroy() {
|
||||
phase_header 7 "DESTROY — terraform destroy → VM в suspend"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
|
||||
if tf_destroy; then
|
||||
pass "7.1 terraform destroy завершился"
|
||||
else
|
||||
fail "7.1 terraform destroy упал"
|
||||
phase_result "DESTROY" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# State должен быть пуст
|
||||
local count
|
||||
count=$(tf_state_count)
|
||||
if [[ $count -eq 0 ]]; then
|
||||
pass "7.2 state пуст ($count ресурсов)"
|
||||
else
|
||||
fail "7.2 state не пуст ($count ресурсов)"
|
||||
fi
|
||||
|
||||
# VM не должна отвечать по SSH
|
||||
if [[ -n "$ip" ]]; then
|
||||
info "проверяю что VM $ip недоступна..."
|
||||
if vm_alive "$ip"; then
|
||||
fail "7.3 VM $ip всё ещё отвечает по SSH после destroy"
|
||||
else
|
||||
pass "7.3 VM $ip не отвечает по SSH (suspend подтверждён)"
|
||||
fi
|
||||
else
|
||||
skip "7.3 IP VM неизвестен, пропускаю SSH-проверку"
|
||||
fi
|
||||
|
||||
phase_result "DESTROY" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 8: RESURRECT — apply после destroy, VM просыпается
|
||||
# Используем текущий run_id — terraform.tfvars НЕ ТРОГАЕМ.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_8_resurrect() {
|
||||
phase_header 8 "RESURRECT — apply после destroy"
|
||||
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid"; then
|
||||
pass "8.1 apply после destroy завершился"
|
||||
else
|
||||
fail "8.1 apply после destroy упал"
|
||||
phase_result "RESURRECT" "FAIL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(tf_state_count)
|
||||
if [[ $count -ge 5 ]]; then
|
||||
pass "8.2 state содержит $count ресурсов"
|
||||
else
|
||||
fail "8.2 state содержит $count ресурсов (ожидали ≥5)"
|
||||
fi
|
||||
|
||||
# Проверить VM
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
if [[ -n "$ip" ]] && vm_alive "$ip"; then
|
||||
pass "8.3 VM $ip доступна по SSH после resurrect"
|
||||
else
|
||||
# VM может ещё просыпаться — ждём
|
||||
info "VM не отвечает, жду до 120s..."
|
||||
if vm_wait_alive "$ip" 120; then
|
||||
pass "8.3 VM $ip доступна по SSH после ожидания"
|
||||
else
|
||||
fail "8.3 VM $ip не доступна по SSH через 120s"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Проверить пакеты
|
||||
if [[ -n "$ip" ]] && vm_alive "$ip"; then
|
||||
if vm_check_binary "$ip" "jq"; then
|
||||
pass "8.4 jq установлен после resurrect"
|
||||
else
|
||||
fail "8.4 jq НЕ установлен после resurrect"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "nginx"; then
|
||||
pass "8.5 nginx установлен после resurrect"
|
||||
else
|
||||
fail "8.5 nginx НЕ установлен после resurrect"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "docker"; then
|
||||
pass "8.6 docker установлен после resurrect"
|
||||
else
|
||||
fail "8.6 docker НЕ установлен после resurrect"
|
||||
fi
|
||||
fi
|
||||
|
||||
phase_result "RESURRECT" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 9: STRESS_CYCLES — N destroy/apply циклов подряд
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_9_stress() {
|
||||
phase_header 9 "STRESS_CYCLES — $STRESS_CYCLES циклов destroy/apply"
|
||||
|
||||
local i rid
|
||||
for i in $(seq 1 "$STRESS_CYCLES"); do
|
||||
info "── цикл $i/$STRESS_CYCLES ──"
|
||||
|
||||
info "[$i] destroy..."
|
||||
if tf_destroy; then
|
||||
pass "9.${i}a destroy цикл $i"
|
||||
else
|
||||
fail "9.${i}a destroy цикл $i упал"
|
||||
# Попробовать восстановиться
|
||||
rid=$(next_run_id)
|
||||
tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid" || true
|
||||
continue
|
||||
fi
|
||||
|
||||
rid=$(next_run_id)
|
||||
info "[$i] apply (run_id=$rid)..."
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid"; then
|
||||
pass "9.${i}b apply цикл $i"
|
||||
else
|
||||
fail "9.${i}b apply цикл $i упал"
|
||||
continue
|
||||
fi
|
||||
done
|
||||
|
||||
phase_result "STRESS_CYCLES" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ФАЗА 10: FINAL_SANITY — финальная проверка состояния
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
phase_10_final() {
|
||||
phase_header 10 "FINAL_SANITY — финальная проверка"
|
||||
|
||||
# Убедиться что state на месте
|
||||
local count
|
||||
count=$(tf_state_count)
|
||||
if [[ $count -ge 5 ]]; then
|
||||
pass "10.1 state содержит $count ресурсов"
|
||||
else
|
||||
fail "10.1 state содержит $count ресурсов (ожидали ≥5)"
|
||||
# Попробовать восстановить (через -var, БЕЗ изменения файлов)
|
||||
local rid
|
||||
rid=$(next_run_id)
|
||||
info "пытаюсь восстановить baseline..."
|
||||
tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$rid" || warn "восстановление не удалось"
|
||||
fi
|
||||
|
||||
# Idempotency финально: повторный apply → 0 changed.
|
||||
# sless_job пересоздаются (add+destroy) — это нормально для job-ресурса.
|
||||
# Проверяем только "0 changed" — VM и vApp не должны изменяться.
|
||||
if tf_apply \
|
||||
-var "install_packages=true" \
|
||||
-var "install_nginx=true" \
|
||||
-var "install_docker=true" \
|
||||
-var "install_run_id=$RUN_ID"; then
|
||||
if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then
|
||||
pass "10.2 финальный apply → 0 changed (sless_job пересоздан — ожидаемо)"
|
||||
grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do
|
||||
info " $line"
|
||||
done
|
||||
else
|
||||
fail "10.2 финальный apply изменил persistent ресурсы (ожидали 0 changed)"
|
||||
grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do
|
||||
info " $line"
|
||||
done
|
||||
fi
|
||||
else
|
||||
fail "10.2 финальный apply упал"
|
||||
fi
|
||||
|
||||
# VM доступна
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
if [[ -n "$ip" ]] && vm_alive "$ip"; then
|
||||
pass "10.3 VM $ip доступна по SSH"
|
||||
|
||||
# Полная проверка пакетов
|
||||
for pkg in jq htop unzip; do
|
||||
if vm_check_package "$ip" "$pkg"; then
|
||||
pass "10.4 пакет $pkg установлен"
|
||||
else
|
||||
fail "10.4 пакет $pkg НЕ установлен"
|
||||
fi
|
||||
done
|
||||
|
||||
if vm_check_binary "$ip" "nginx"; then
|
||||
pass "10.5 nginx работает"
|
||||
else
|
||||
fail "10.5 nginx НЕ найден"
|
||||
fi
|
||||
|
||||
if vm_check_binary "$ip" "docker"; then
|
||||
pass "10.6 docker работает"
|
||||
else
|
||||
fail "10.6 docker НЕ найден"
|
||||
fi
|
||||
|
||||
# nginx service активен (не просто бинарник, а именно демон)
|
||||
if vm_ssh "$ip" "systemctl is-active nginx 2>/dev/null" 2>/dev/null | grep -q '^active$'; then
|
||||
pass "10.7 nginx service активен (systemctl)"
|
||||
else
|
||||
fail "10.7 nginx service не активен"
|
||||
fi
|
||||
|
||||
# docker daemon активен
|
||||
if vm_ssh "$ip" "systemctl is-active docker 2>/dev/null" 2>/dev/null | grep -q '^active$'; then
|
||||
pass "10.8 docker daemon активен (systemctl)"
|
||||
else
|
||||
fail "10.8 docker daemon не активен"
|
||||
fi
|
||||
|
||||
# HTTP probe: nginx отвечает на localhost:80
|
||||
local http_code
|
||||
http_code=$(vm_ssh "$ip" "curl -sS -o /dev/null -w '%{http_code}' http://localhost 2>/dev/null" 2>/dev/null)
|
||||
if [[ "$http_code" == "200" ]]; then
|
||||
pass "10.9 nginx отвечает HTTP 200"
|
||||
else
|
||||
fail "10.9 nginx не отвечает HTTP 200 (code='$http_code')"
|
||||
fi
|
||||
|
||||
# python3 доступен
|
||||
local py_ver
|
||||
py_ver=$(vm_ssh "$ip" "python3 --version 2>&1" 2>/dev/null)
|
||||
if echo "$py_ver" | grep -q 'Python 3'; then
|
||||
pass "10.10 python3 доступен ($py_ver)"
|
||||
else
|
||||
fail "10.10 python3 недоступен"
|
||||
fi
|
||||
|
||||
# docker smoke: запустить контейнер и проверить вывод
|
||||
if vm_ssh "$ip" "docker run --rm hello-world 2>&1 | grep -q 'Hello from Docker'" 2>/dev/null; then
|
||||
pass "10.11 docker run hello-world → успешно"
|
||||
else
|
||||
fail "10.11 docker run hello-world → не прошёл"
|
||||
fi
|
||||
|
||||
# disk space: убедиться что на VM есть место (>500MB free)
|
||||
local free_mb
|
||||
free_mb=$(vm_ssh "$ip" "df -m / 2>/dev/null | awk 'NR==2{print \$4}'" 2>/dev/null)
|
||||
if [[ -n "$free_mb" && "$free_mb" -gt 500 ]]; then
|
||||
pass "10.12 свободное место на / = ${free_mb}MB (>500MB)"
|
||||
else
|
||||
fail "10.12 мало места на / = ${free_mb}MB (ожидали >500MB)"
|
||||
fi
|
||||
else
|
||||
fail "10.3 VM не доступна по SSH"
|
||||
fi
|
||||
|
||||
phase_result "FINAL_SANITY" "PASS"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# MAIN
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
echo -e "${BOLD}${CYAN}"
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ VM STRESS TEST v2 (READ-ONLY) — examples/VM ║"
|
||||
echo "║ $(date '+%Y-%m-%d %H:%M:%S') ║"
|
||||
echo "║ Начальный run_id: $RUN_ID ║"
|
||||
echo "║ Циклов stress: $STRESS_CYCLES ║"
|
||||
echo "║ terraform.tfvars НЕ МОДИФИЦИРУЕТСЯ ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${RESET}"
|
||||
|
||||
# Проверить что мы в правильной директории
|
||||
if [[ ! -f "terraform.tfvars" ]]; then
|
||||
echo -e "${RED}ОШИБКА: terraform.tfvars не найден. Запускайте из examples/VM/${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$VM_KEY" ]]; then
|
||||
echo -e "${RED}ОШИБКА: $VM_KEY не найден${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ⛔ ПРОВЕРКА ЦЕЛОСТНОСТИ: terraform.tfvars НЕ ДОЛЖЕН БЫТЬ МОДИФИЦИРОВАН
|
||||
# Сохраняем md5 до запуска и проверяем после каждой фазы
|
||||
TFVARS_MD5=$(md5sum terraform.tfvars | awk '{print $1}')
|
||||
info "md5 terraform.tfvars = $TFVARS_MD5 (будет проверяться после каждой фазы)"
|
||||
|
||||
check_tfvars_integrity() {
|
||||
local current_md5
|
||||
current_md5=$(md5sum terraform.tfvars | awk '{print $1}')
|
||||
if [[ "$current_md5" != "$TFVARS_MD5" ]]; then
|
||||
echo -e "${RED}⛔⛔⛔ КРИТИЧЕСКАЯ ОШИБКА: terraform.tfvars был изменён! ⛔⛔⛔${RESET}"
|
||||
echo -e "${RED}Ожидали md5: $TFVARS_MD5${RESET}"
|
||||
echo -e "${RED}Текущий md5: $current_md5${RESET}"
|
||||
echo -e "${RED}АВАРИЙНАЯ ОСТАНОВКА ТЕСТА${RESET}"
|
||||
exit 99
|
||||
fi
|
||||
}
|
||||
|
||||
# Запуск фаз с проверкой целостности после каждой
|
||||
phase_1_baseline; check_tfvars_integrity
|
||||
phase_2_idempotent; check_tfvars_integrity
|
||||
phase_3_partial_disable; check_tfvars_integrity
|
||||
phase_4_partial_enable; check_tfvars_integrity
|
||||
phase_5_reorder; check_tfvars_integrity
|
||||
phase_6_manual_purge; check_tfvars_integrity
|
||||
|
||||
if [[ "$SKIP_DESTROY" == "1" ]]; then
|
||||
skip "фазы 7-9 пропущены (SKIP_DESTROY=1)"
|
||||
PHASE_RESULTS+=("DESTROY:SKIP" "RESURRECT:SKIP" "STRESS_CYCLES:SKIP")
|
||||
else
|
||||
phase_7_destroy; check_tfvars_integrity
|
||||
phase_8_resurrect; check_tfvars_integrity
|
||||
phase_9_stress; check_tfvars_integrity
|
||||
fi
|
||||
|
||||
phase_10_final; check_tfvars_integrity
|
||||
|
||||
# ── ИТОГОВАЯ СВОДКА ──────────────────────────────────────────────────────────
|
||||
|
||||
ELAPSED=$((SECONDS - START_TIME))
|
||||
ELAPSED_MIN=$((ELAPSED / 60))
|
||||
ELAPSED_SEC=$((ELAPSED % 60))
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════════════════════╗${RESET}"
|
||||
echo -e "${BOLD}${CYAN}║ ИТОГОВАЯ СВОДКА ║${RESET}"
|
||||
echo -e "${BOLD}${CYAN}╠═══════════════════════════════════════════════════════════════╣${RESET}"
|
||||
echo -e "${BOLD} Время: ${ELAPSED_MIN}m ${ELAPSED_SEC}s${RESET}"
|
||||
echo -e "${BOLD} ${GREEN}PASS: $PASS${RESET} ${RED}FAIL: $FAIL${RESET} ${YELLOW}SKIP: $SKIP${RESET}"
|
||||
echo -e "${BOLD} Финальный run_id: $RUN_ID${RESET}"
|
||||
echo ""
|
||||
echo -e "${BOLD} Фазы:${RESET}"
|
||||
for pr in "${PHASE_RESULTS[@]}"; do
|
||||
name="${pr%%:*}"
|
||||
result="${pr##*:}"
|
||||
case "$result" in
|
||||
PASS) echo -e " ${GREEN}✓${RESET} $name" ;;
|
||||
FAIL) echo -e " ${RED}✗${RESET} $name" ;;
|
||||
SKIP) echo -e " ${YELLOW}○${RESET} $name" ;;
|
||||
esac
|
||||
done
|
||||
echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════════════════════╝${RESET}"
|
||||
|
||||
# Финальная проверка целостности tfvars
|
||||
check_tfvars_integrity
|
||||
info "terraform.tfvars НЕ БЫЛ ИЗМЕНЁН (md5 совпадает)"
|
||||
|
||||
# Exit code: 0 если все PASS, 1 если есть FAIL
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo -e "\n${RED}РЕЗУЛЬТАТ: FAIL ($FAIL ошибок)${RESET}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "\n${GREEN}РЕЗУЛЬТАТ: ALL PASS${RESET}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -132,11 +132,17 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
|
||||
if hasRequirements {
|
||||
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
|
||||
}
|
||||
// Проверяем синтаксис всех .py файлов на этапе сборки образа.
|
||||
// Если в коде синтаксическая ошибка — kaniko завершится с ошибкой и фаза = Failed.
|
||||
// Без этого шага образ собирается успешно, а под падает в CrashLoopBackOff при запуске.
|
||||
content += "RUN python -m py_compile $(find /app/function -name '*.py')\n"
|
||||
case "nodejs20":
|
||||
if hasPackageJSON {
|
||||
// cd нужен т.к. npm install читает package.json из текущей директории
|
||||
content += "RUN cd /app/function && npm install --omit=dev\n"
|
||||
}
|
||||
// Проверяем синтаксис всех .js файлов через node --check.
|
||||
content += "RUN find /app/function -name '*.js' | xargs -r node --check\n"
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Изменено: 2026-03-09
|
||||
// Изменено: 2026-04-10 (v0.1.3) — sendResult: если handler вернул строку начинающуюся с '<' → text/html.
|
||||
// HTTP-обёртка для serverless функций на Node.js 20.
|
||||
// Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию.
|
||||
// Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle)
|
||||
@@ -65,7 +66,7 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await userHandle(event);
|
||||
sendJSON(res, 200, result);
|
||||
sendResult(res, 200, result);
|
||||
} catch (err) {
|
||||
console.error('Handler error:', err);
|
||||
sendJSON(res, 500, { error: err.message });
|
||||
@@ -73,6 +74,21 @@ const server = http.createServer(async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
function sendResult(res, status, data) {
|
||||
// Если handler вернул строку начинающуюся с '<' — HTML страница.
|
||||
// Если строка, но не HTML — text/plain. Иначе — JSON.
|
||||
if (typeof data === 'string') {
|
||||
const ctype = data.trimStart().startsWith('<')
|
||||
? 'text/html; charset=utf-8'
|
||||
: 'text/plain; charset=utf-8';
|
||||
const buf = Buffer.from(data, 'utf-8');
|
||||
res.writeHead(status, { 'Content-Type': ctype, 'Content-Length': buf.length });
|
||||
res.end(buf);
|
||||
return;
|
||||
}
|
||||
sendJSON(res, status, data);
|
||||
}
|
||||
|
||||
function sendJSON(res, status, data) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(status, {
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: funcs
|
||||
image: naeel/sless-funcs-service:v0.1.3
|
||||
image: naeel/sless-funcs-service:v0.1.11
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
+644
-24
@@ -188,15 +188,25 @@
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.fn-url-row {
|
||||
padding: 0 14px 9px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fn-url-row:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fn-url {
|
||||
font-size: 0.76rem;
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fn-url:hover {
|
||||
@@ -312,6 +322,197 @@
|
||||
color: #484f58;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Кнопки действий на карточке функции */
|
||||
.action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #30363d;
|
||||
cursor: pointer;
|
||||
font-size: 0.73rem;
|
||||
font-weight: 500;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.action-btn:hover { background: #30363d; color: #c9d1d9; }
|
||||
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
|
||||
|
||||
/* Кодовый редактор во встроенном режиме */
|
||||
.edit-area {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border: none;
|
||||
border-top: 1px solid #30363d;
|
||||
padding: 14px 16px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #161b22;
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
.edit-save-btn {
|
||||
padding: 5px 16px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-save-btn:hover { background: #2ea043; }
|
||||
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.edit-cancel-btn {
|
||||
padding: 5px 16px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.edit-status {
|
||||
font-size: 0.77rem;
|
||||
padding: 5px 8px;
|
||||
align-self: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
.edit-status.ok { color: #3fb950; }
|
||||
.edit-status.err { color: #f85149; }
|
||||
|
||||
/* ---------- Модалка создания функции ---------- */
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
width: min(660px, 95vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.modal label {
|
||||
font-size: 0.82rem;
|
||||
color: #8b949e;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal input, .modal select {
|
||||
width: 100%;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
|
||||
.modal-row { display: flex; gap: 12px; }
|
||||
.modal-row > div { flex: 1; }
|
||||
.modal-code {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 10px 12px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-code:focus { border-color: #58a6ff; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal-create-btn {
|
||||
padding: 7px 20px;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-create-btn:hover { background: #2ea043; }
|
||||
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.modal-cancel-btn {
|
||||
padding: 7px 20px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-err {
|
||||
color: #f85149;
|
||||
font-size: 0.8rem;
|
||||
min-height: 18px;
|
||||
}
|
||||
.create-fn-btn {
|
||||
margin-left: auto;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.create-fn-btn:hover { background: #2ea043; }
|
||||
|
||||
/* Таймер Building */
|
||||
.fn-timer {
|
||||
font-size: 0.72rem;
|
||||
color: #d29922;
|
||||
font-family: monospace;
|
||||
margin-left: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-Building {
|
||||
animation: pulse-bld 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-bld {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -321,12 +522,56 @@
|
||||
<span class="logo-slash">/</span>
|
||||
<span class="ns-badge" id="ns-label">…</span>
|
||||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
||||
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
|
||||
</header>
|
||||
<main>
|
||||
<p class="summary" id="summary"></p>
|
||||
<div id="fn-list"></div>
|
||||
</main>
|
||||
|
||||
<!-- Модалка создания новой функции -->
|
||||
<div class="modal-backdrop" id="create-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<h2 id="modal-title">Создать функцию</h2>
|
||||
|
||||
<div>
|
||||
<label>Runtime</label>
|
||||
<select id="m-runtime">
|
||||
<option value="python3.11">Python 3.11</option>
|
||||
<option value="nodejs20">Node.js 20</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-row">
|
||||
<div>
|
||||
<label>Имя функции (k8s name)</label>
|
||||
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
|
||||
</div>
|
||||
<div>
|
||||
<label>Entrypoint (module.func)</label>
|
||||
<input id="m-entrypoint" type="text" value="handler.handler">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
|
||||
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
|
||||
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-err" id="m-err"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
|
||||
<button class="modal-create-btn" id="modal-submit">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -355,6 +600,8 @@
|
||||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
||||
}
|
||||
|
||||
// ---------- Карточки функций ----------
|
||||
|
||||
function buildCard(fn, triggers) {
|
||||
var card = $el('div', 'fn-card');
|
||||
var hdr = $el('div', 'fn-header');
|
||||
@@ -363,40 +610,46 @@
|
||||
|
||||
$txt(hdr, 'span', 'fn-name', fn.name);
|
||||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
||||
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
|
||||
if (fn.kind) {
|
||||
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
|
||||
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
|
||||
}
|
||||
|
||||
// sless_service имеет прямой URL из статуса деплоя
|
||||
if (fn.url) {
|
||||
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
|
||||
if (fn.phase === 'Building' || fn.phase === 'Pending') {
|
||||
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
|
||||
startBuildPoll(phaseBadge, timerEl, fn, ns, null, function(url) {
|
||||
if (!fn.url) { fn.url = url; setCardUrl(url); }
|
||||
});
|
||||
}
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
|
||||
// urlRow — отдельная строка под заголовком для URL
|
||||
var urlRow = $el('div', 'fn-url-row');
|
||||
var cardUrlAnchor = null;
|
||||
|
||||
function setCardUrl(url) {
|
||||
if (cardUrlAnchor) return; // уже есть
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
a.href = fn.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = fn.url;
|
||||
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
a.textContent = url;
|
||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
hdr.appendChild(a);
|
||||
urlRow.appendChild(a);
|
||||
cardUrlAnchor = a;
|
||||
}
|
||||
|
||||
if (fn.url) setCardUrl(fn.url);
|
||||
|
||||
for (var t = 0; t < triggers.length; t++) {
|
||||
var tr = triggers[t];
|
||||
if (tr.type === 'http') {
|
||||
// Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
|
||||
if (!fn.url) {
|
||||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
||||
if (url) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'fn-url';
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = url;
|
||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
hdr.appendChild(a);
|
||||
}
|
||||
if (url) setCardUrl(url);
|
||||
}
|
||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||||
} else if (tr.type === 'cron') {
|
||||
@@ -405,8 +658,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||||
hdr.appendChild($el('span', 'spacer'));
|
||||
// Кнопка редактирования кода
|
||||
var editBtn = $el('button', 'action-btn');
|
||||
editBtn.textContent = '✎';
|
||||
editBtn.title = 'Редактировать код';
|
||||
editBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
body.innerHTML = ''; // сбрасываем содержимое перед переходом в edit
|
||||
loaded = false; // следующий expand снова зачитает исходники
|
||||
body.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
|
||||
});
|
||||
hdr.appendChild(editBtn);
|
||||
|
||||
// Кнопка удаления
|
||||
var delBtn = $el('button', 'action-btn del');
|
||||
delBtn.textContent = '✕';
|
||||
delBtn.title = 'Удалить функцию';
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
|
||||
delBtn.disabled = true;
|
||||
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
|
||||
method: 'DELETE'
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
card.remove();
|
||||
} else {
|
||||
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert('Сетевая ошибка: ' + err);
|
||||
}).finally(function () {
|
||||
delBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
hdr.appendChild(delBtn);
|
||||
|
||||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
||||
|
||||
var loaded = false;
|
||||
@@ -425,10 +714,226 @@
|
||||
});
|
||||
|
||||
card.appendChild(hdr);
|
||||
card.appendChild(urlRow);
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
// openEditMode — открывает тело карточки в режиме редактирования.
|
||||
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
|
||||
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
|
||||
body.innerHTML = '';
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
|
||||
var kindParam = fnKind === 'service' ? '?kind=service' : '';
|
||||
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
|
||||
.then(function (r) {
|
||||
body.removeChild(msg);
|
||||
if (!r.ok) {
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка загрузки кода: ' + r.status;
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
return r.json().then(function (files) {
|
||||
var editable = files.filter(function (f) {
|
||||
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
|
||||
});
|
||||
if (editable.length === 0) {
|
||||
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
|
||||
}
|
||||
var mainFile = editable[0];
|
||||
|
||||
// Ищем файл зависимостей (requirements.txt / package.json)
|
||||
var tpl = TEMPLATES[fnRuntime] || {};
|
||||
var depsFilename = tpl.depsFilename || '';
|
||||
var depsContent = tpl.deps || '';
|
||||
if (depsFilename) {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
if (files[i].name === depsFilename && !files[i].binary) {
|
||||
depsContent = files[i].content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
|
||||
mainFile.name, mainFile.content, depsFilename, depsContent);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '';
|
||||
var e = $el('div', 'src-msg');
|
||||
e.textContent = 'Ошибка: ' + err;
|
||||
body.appendChild(e);
|
||||
});
|
||||
}
|
||||
|
||||
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
|
||||
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
|
||||
body.innerHTML = '';
|
||||
|
||||
// Строка переименования
|
||||
var renameRow = $el('div', 'edit-actions');
|
||||
renameRow.style.borderBottom = '1px solid #30363d';
|
||||
var nameLabel = $el('span', 'edit-status');
|
||||
nameLabel.textContent = 'Имя:';
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = fnName;
|
||||
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
|
||||
var renameBtn = $el('button', 'edit-cancel-btn');
|
||||
renameBtn.textContent = 'Переименовать';
|
||||
var renameStatus = $el('span', 'edit-status');
|
||||
renameRow.appendChild(nameLabel);
|
||||
renameRow.appendChild(nameInput);
|
||||
renameRow.appendChild(renameBtn);
|
||||
renameRow.appendChild(renameStatus);
|
||||
body.appendChild(renameRow);
|
||||
|
||||
// Код функции
|
||||
var codeHdr = $el('div', 'file-header');
|
||||
codeHdr.textContent = filename;
|
||||
body.appendChild(codeHdr);
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'edit-area';
|
||||
ta.spellcheck = false;
|
||||
ta.value = initialCode;
|
||||
body.appendChild(ta);
|
||||
|
||||
// Файл зависимостей (если есть)
|
||||
var depsTA = null;
|
||||
if (depsFilename) {
|
||||
var depsHdr = $el('div', 'file-header');
|
||||
depsHdr.textContent = depsFilename;
|
||||
body.appendChild(depsHdr);
|
||||
depsTA = document.createElement('textarea');
|
||||
depsTA.className = 'edit-area';
|
||||
depsTA.style.minHeight = '80px';
|
||||
depsTA.spellcheck = false;
|
||||
depsTA.value = depsContent;
|
||||
body.appendChild(depsTA);
|
||||
}
|
||||
|
||||
// Кнопки Save / Cancel
|
||||
var actions = $el('div', 'edit-actions');
|
||||
var saveBtn = $el('button', 'edit-save-btn');
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
var cancelBtn = $el('button', 'edit-cancel-btn');
|
||||
cancelBtn.textContent = 'Закрыть';
|
||||
var status = $el('span', 'edit-status');
|
||||
actions.appendChild(saveBtn);
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(status);
|
||||
body.appendChild(actions);
|
||||
|
||||
saveBtn.addEventListener('click', function () {
|
||||
saveBtn.disabled = true;
|
||||
status.textContent = 'Сохраняю…';
|
||||
status.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/save-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: ta.value, filename: filename, kind: fnKind || 'function',
|
||||
deps: depsTA ? depsTA.value : '',
|
||||
deps_filename: depsFilename || ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
saveBtn.disabled = false;
|
||||
if (r.ok) {
|
||||
// Ищем phaseBadge текущей карточки через DOM
|
||||
var cardEl = body.closest ? body.closest('.fn-card') : null;
|
||||
var pb = cardEl ? cardEl.querySelector('.badge-Ready, .badge-Building, .badge-Pending, .badge-Failed, .badge-Error') : null;
|
||||
if (pb) { pb.className = 'badge badge-Building'; pb.textContent = 'Building'; }
|
||||
status.textContent = '⏳ Сборка… 0с';
|
||||
status.className = 'edit-status';
|
||||
// Запускаем поллинг: обновляет статус в строке редактора и badge карточки
|
||||
startBuildPoll(pb, null, { name: fnName, kind: fnKind || 'function' }, ns, status, null);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
status.textContent = '✗ ' + t;
|
||||
status.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
saveBtn.disabled = false;
|
||||
status.textContent = '✗ ' + err;
|
||||
status.className = 'edit-status err';
|
||||
});
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
|
||||
|
||||
renameBtn.addEventListener('click', function () {
|
||||
var newName = nameInput.value.trim();
|
||||
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
|
||||
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
|
||||
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
|
||||
renameStatus.className = 'edit-status err';
|
||||
return;
|
||||
}
|
||||
renameBtn.disabled = true;
|
||||
renameStatus.textContent = 'Переименовываю…';
|
||||
renameStatus.className = 'edit-status';
|
||||
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
renameStatus.textContent = '✓ Переименовано → ' + newName;
|
||||
renameStatus.className = 'edit-status ok';
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
renameStatus.textContent = '✗ ' + t;
|
||||
renameStatus.className = 'edit-status err';
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
renameStatus.textContent = '✗ ' + err;
|
||||
renameStatus.className = 'edit-status err';
|
||||
}).finally(function () { renameBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
|
||||
// Показывает таймер-строку в statusEl (если передан), обновляет phaseBadge,
|
||||
// добавляет URL через onUrl-коллбэк когда сервис появляется.
|
||||
function startBuildPoll(phaseBadge, timerEl, fn, ns, statusEl, onUrl) {
|
||||
var buildStart = Date.now();
|
||||
var timerItv = setInterval(function () {
|
||||
var sec = Math.floor((Date.now() - buildStart) / 1000);
|
||||
if (timerEl && timerEl.parentNode) timerEl.textContent = sec + 'с';
|
||||
if (statusEl) statusEl.textContent = '⏳ Сборка… ' + sec + 'с';
|
||||
}, 1000);
|
||||
var pollItv = setInterval(function () {
|
||||
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
|
||||
clearInterval(timerItv);
|
||||
clearInterval(pollItv);
|
||||
if (phaseBadge) {
|
||||
phaseBadge.className = 'badge badge-' + d.phase;
|
||||
phaseBadge.textContent = d.phase;
|
||||
}
|
||||
if (timerEl && timerEl.parentNode) timerEl.parentNode.removeChild(timerEl);
|
||||
if (statusEl) {
|
||||
statusEl.textContent = d.phase === 'Ready'
|
||||
? '✓ Сборка завершена — функция готова'
|
||||
: '✗ Сборка завершилась со статусом: ' + d.phase;
|
||||
statusEl.className = 'edit-status ' + (d.phase === 'Ready' ? 'ok' : 'err');
|
||||
}
|
||||
if (d.url && onUrl) onUrl(d.url);
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function makeTriggerBtn(tr, ns) {
|
||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||||
@@ -459,6 +964,7 @@
|
||||
}
|
||||
|
||||
function loadSource(body, ns, fnName, fnKind) {
|
||||
body.innerHTML = ''; // всегда очищаем перед загрузкой
|
||||
var msg = $el('div', 'src-msg');
|
||||
msg.textContent = 'Загрузка кода…';
|
||||
body.appendChild(msg);
|
||||
@@ -506,6 +1012,120 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Модалка создания функции ----------
|
||||
|
||||
var TEMPLATES = {
|
||||
'python3.11': {
|
||||
filename: 'handler.py',
|
||||
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n',
|
||||
depsFilename: 'requirements.txt',
|
||||
deps: '# Python packages\n# e.g.: requests==2.31.0\n'
|
||||
},
|
||||
'nodejs20': {
|
||||
filename: 'handler.js',
|
||||
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
|
||||
depsFilename: 'package.json',
|
||||
deps: '{\n "dependencies": {}\n}\n'
|
||||
}
|
||||
};
|
||||
|
||||
function defaultFilename(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
}
|
||||
function helloWorldTemplate(runtime) {
|
||||
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
|
||||
}
|
||||
|
||||
var modal = document.getElementById('create-modal');
|
||||
var mRuntime = document.getElementById('m-runtime');
|
||||
var mCode = document.getElementById('m-code');
|
||||
var mFilenameDisplay = document.getElementById('m-filename-display');
|
||||
var mErr = document.getElementById('m-err');
|
||||
|
||||
// Заполняем шаблон при открытии / смене runtime
|
||||
function applyTemplate() {
|
||||
var tpl = TEMPLATES[mRuntime.value];
|
||||
if (tpl) {
|
||||
mCode.value = tpl.code;
|
||||
mFilenameDisplay.textContent = tpl.filename;
|
||||
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
|
||||
document.getElementById('m-deps').value = tpl.deps || '';
|
||||
}
|
||||
}
|
||||
applyTemplate();
|
||||
mRuntime.addEventListener('change', applyTemplate);
|
||||
|
||||
document.getElementById('open-create-modal').addEventListener('click', function () {
|
||||
applyTemplate();
|
||||
mErr.textContent = '';
|
||||
document.getElementById('m-name').value = '';
|
||||
document.getElementById('m-entrypoint').value = 'handler.handler';
|
||||
modal.classList.add('open');
|
||||
document.getElementById('m-name').focus();
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', function () {
|
||||
modal.classList.remove('open');
|
||||
});
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal) modal.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('modal-submit').addEventListener('click', function () {
|
||||
var name = document.getElementById('m-name').value.trim();
|
||||
var runtime = mRuntime.value;
|
||||
var entrypoint = document.getElementById('m-entrypoint').value.trim();
|
||||
var code = mCode.value;
|
||||
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||||
|
||||
mErr.textContent = '';
|
||||
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
|
||||
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
|
||||
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
|
||||
return;
|
||||
}
|
||||
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
|
||||
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
|
||||
|
||||
var btn = document.getElementById('modal-submit');
|
||||
btn.disabled = true;
|
||||
mErr.textContent = 'Создаю…';
|
||||
|
||||
fetch('/funcs/' + ns + '/create-function', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
runtime: runtime,
|
||||
kind: 'service',
|
||||
code: code,
|
||||
filename: filename,
|
||||
entrypoint: entrypoint,
|
||||
memory_mb: 128,
|
||||
timeout_sec: 30,
|
||||
deps: document.getElementById('m-deps').value,
|
||||
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (r.ok) {
|
||||
modal.classList.remove('open');
|
||||
mErr.textContent = '';
|
||||
// Перезагружаем страницу чтобы увидеть новую функцию
|
||||
setTimeout(function () { location.reload(); }, 400);
|
||||
} else {
|
||||
return r.text().then(function (t) {
|
||||
mErr.textContent = '✗ ' + t;
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
mErr.textContent = '✗ Сетевая ошибка: ' + err;
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Утилиты ----------
|
||||
|
||||
function detectLang(name) {
|
||||
var ext = name.split('.').pop().toLowerCase();
|
||||
var m = {
|
||||
|
||||
+398
-7
@@ -1,15 +1,20 @@
|
||||
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
|
||||
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
|
||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
||||
//
|
||||
// Маршруты:
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
||||
// GET /health — liveness/readiness probe
|
||||
// GET /funcs — usage hint (без токена)
|
||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
||||
// GET /funcs/<namespace>/source/<fn> — прокси: исходный код (JSON)
|
||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси: enable/disable триггера
|
||||
// GET /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
|
||||
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
|
||||
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
|
||||
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
|
||||
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
|
||||
// GET /health — liveness/readiness probe
|
||||
//
|
||||
// Env vars:
|
||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
||||
@@ -21,7 +26,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
@@ -29,6 +36,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -180,9 +188,51 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
|
||||
}
|
||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "fn-status":
|
||||
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "save-function":
|
||||
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "rename-function":
|
||||
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
case "delete-function":
|
||||
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 2 && parts[1] == "create-function" {
|
||||
// POST /funcs/{ns}/create-function — создать новую функцию через UI
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) > 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -549,3 +599,344 @@ func env(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
|
||||
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
|
||||
// Принимает code (основной файл) + deps (requirements.txt / package.json).
|
||||
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Kind string `json:"kind"` // "service" | "function"; default "service"
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
|
||||
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.Name) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Kind == "" {
|
||||
req.Kind = "service" // always-on по умолчанию — сразу получает URL
|
||||
}
|
||||
if req.MemoryMB <= 0 {
|
||||
req.MemoryMB = 128
|
||||
}
|
||||
if req.TimeoutSec <= 0 {
|
||||
req.TimeoutSec = 30
|
||||
}
|
||||
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
|
||||
// Шаг 1: создаём CRD через оператор (/functions или /services)
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.Name,
|
||||
"runtime": req.Runtime,
|
||||
"entrypoint": req.Entrypoint,
|
||||
"memory_mb": req.MemoryMB,
|
||||
"timeout_sec": req.TimeoutSec,
|
||||
})
|
||||
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
code := http.StatusBadGateway
|
||||
msg := "create CRD failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
code = createResp.StatusCode
|
||||
}
|
||||
http.Error(w, msg, code)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 2: упаковываем code + deps в zip и загружаем
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/upload"
|
||||
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
|
||||
}
|
||||
|
||||
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
|
||||
// Принимает code + deps (опционально), упаковывает в zip и загружает через оператор.
|
||||
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
|
||||
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Filename string `json:"filename"`
|
||||
Kind string `json:"kind"` // "function" | "service"
|
||||
Deps string `json:"deps"` // содержимое requirements.txt / package.json
|
||||
DepsFile string `json:"deps_filename"` // имя файла зависимостей
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
|
||||
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
zipFiles := map[string]string{req.Filename: req.Code}
|
||||
if req.Deps != "" && req.DepsFile != "" {
|
||||
zipFiles[req.DepsFile] = req.Deps
|
||||
}
|
||||
zipData, err := buildZipFiles(zipFiles)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
|
||||
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
|
||||
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
|
||||
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
|
||||
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
|
||||
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
http.Error(w, string(b), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
|
||||
}
|
||||
|
||||
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
|
||||
// Используется при создании/редактировании функций через UI.
|
||||
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
|
||||
func buildZipFiles(files map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.WriteString(f, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
|
||||
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
|
||||
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
|
||||
if !isValidK8sName(fnName) {
|
||||
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if r.URL.Query().Get("kind") == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
resp, err := operatorRequest(r.Context(), http.MethodGet,
|
||||
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(b) //nolint:errcheck
|
||||
}
|
||||
|
||||
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
|
||||
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
|
||||
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
|
||||
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
|
||||
if !isValidK8sName(oldName) {
|
||||
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
NewName string `json:"new_name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
|
||||
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isValidK8sName(req.NewName) {
|
||||
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resourceType := "functions"
|
||||
if req.Kind == "service" {
|
||||
resourceType = "services"
|
||||
}
|
||||
ctx := r.Context()
|
||||
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
|
||||
|
||||
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
|
||||
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if err != nil || cfgResp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "get old config failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
|
||||
cfgResp.Body.Close()
|
||||
|
||||
// Шаг 2: получаем исходный код старой функции
|
||||
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
|
||||
zipFiles := map[string]string{}
|
||||
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
|
||||
var files []struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
|
||||
for _, f := range files {
|
||||
if !f.Binary {
|
||||
zipFiles[f.Name] = f.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
srcResp.Body.Close()
|
||||
}
|
||||
|
||||
// Шаг 3: создаём новую функцию с новым именем
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
|
||||
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
|
||||
})
|
||||
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
|
||||
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
|
||||
msg := "create new failed"
|
||||
if createResp != nil {
|
||||
b, _ := io.ReadAll(createResp.Body)
|
||||
createResp.Body.Close()
|
||||
msg = string(b)
|
||||
}
|
||||
http.Error(w, msg, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
createResp.Body.Close()
|
||||
|
||||
// Шаг 4: загружаем старый код в новую функцию (если есть)
|
||||
if len(zipFiles) > 0 {
|
||||
if zipData, err := buildZipFiles(zipFiles); err == nil {
|
||||
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 5: удаляем старую функцию
|
||||
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
|
||||
if delResp != nil {
|
||||
delResp.Body.Close()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
|
||||
}
|
||||
|
||||
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
|
||||
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("code", filename+".zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(zipData); err != nil {
|
||||
return err
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
|
||||
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user