Compare commits
59
Commits
48009583a6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4197a76aba | ||
|
|
575f1e29a4 | ||
|
|
b3cce5bb70 | ||
|
|
7a6f6650c6 | ||
|
|
9da97663c6 | ||
|
|
54f036e228 | ||
|
|
5a0bb432ab | ||
|
|
0e26e98384 | ||
|
|
8519ba0f44 | ||
|
|
ea75cac1a8 | ||
|
|
383f8ea321 | ||
|
|
c5a4499094 | ||
|
|
047d53a67b | ||
|
|
2c51b0392d | ||
|
|
47e010edf3 | ||
|
|
96adf958bc | ||
|
|
e46bc35b98 | ||
|
|
e1e45423fa | ||
|
|
ea75507fe4 | ||
|
|
dc85e7b4e0 | ||
|
|
752244fa26 | ||
|
|
bed269cf27 | ||
|
|
fd3ab32534 | ||
|
|
46dc548af4 | ||
|
|
89bfb46b1c | ||
|
|
5d29010352 | ||
|
|
a68a36a0d2 | ||
|
|
5e1a6f06bc | ||
|
|
c822ae2f2a | ||
|
|
1e796c8eb1 | ||
|
|
cbd559d767 | ||
|
|
ad4daab358 | ||
|
|
12b3932817 | ||
|
|
190fc68f93 | ||
|
|
b81dc88abe | ||
|
|
c1b02a1c2d | ||
|
|
d6d0b733ae | ||
|
|
9aed2dcdd9 | ||
|
|
09e38928d0 | ||
|
|
a52170cf1e | ||
|
|
25f339bc8a | ||
|
|
5742457cc0 | ||
|
|
6b6252c42b | ||
|
|
70b937ea2f | ||
|
|
bc1af681aa | ||
|
|
318b847ede | ||
|
|
7d762387e7 | ||
|
|
0d7b8c5310 | ||
|
|
2675495eef | ||
|
|
034096d16d | ||
|
|
15cd369ec3 | ||
|
|
2fd9ef8aba | ||
|
|
ef22e78d8a | ||
|
|
a366f47eff | ||
|
|
6791e8fb5f | ||
|
|
ff38352bfc | ||
|
|
dc837e8e1a | ||
|
|
223b0ccdca | ||
|
|
ea725bd8b9 |
@@ -1,17 +1,31 @@
|
|||||||
НИКАКОЙ САМОДЕЙТЕЛЬНОСТИ !!! делать ТОЛЬКО ТО НА ЧТО ПОЛУЧЕНО РАЗРЕШЕНИЕ !!!!
|
# Правила работы в этом репозитории
|
||||||
НИКАКИХ ДОГАДОК !!! ЕСТь сомнения - СПРОСИ !!!
|
|
||||||
НИКОГДА НЕ ДЕЛАЙ ПРЕДПОЛОЖЕНИЙ !!!
|
## Разрешения и самодеятельность
|
||||||
ВСЕГДА СПРАШИВАЙ, ЕСЛИ НЕ УВЕРЕН !!!
|
|
||||||
НИКОГДА НЕ ИГНОРИРУЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
|
- **Никакой самодеятельности**: делать только то, на что получено разрешение.
|
||||||
ВСЕГДА ПОДТВЕРЖДАЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
|
- Полученные инструкции **не игнорировать**: соблюдать их и подтверждать.
|
||||||
НИКОГДА НЕ ИЗМЕНЯЙ ИНСТРУКЦИИ БЕЗ РАЗРЕШЕНИЯ !!!
|
- Соблюдать порядок и последовательность инструкций.
|
||||||
ВСЕГДА СОБЛЮДАЙ ПОРЯДОК И ПОСЛЕДОВАТЕЛЬНОСТЬ В ИНСТРУКЦИЯХ !!!
|
- Не превышать свои полномочия.
|
||||||
НИКОГДА НЕ ПРЕВЫШАЙ СВОИ ПОЛНОМОЧИЯ !!!
|
- Соблюдать безопасность и конфиденциальность.
|
||||||
ВСЕГДА СОБЛЮДАЙ БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ !!!
|
- Не изменять инструкции без разрешения.
|
||||||
НИКОГДА НЕ ПЕРЕДАВАЙ СЕКРЕТЫ ИНТЕРНЕТУ БЕЗ РАЗРЕШЕНИЯ !!!
|
- Не вызывать другие агенты без разрешения.
|
||||||
НЕ ВЫЗЫВАТЬ ДРУГИЕ АГЕНТЫ БЕЗ РАЗРЕШЕНИЯ !!!
|
- Не передавать секреты в интернет без разрешения.
|
||||||
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
|
|
||||||
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
## Сомнения и вопросы
|
||||||
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
|
||||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
- **Никаких догадок**: есть сомнения — спроси.
|
||||||
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
|
- Никогда не делать предположений и не действовать по догадкам.
|
||||||
|
- Всегда спрашивать, если не уверен.
|
||||||
|
- Если уверенности в распоряжении нет на 100 % — остановиться, спросить снова и подтвердить, что имел в виду пользователь. Не гадать.
|
||||||
|
- Перепроверять всё несколько раз.
|
||||||
|
|
||||||
|
## Коммиты и бэкапы
|
||||||
|
|
||||||
|
- Коммитить после каждой правки — чтобы зафиксировать текущее состояние и не потерять изменения.
|
||||||
|
- Сообщения коммитов — осмысленные, отражающие суть изменений.
|
||||||
|
- Всегда сохранять резервные копии важных файлов перед внесением изменений.
|
||||||
|
|
||||||
|
## Общий принцип
|
||||||
|
|
||||||
|
- Соблюдать инструкции, давать подтверждения и не делать самостоятельных изменений.
|
||||||
|
- Не полагаться на память — всегда проверять актуальность инструкций.
|
||||||
+1
-3
@@ -36,9 +36,6 @@ provider/generated/
|
|||||||
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
|
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
|
||||||
TMP/devbin/
|
TMP/devbin/
|
||||||
terraform-provider-nubes
|
terraform-provider-nubes
|
||||||
|
|
||||||
# === Build artifacts (generated by devops scripts) ===
|
|
||||||
devops/profiles/*/generated/
|
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.sha256
|
*.sha256
|
||||||
@@ -78,6 +75,7 @@ secrets/.s3cfg_provider
|
|||||||
secrets/.s3cfg*
|
secrets/.s3cfg*
|
||||||
secrets/pearlharbor_registry.txt
|
secrets/pearlharbor_registry.txt
|
||||||
secrets/id_ed25519.txt
|
secrets/id_ed25519.txt
|
||||||
|
secrets/
|
||||||
|
|
||||||
# === MkDocs ===
|
# === MkDocs ===
|
||||||
site/
|
site/
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Виртуальная машина внутри vApp — услуги 26 (vApp) и 28 (ВМ)
|
||||||
|
#
|
||||||
|
# Всё, что относится к ВМ, лежит ТОЛЬКО в этом файле: переменные, их значения
|
||||||
|
# по умолчанию, оба ресурса и выводы. Чтобы выключить ВМ — удалить или
|
||||||
|
# закомментировать этот файл (по аналогии с shturval.tf).
|
||||||
|
#
|
||||||
|
# Место в цепочке:
|
||||||
|
# орга (вручную в ЛК) → vDC (21) → Edge (22) → внешние IP → SNAT
|
||||||
|
# → [ vApp (26) → ВМ (28) ] → Штурвал (150)
|
||||||
|
#
|
||||||
|
# Зависимости (из манифестов услуг, сгенерированные ресурсы):
|
||||||
|
# vApp (26) — nubes_vapp: требует vdc_uid (21) и nsxt_uid (22)
|
||||||
|
# ВМ (28) — nubes_vc_vm_v3: требует vapp_uid (26)
|
||||||
|
#
|
||||||
|
# Внешний доступ: ВМ публикуется за общим SNAT эджа (same_snat = false), для
|
||||||
|
# этого ipSpace должен быть выделен на организации и включён как SNAT
|
||||||
|
# (см. modifiers.tf). Нужен ВЫДЕЛЕННЫЙ внешний адрес — same_snat = true.
|
||||||
|
# ipSpace для ВМ берём тот же, что у SNAT (var.ip_space_name).
|
||||||
|
#
|
||||||
|
# Режим destroy: у обеих услуг операция delete требует предварительного
|
||||||
|
# suspend, поэтому по умолчанию suspend_on_destroy = true («заморозка»).
|
||||||
|
# Полное удаление vApp возможно только через 14 дней после suspend.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Переменные vApp ---
|
||||||
|
|
||||||
|
variable "vapp_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vapp"
|
||||||
|
description = "Имя услуги «Виртуальный каталог ВМ (vApp)» в ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vapp_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vapp-01"
|
||||||
|
description = "Имя vApp. Маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации; участвует в DNS-имени ВМ. НЕ оставлять дефолтом платформы."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Переменные ВМ ---
|
||||||
|
|
||||||
|
variable "vm_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vm-01"
|
||||||
|
description = "Имя услуги «Виртуальная машина» в ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_name" {
|
||||||
|
type = string
|
||||||
|
default = "web01"
|
||||||
|
description = "Имя ВМ. Маска ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$. Определяет имя NSX-T IP Set: {vapp_name}-{vm_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_image" {
|
||||||
|
type = string
|
||||||
|
default = "Ubuntu_22-20G"
|
||||||
|
description = "Образ ОС. Доступные значения: RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G. Не изменяется после создания"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_cpu" {
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
description = "vCPU (1..64), шт"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_ram" {
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
description = "RAM (1..256), GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_disk" {
|
||||||
|
type = number
|
||||||
|
default = 20
|
||||||
|
description = "Дополнительный диск, GB (основной диск зависит от образа)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_user_login" {
|
||||||
|
type = string
|
||||||
|
default = "ubuntu"
|
||||||
|
description = "Учётка SSH. Не изменяется после создания"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_user_public_key" {
|
||||||
|
type = string
|
||||||
|
# ВСЕ параметры ВМ живут в этом файле — включая ключ. Удалил файл — ВМ исключена
|
||||||
|
# из конфига полностью, в terraform.tfvars ничего про ВМ не остаётся.
|
||||||
|
# Здесь публичный ключ (не секрет), тот же, что в secrets/id_ed25519.pub.
|
||||||
|
# Переопределить можно в terraform.tfvars — но тогда при исключении ВМ
|
||||||
|
# надо удалить и эту строку (иного способа у Terraform нет).
|
||||||
|
default = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPR8S07Mnku1VlVR/lq6hCKPo9fNzJ+7E0DoE7bkvy4p tazet@narod.ru"
|
||||||
|
description = "Публичная часть SSH-ключа в формате OpenSSH. Не изменяется после создания. По умолчанию — ключ tazet@narod.ru"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_access_port_list" {
|
||||||
|
type = list(object({
|
||||||
|
port = string
|
||||||
|
type = string
|
||||||
|
}))
|
||||||
|
default = [
|
||||||
|
{ port = "22", type = "tcp" }
|
||||||
|
]
|
||||||
|
description = "Белый список портов для доступа извне; type: tcp | udp | all"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_access_ip_list" {
|
||||||
|
type = list(string)
|
||||||
|
default = ["0.0.0.0/0"]
|
||||||
|
description = "Белый список адресов, которым разрешён доступ к ВМ. Требует выделенного внешнего IP"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_same_snat" {
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
description = "false — публикация за общим SNAT эджа; true — за выделенным внешним IP услуги"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- vApp (услуга 26) ---
|
||||||
|
|
||||||
|
resource "nubes_vapp" "vapp" {
|
||||||
|
resource_name = var.vapp_resource_name
|
||||||
|
vapp_name = var.vapp_name
|
||||||
|
|
||||||
|
vdc_uid = nubes_vc_vdc.vdc.id # ref 21 — вычислительная инфраструктура
|
||||||
|
nsxt_uid = nubes_vc_nsxt.edge.id # ref 22 — сеть/маршрутизация
|
||||||
|
|
||||||
|
# «Заморозка»: destroy переводит vApp в suspend (delete требует suspend).
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
# Повторный apply усыновляет существующий vApp, а не падает с
|
||||||
|
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ».
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
|
||||||
|
# vApp требует готовую сеть (ipSpace на организации + SNAT на эдже).
|
||||||
|
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- ВМ (услуга 28) ---
|
||||||
|
|
||||||
|
resource "nubes_vc_vm_v3" "vm" {
|
||||||
|
resource_name = var.vm_resource_name
|
||||||
|
vm_name = var.vm_name
|
||||||
|
|
||||||
|
vapp_uid = nubes_vapp.vapp.id # ref 26 — ВМ размещается в vApp
|
||||||
|
image_vm = var.vm_image
|
||||||
|
|
||||||
|
vm_cpu = var.vm_cpu
|
||||||
|
vm_ram = var.vm_ram
|
||||||
|
vm_disk = var.vm_disk
|
||||||
|
|
||||||
|
user_login = var.vm_user_login
|
||||||
|
user_public_key = var.vm_user_public_key
|
||||||
|
|
||||||
|
# Внешний доступ
|
||||||
|
ip_space_name = var.ip_space_name # тот же ipSpace, что у SNAT эджа
|
||||||
|
same_snat = var.vm_same_snat
|
||||||
|
access_port_list = jsonencode(var.vm_access_port_list)
|
||||||
|
access_ip_list = jsonencode(var.vm_access_ip_list)
|
||||||
|
|
||||||
|
# «Заморозка»: destroy переводит ВМ в suspend.
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
|
||||||
|
# ВМ создаётся платформой долго — поднимаем таймаут ожидания.
|
||||||
|
operation_timeout = "15m"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Выводы ---
|
||||||
|
|
||||||
|
output "vapp_id" {
|
||||||
|
description = "UID созданного vApp (услуга 26)"
|
||||||
|
value = nubes_vapp.vapp.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vapp_name" {
|
||||||
|
description = "Имя vApp"
|
||||||
|
value = nubes_vapp.vapp.vapp_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vm_id" {
|
||||||
|
description = "UID созданной ВМ (услуга 28)"
|
||||||
|
value = nubes_vc_vm_v3.vm.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vm_state_flat" {
|
||||||
|
description = "Плоский state ВМ — IP-адреса, статус и т.д."
|
||||||
|
value = nubes_vc_vm_v3.vm.state_out_flat
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ API стенда ──▶ generated/<стенд>/resources_yaml/ ──▶ gene
|
|||||||
| `scripts/publish-docs.sh` | актуальная заливка docs в S3 (без версии) |
|
| `scripts/publish-docs.sh` | актуальная заливка docs в S3 (без версии) |
|
||||||
| `scripts/publish-doc-page.sh` | заливка одной страницы |
|
| `scripts/publish-doc-page.sh` | заливка одной страницы |
|
||||||
| `TOOLS/config/<стенд>/profile.env` | параметры стенда (endpoint, NAMESPACE, VERSION, токен, GPG) |
|
| `TOOLS/config/<стенд>/profile.env` | параметры стенда (endpoint, NAMESPACE, VERSION, токен, GPG) |
|
||||||
| `TOOLS/config/registry.env`, `services_list.txt`, `operation_timeouts.json` | конфиги реестра/генерации |
|
| `TOOLS/config/registry.env`, `TOOLS/config/<стенд>/{services_list.txt,operation_timeouts.json}` | конфиги реестра/генерации |
|
||||||
| `TOOLS/bin/` | генераторы: `resource-generator`, `docs-generator`, `yaml-generator` |
|
| `TOOLS/bin/` | генераторы: `resource-generator`, `docs-generator`, `yaml-generator` |
|
||||||
| `secrets/{dev,test,prod}.token`, `.s3cfg_registry`, `private_key.asc` | токены API, S3-креды, GPG |
|
| `secrets/{dev,test,prod}.token`, `.s3cfg_registry`, `private_key.asc` | токены API, S3-креды, GPG |
|
||||||
| `mkdocs.yml` | базовый конфиг MkDocs (тема material, exclude_docs, extra) |
|
| `mkdocs.yml` | базовый конфиг MkDocs (тема material, exclude_docs, extra) |
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# VPN transit via VM 213 and Vultr
|
||||||
|
|
||||||
|
Date: 2026-09-27 to 2026-09-28
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Provide access from Russian residential/mobile networks to services restricted by Russian network filtering, while retaining the existing foreign egress on Vultr.
|
||||||
|
|
||||||
|
## Verified network facts
|
||||||
|
|
||||||
|
- Test host `3060`: `46.39.251.163`, connection from Khimki / Iskratelecom.
|
||||||
|
- Transit VM `213`: `5.172.178.213`, public egress observed as `5.172.178.65`; hosted in NUBES data centre.
|
||||||
|
- Vultr addresses: primary `95.179.252.111`; secondary `104.238.177.67`.
|
||||||
|
- `3060 -> 213`: ICMP approximately 3 ms, 0% loss.
|
||||||
|
- `213 -> Vultr`: ICMP approximately 34 ms, 0% loss; HTTPS response returned in about 0.07-0.11 s.
|
||||||
|
- Direct `213 -> Vultr` test file transfer: 10 MiB in 1.59 s, about 6.27 MiB/s / 50.2 Mbit/s.
|
||||||
|
- Direct `3060 -> Vultr` test file transfer timed out / was throttled.
|
||||||
|
- Direct `213 -> OVH proof endpoint`: 10 MiB in 1.18 s, about 8.5 MiB/s.
|
||||||
|
- Direct access from `213` to YouTube and Telegram failed with `HTTP=000` and timeout/SSL errors, while OVH and Google returned HTTP 200. Therefore a foreign egress remains required for those services.
|
||||||
|
|
||||||
|
## Persistent changes on VM 213
|
||||||
|
|
||||||
|
- Created backup:
|
||||||
|
- `/etc/nginx/sites-available/check.kube5s.ru.bak_vpn`
|
||||||
|
- Modified:
|
||||||
|
- `/etc/nginx/sites-available/check.kube5s.ru`
|
||||||
|
- Added an Nginx `/ws` reverse-proxy location with:
|
||||||
|
- upstream `https://95.179.252.111:443`
|
||||||
|
- SNI `vipien.kube5s.ru`
|
||||||
|
- upstream Host header `vipien.kube5s.ru`
|
||||||
|
- WebSocket upgrade headers
|
||||||
|
- 3600-second proxy timeouts
|
||||||
|
- Ran `nginx -t` successfully and reloaded Nginx.
|
||||||
|
- Existing unrelated Nginx warnings about duplicate `contracts.kube5s.ru` server names remained.
|
||||||
|
|
||||||
|
## Persistent/previously existing changes on Vultr
|
||||||
|
|
||||||
|
The following configuration was read or used during validation:
|
||||||
|
|
||||||
|
- `/etc/nginx/conf.d/vipien.conf`: TLS/WebSocket endpoint for `vipien.kube5s.ru`.
|
||||||
|
- `/etc/v2ray-agent/xray/conf/08_VLESS_ws_inbound.json`: VLESS WebSocket inbound on `127.0.0.1:10086`, path `/ws`.
|
||||||
|
- `/etc/systemd/system/hysteria-server.service`: Hysteria service was stopped and disabled; it was not changed in this work.
|
||||||
|
- Xray service was confirmed active.
|
||||||
|
- Nginx service was confirmed active.
|
||||||
|
- Cloudflared tunnel configuration was inspected earlier, but it is not used by the final working route.
|
||||||
|
- A temporary 10 MiB test file was created on Vultr and removed after testing.
|
||||||
|
|
||||||
|
## Temporary files on test VM 3060
|
||||||
|
|
||||||
|
The following temporary client files were created under `/tmp/xray-test/` for validation and are not repository files:
|
||||||
|
|
||||||
|
- `client-cf.json`
|
||||||
|
- `client-213.json`
|
||||||
|
- `client-directip.json`
|
||||||
|
- temporary log/test artifacts where applicable
|
||||||
|
|
||||||
|
The files contained test Xray client configurations. They were used only to verify the route from `3060`; no permanent system service was installed there.
|
||||||
|
|
||||||
|
## Final tested route
|
||||||
|
|
||||||
|
`client in Russia -> 5.172.178.213:443 -> Nginx WebSocket proxy -> 95.179.252.111:443 -> Xray -> Internet`
|
||||||
|
|
||||||
|
Final test from `3060` through the route:
|
||||||
|
|
||||||
|
- observed outbound IP: `95.179.252.111`
|
||||||
|
- 10 MiB OVH download: 1.76-1.91 s
|
||||||
|
- measured speed: approximately 5.5-6.0 MiB/s
|
||||||
|
|
||||||
|
## Final client parameters
|
||||||
|
|
||||||
|
- Address: `5.172.178.213`
|
||||||
|
- Port: `443`
|
||||||
|
- UUID: existing UUID used by the Vultr Xray inbound
|
||||||
|
- TLS SNI: `check.kube5s.ru`
|
||||||
|
- WebSocket path: `/ws`
|
||||||
|
- WebSocket Host: `vipien.kube5s.ru`
|
||||||
|
|
||||||
|
The final direct-IP test used Xray 26.3.27. The client-side `allowInsecure` option was not used because this Xray version reports that the option was removed.
|
||||||
|
|
||||||
|
## Secondary Vultr IP
|
||||||
|
|
||||||
|
Before removal, the Nginx upstream on VM 213 was switched from `104.238.177.67` to `95.179.252.111`. A post-switch end-to-end test succeeded, with outbound IP `95.179.252.111` and approximately 6.0 MiB/s.
|
||||||
|
|
||||||
|
No Vultr IP deletion was performed in this work. The secondary address was only confirmed as no longer referenced by the transit configuration.
|
||||||
|
|
||||||
|
## Scope audit
|
||||||
|
|
||||||
|
- No repository source/configuration files were edited before this record.
|
||||||
|
- `git status` was clean before this documentation file was created.
|
||||||
|
- This documentation file is the only workspace file created by the current documentation action.
|
||||||
|
- Server-side files were changed on VM 213 and earlier on Vultr; temporary test files were also created on VM 3060.
|
||||||
|
- No commit was created for this record.
|
||||||
|
|
||||||
|
## Important limitations
|
||||||
|
|
||||||
|
The measurements prove the route worked at test time. They do not guarantee permanent availability: NUBES, Vultr, upstream providers, or network filtering policy can change independently.
|
||||||
|
|
||||||
|
## Later the same day: optimisation attempt and its outcome
|
||||||
|
|
||||||
|
### Automation created
|
||||||
|
|
||||||
|
A reusable, idempotent tool was created outside this repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/naeel/nubes/HowTo/vpn-transit/vpn-setup.sh check | apply | verify | passthrough | verify-passthrough | client-config | rollback
|
||||||
|
/home/naeel/nubes/HowTo/vpn-transit/client-config.json generated client config (chmod 600, contains UUID)
|
||||||
|
/home/naeel/nubes/HowTo/vpn-transit/README.md description, measurements, rollback
|
||||||
|
/home/naeel/nubes/HowTo/howto-vpn-transit-213-vultr-2026-09-28.md full report
|
||||||
|
```
|
||||||
|
|
||||||
|
Every change is preceded by a timestamped backup and followed by a config test (`nginx -t`, `xray run -test`) with automatic rollback on failure.
|
||||||
|
|
||||||
|
### Changes applied
|
||||||
|
|
||||||
|
| Host | File | Change | Backup |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 213 | `/etc/nginx/sites-available/check.kube5s.ru` | `proxy_buffering off;` added inside `location /ws`, marked `# vpn-transit: proxy_buffering off` | `check.kube5s.ru.bak.1790601681` |
|
||||||
|
| Vultr | `/etc/v2ray-agent/xray/conf/00_log.json` | `loglevel`: `debug` → `warning` (log had grown to 76 MB), service restarted | `00_log.json.bak.1790601723` |
|
||||||
|
| 213 | `/usr/local/sbin/vpn-transit-dnat.sh`, `/etc/systemd/system/vpn-transit-dnat.service` | DNAT `213:8443 → 95.179.252.111:443` plus FORWARD rules, enabled at boot | none (rules tagged `vpn-transit`) |
|
||||||
|
|
||||||
|
### Measurements after the changes
|
||||||
|
|
||||||
|
- Outbound IP: `95.179.252.111`
|
||||||
|
- Throughput: `5.6–7.3 MiB/s` (10 MiB in 1.4–1.9 s)
|
||||||
|
- Per-connection latency: `0.23–0.37 s`
|
||||||
|
- WebSocket upgrade success rate on 213: `14569 / 14573` (99.97%), one `upstream timed out` error
|
||||||
|
|
||||||
|
### Hypothesis that was disproved: mux
|
||||||
|
|
||||||
|
`verify` compared the tunnel with and without `"mux": {"enabled": true, "concurrency": 8}`:
|
||||||
|
|
||||||
|
| Mode | 10 MiB download | Connection behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| without mux | 7.32 MiB/s in 1.43 s | stable |
|
||||||
|
| with mux | **0 B/s, failed** | after 4 requests connections hang for 15 s |
|
||||||
|
|
||||||
|
Conclusion: mux is harmful in the `VLESS + WebSocket behind nginx` combination. It is excluded from the client config. The test remains in the script for re-checking on future Xray versions.
|
||||||
|
|
||||||
|
### Optimisation that could not be delivered: removing the second TLS layer
|
||||||
|
|
||||||
|
The intended speed fix was to drop one TLS handshake (`client → 213`, then `213 → Vultr`) by forwarding TCP straight through to Vultr.
|
||||||
|
|
||||||
|
- `ngx_stream_module.so` is absent on 213, so nginx cannot do SNI-based passthrough without installing `libnginx-mod-stream`.
|
||||||
|
- Kernel-level DNAT on port 8443 was installed instead, but **does not work**: from outside, port 8443 returns `Connection refused` and the DNAT counter on 213 stays at 0 packets — traffic never reaches the machine.
|
||||||
|
- Cause: the provider firewall in front of 213 exposes only ports 80 and 443. Measured from `3060`: `3001, 8080, 8443, 8766, 8767, 8888, 18080, 40229` are closed.
|
||||||
|
- Therefore the second TLS layer can only be removed after the provider opens an additional port. The rules are already installed and would start working immediately once that happens.
|
||||||
|
|
||||||
|
### Errors made during this work
|
||||||
|
|
||||||
|
1. **Recommended `mux` before measuring it.** The recommendation was given as the main fix and was later disproved by measurement. Correct order: measure first, recommend after.
|
||||||
|
2. **Changed server configuration before measuring the benefit.** `proxy_buffering off` has no effect on a WebSocket connection after the `101 Switching Protocols` upgrade, and `loglevel` affects only log size. Neither change improves speed, so from the user's point of view nothing changed.
|
||||||
|
3. **Changed the client config to port 8443 before verifying the port was reachable from outside.** The config was regenerated back to port 443 immediately.
|
||||||
|
|
||||||
|
### Net result for the user
|
||||||
|
|
||||||
|
Nothing changed for the client: address `5.172.178.213`, port `443`, SNI `check.kube5s.ru`, path `/ws` and the UUID are unchanged, and the previously used link still works. No client-side reconfiguration is required.
|
||||||
|
|
||||||
|
The only actionable finding is client-side: the Xray log on Vultr contained **331** `connect: connection refused` to `127.0.0.1:45987`, i.e. the client requested a loopback address, plus Telegram advertises AAAA records while the tunnel is IPv4-only. The generated `client-config.json` addresses both (remote DNS, `queryStrategy: UseIPv4`), but the device itself was not modified.
|
||||||
|
|
||||||
|
Separately: **10170** `reset by peer` entries to `157.240.0.13` (Meta infrastructure) are blocking by those sites, unrelated to the transit.
|
||||||
|
|
||||||
|
### Scope audit (this action)
|
||||||
|
|
||||||
|
- Repository files changed: this document only. `git status` also showed unrelated pre-existing changes (`DEV_STAND/FullPipe/shturval.tf` deletion, `TMP/*` files) that were **not** touched or committed.
|
||||||
|
- Server-side files changed: as listed in the table above.
|
||||||
|
- Temporary test files on 3060: `/tmp/xray-test/*` (no permanent service installed).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Правки ядра и модификаторов по итогам анализа 2026-09-30 (раунд Flash)
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider`. **Дата:** 2026-09-30.
|
||||||
|
**Источник заданий:** `NOTES/20_prompts/prompt_for_deepseek_pro_remediation_plan.md`
|
||||||
|
(гипотезы A1–A5, B1–B9) + диалог с Opus `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что сделано (6 коммитов)
|
||||||
|
|
||||||
|
| Коммит | Пункт | Файлы | Суть |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `047d53a` | C | `TOOLS/ARCHITECTURE.md` | Спека приведена к коду: ручные ресурсы, 401, удалённый `serviceSpecificModifiers`, раздел «Lifecycle Vocabulary». |
|
||||||
|
| `c5a4499` | B3 | `TOOLS/scripts/check_hardcoded_service_ids.sh`, `org_ip_allocation_resource.go` | Страж сканирует `TOOLS/` + `provider/internal/` (кроме `resources_gen/`), второй паттерн — литеральный ref-svc id. `ResolveRefSvcParamValue(ctx, 19, …)` → константа `svcIDVcOrg`. |
|
||||||
|
| `383f8ea` | A1 | `core/http.go`, `core/client_test.go`, `ARCHITECTURE.md` | 401 добавлен в `isRetryable` (действует для GET). Тест `TestIsRetryable`. |
|
||||||
|
| `ea75cac` | B6 | `core/modifier_compare.go`, `operation_run_bycode.go`, `nsxt_snat_resource.go` | Idempotency pre-check сравнивает с **live** (`state.params`), а не с `paramValue` формы. `setSnat` → `ByIdempotent`. Тест `TestModifierDesiredEqualsLive`. |
|
||||||
|
| `8519ba0` | B7 | `org_ip_allocation_resource.go`, `nsxt_snat_resource.go`, `org_ip_allocation_test.go` | `ImportState` заполняет Required (`vip_configure` из live/`[]`; `ip_space_name` из live/`no-needed`). Тесты на чистые хелперы. |
|
||||||
|
| `0e26e98` | — | `core/refsvc.go`, `docs/60_strategy/terraform_case_sensitivity_fix.md` | Убран устаревший комментарий про несуществующий блок «Restore user-provided casing»; §4 помечен как исторический. |
|
||||||
|
|
||||||
|
**Проверка после каждого коммита:** `go build ./...` OK, `go test ./internal/... -short` PASS,
|
||||||
|
`bash TOOLS/scripts/check_hardcoded_service_ids.sh` → OK.
|
||||||
|
|
||||||
|
**Бэкапы:** `TMP/backup_2026-09-30/` (исходные версии всех правимых файлов).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что ОТКЛОНЕНО после проверки по коду (важно)
|
||||||
|
|
||||||
|
- **A5 (нормализация регистра в `Read`) — был бы РЕГРЕССОМ.**
|
||||||
|
Принятое решение (проверено): state хранит регистр **пользователя**; ref_svc-атрибуты **исключены
|
||||||
|
из read-back** (шаблон `instance.go` добавляет `InputField` только при `eq .RefSvcId 0`); UUID
|
||||||
|
внутри JSON нормализуются при **отправке** (`resources_core.BuildJSON` →
|
||||||
|
`jsonutil.LowercaseUUIDsInText`). См. `docs/60_strategy/terraform_case_sensitivity_fix.md` §4 (пометка),
|
||||||
|
§10–§11. Нормализация state к lowercase сломала бы соответствие plan=config.
|
||||||
|
- **A3 (не обрывать modify при сбое live) — осознанная защита, а не дефект.**
|
||||||
|
`instanceLiveParams` намеренно возвращает ошибку: тихий fallback на `paramValue` (дефолт ФОРМЫ)
|
||||||
|
возвращает reset-баг (затирание параметров инстанса, HAR/edge_.har: `needEnableAVI`). Требуется
|
||||||
|
отдельное решение (см. Q2 промпта раунда 4).
|
||||||
|
- **A4 (угадывание типа по подстроке имени) — нужен замер.**
|
||||||
|
Fallback применяется только к required-параметру без `paramValue`/`defaultValue`
|
||||||
|
(`instance_create.go:105-113`) и при досылке modify. Гарантированного улучшения нет, риск сломать
|
||||||
|
больше, чем починить. Оставлено как есть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отложено
|
||||||
|
|
||||||
|
- **A2 — retry POST.** Слепой ретрай создающего `POST /instanceOperations` опаснее обрыва
|
||||||
|
(дубликат операции). Решение — за владельцем (варианты в промпте раунда 4, Q1).
|
||||||
|
- **B8** — создавать ли оверлей `modifiers.yaml` или узаконить ручные модификаторы категорией в спеке.
|
||||||
|
- **B9** — единый словарь жизненного цикла (в спеку внесён как незакрытый вопрос; решение — Q4 промпта).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Артефакты
|
||||||
|
|
||||||
|
- Промпт раунда 4 для Opus: `NOTES/20_prompts/prompt_for_opus_remediation_round4.md`
|
||||||
|
(5 коротких вопросов, лимит ответа ≤ 25 строк).
|
||||||
|
- Ограничение сессии: чат с Opus по раундам 1–3 исчерпан по токенам → раунд 4 в новом чате.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Замер Q3 (2026-09-30): безопасно ли угадывание типа по имени?
|
||||||
|
|
||||||
|
**Источник:** `generated/dev/resources_yaml/*.yaml` (40 файлов), поля `data_type` / `required`.
|
||||||
|
**Метод:** подсчёт + эмуляция `normalizeUniversalValueV6` (ветка `nameHint`). Только чтение.
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---|---|
|
||||||
|
| required-параметров всего | 852 |
|
||||||
|
| из них с пустым `data_type` | 5 |
|
||||||
|
| всего параметров с пустым `data_type` | 12 (~1.2 %) |
|
||||||
|
| из них угадывание по имени даёт ≠ `""` | **1** — `1_dummy.yaml` (`jsonExample` → `{}`), тестовый сервис |
|
||||||
|
|
||||||
|
Required с пустым `data_type` (все получают `""`; угадывание не срабатывает):
|
||||||
|
`120_clickhouse/delete:username`, `12_s3/create:resourceRealm`, `13_s3bucket/create:maxSize`,
|
||||||
|
`151_k8s_openbao/create:policyName`, `28_vc_vm_v3/create:userLogin`.
|
||||||
|
|
||||||
|
**Вывод.** Гипотеза A4 («риск неверной типизации» из-за подстроки имени) на dev-спеках
|
||||||
|
**не подтверждается**: для всех реальных сервисов угадывание по имени не срабатывает (итог `""`);
|
||||||
|
единственный эффект — тестовый `1_dummy.jsonExample`. То есть правка косметическая (упрощение),
|
||||||
|
а не исправление дефекта. Решение «снимать/оставлять» — за владельцем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Раунд 4 (Opus, новый чат) — решения и правки
|
||||||
|
|
||||||
|
Промпт: `NOTES/20_prompts/prompt_for_opus_remediation_round4.md` (5 вопросов, ответ ≤ 25 строк).
|
||||||
|
Ответ получен; ниже — что принято и что сделано.
|
||||||
|
|
||||||
|
| Q | Решение | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 retry POST | Не ретраить. `POST /instanceOperations` не идемпотентен, `Idempotency-Key` у API нет. | Зафиксировано в `TOOLS/ARCHITECTURE.md` (коммит `7a6f665`) |
|
||||||
|
| Q2 черновик операции | Отмены нет: `DELETE /instanceOperations/{uid}` отсутствует и в коде, и в HAR (проверено: `grep '"method": "DELETE"'` по `HAR/*.har` — ноль совпадений). Оставляем как есть, задокументировано. | `7a6f665` |
|
||||||
|
| Q3 zero-value по имени | **Закрыт замером**: угадывание не срабатывает (см. выше) — не дефект. | замер `54f036e` |
|
||||||
|
| Q4 словарь жизненного цикла | Единый контракт: `keep_on_destroy` + `suspend_on_destroy`; `delete_strategy` = маппинг (`noop_warn`→keep, `inverse`→destroy, `error`→валидация). | `7a6f665` |
|
||||||
|
| Q5 осиротевший инстанс | **Исправлено** (критичный). Ядро возвращает `instanceUid` вместе с ошибкой после создания; шаблон пишет partial state. | `9da9766` |
|
||||||
|
|
||||||
|
**Q5 детали:** `core/instance_create.go` — все ошибки ПОСЛЕ получения `instanceUid` возвращают
|
||||||
|
`instanceUid` (до создания — `""`); `templates/instance.go` — при `err != nil && id != ""` пишет
|
||||||
|
`data.ID` + `resp.State.Set` перед `AddError`. Регенерация dev (`02` + `dev-materialize`) → фикс в
|
||||||
|
40 файлах `resources_gen` (эфемерные, не в git). Тесты: `TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails`,
|
||||||
|
`TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails`.
|
||||||
|
|
||||||
|
**Коммиты раунда 4:** `54f036e` (замер), `9da9766` (Q5), `7a6f665` (Q1/Q2/Q4).
|
||||||
|
|
||||||
|
**Осталось:** замер владельцем (`terraform plan` ×2 на `DEV_STAND/FullPipe`); B8 (`modifiers.yaml`).
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 2026-09-30 — Очистка dev-реестра: удалены версии провайдера старше 2.0.21
|
||||||
|
|
||||||
|
> Команда владельца: «в деве — сотри ФИЗИЧЕСКИ все провайдеры старше 21 версии».
|
||||||
|
> Операция **необратимая** (бакет без версионирования), выполнена 2026-09-30.
|
||||||
|
|
||||||
|
## Что и где
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|---|---|
|
||||||
|
| Хранилище | S3 `https://s3.msk-1.ngcloud.ru`, бакет `nubes-terraform-registry` |
|
||||||
|
| Префикс | `tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/` |
|
||||||
|
| Стенд | **только dev** (`nubes-dev`); `nubes-test` и `nubes` не тронуты |
|
||||||
|
| Инструмент | `mc` (`/usr/bin/mc`), alias `prod-s3`, `--api S3v4`, креды из `secrets/.s3cfg_provider` |
|
||||||
|
| Версионирование бакета | `un-versioned` — удаление физическое, без «теневых» копий |
|
||||||
|
|
||||||
|
Состав одной версии — 5 объектов:
|
||||||
|
|
||||||
|
```
|
||||||
|
terraform-provider-nubes_<v>_darwin_amd64.zip (~13 MiB)
|
||||||
|
terraform-provider-nubes_<v>_linux_amd64.zip (~13 MiB)
|
||||||
|
terraform-provider-nubes_<v>_windows_amd64.zip (~13 MiB)
|
||||||
|
terraform-provider-nubes_<v>_SHA256SUMS
|
||||||
|
terraform-provider-nubes_<v>_SHA256SUMS.sig
|
||||||
|
```
|
||||||
|
|
||||||
|
## Было → стало
|
||||||
|
|
||||||
|
| | До | После |
|
||||||
|
|---|---|---|
|
||||||
|
| Версий | 25 (`2.0.0` … `2.0.24`) | **4** (`2.0.21`, `2.0.22`, `2.0.23`, `2.0.24`) |
|
||||||
|
| Объектов | 125 | **20** |
|
||||||
|
| Объём (zip) | ~975 MiB | ~156 MiB |
|
||||||
|
|
||||||
|
## Удалено (21 версия, 105 объектов)
|
||||||
|
|
||||||
|
```
|
||||||
|
2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7
|
||||||
|
2.0.8 2.0.9 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15
|
||||||
|
2.0.16 2.0.17 2.0.18 2.0.19 2.0.20
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая версия: 5 объектов (3 zip ~13 MiB + `SHA256SUMS` + `SHA256SUMS.sig`).
|
||||||
|
|
||||||
|
Команда (по версии):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mc rm --recursive --force \
|
||||||
|
"prod-s3/nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/<v>/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед удалением снят полный манифест (125 строк):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mc ls -r "prod-s3/nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Оставлено
|
||||||
|
|
||||||
|
```
|
||||||
|
2.0.21 2.0.22 2.0.23 2.0.24 (20 объектов, 4 × 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Актуальная версия dev — `2.0.24` (см. `VERSIONS.md`).
|
||||||
|
|
||||||
|
## Проверки после удаления
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `mc ls` префикса dev | только `2.0.21/`, `2.0.22/`, `2.0.23/`, `2.0.24/` |
|
||||||
|
| `mc ls -r` (всего объектов) | 20 (по 5 на версию) |
|
||||||
|
| API `…/nubes-dev/nubes/versions` | `['2.0.21','2.0.22','2.0.23','2.0.24']`, count 4 |
|
||||||
|
| Скачивание `2.0.24`/`2.0.23` (linux/amd64) | HTTP **206**, ZIP-магия `50 4b 03 04` |
|
||||||
|
| Скачивание `2.0.20`/`2.0.0` (linux/amd64) | HTTP **404** (объекта нет) |
|
||||||
|
| Контроль: `nubes-test` | `3.0.0` — не тронуто |
|
||||||
|
| Контроль: `nubes` (prod) | `1.0.0` — не тронуто |
|
||||||
|
|
||||||
|
> Примечание: эндпоинт `…/<v>/download/<os>/<arch>` отдаёт метаданные (JSON с `download_url`)
|
||||||
|
> **не проверяя наличие объекта** — статус 200 у него ничего не доказывает. Фактическая
|
||||||
|
> доступность проверяется загрузкой по `download_url` (как в таблице выше).
|
||||||
|
|
||||||
|
## Риски и восстановление
|
||||||
|
|
||||||
|
- ⛔ **Резервные копии zip не делались** — по прямому указанию «стереть физически»
|
||||||
|
(плюс канал до S3 из локальной сети медленный). Восстановление возможно **только
|
||||||
|
пересборкой** нужной версии из git-истории пакета;
|
||||||
|
`download_url`/`SHA256SUMS` удалённых версий не сохранялись.
|
||||||
|
- Пользователи, закрепившие в dev-стендах версии `< 2.0.21`, получат 404 при `terraform init`
|
||||||
|
и должны перейти на `2.0.21+`.
|
||||||
|
- `test` и `prod` не затронуты.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 2026-09-30 — Перезаливка провайдера во все три стенда под версиями 1.0.0 / 2.0.0 / 3.0.0
|
||||||
|
|
||||||
|
> Команда владельца: «надо — чтобы в 1.0.0 2.0.0 3.0.0 стали НОВЫЕ провайдеры…
|
||||||
|
> ПОХУЙ на пользователей! ПОХУЙ на старые версии!!! генери всё новое и ЗАЛИВАЙ».
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
Полный цикл по каждому стенду: перегенерация (`01` YAML → `02` Go+доки) и
|
||||||
|
сборка+подпись+заливка (`03`) — всё одной командой `03` (она сама вызывает `01` и `02`).
|
||||||
|
|
||||||
|
| Стенд | Namespace | Версия | `VERSION` в profile.env | Результат |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| prod | `nubes` | `1.0.0` | `1.0.0` (без изменений) | `Done. Version 1.0.0 uploaded.` |
|
||||||
|
| dev | `nubes-dev` | `2.0.0` | `2.0.24` → **`2.0.0`** | `Done. Version 2.0.0 uploaded.` |
|
||||||
|
| test | `nubes-test` | `3.0.0` | `3.0.0` (без изменений) | `Done. Version 3.0.0 uploaded.` |
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MC_CONFIG_DIR=/tmp/mc-cfg
|
||||||
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.0
|
||||||
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test 3.0.0
|
||||||
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/prod 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делает `03`: `01` (YAML-спеки стенда) → `02` (Go-ресурсы + доки) → сборка
|
||||||
|
3 платформ (`linux/windows/darwin amd64`, `CGO_ENABLED=0`, `-ldflags=-X main.version=…`)
|
||||||
|
→ `terraform-provider-nubes_<v>_SHA256SUMS` → GPG-подпись (`secrets/private_key.asc`,
|
||||||
|
`CB3A0DF161ECC416`) → заливка 5 объектов в S3.
|
||||||
|
|
||||||
|
## Проверки после заливки
|
||||||
|
|
||||||
|
| Стенд | Версия | `linux/amd64` скачивание | sha256 залитого vs локальной сборки |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dev | `2.0.0` | HTTP 200, 13 169 112 B | ✅ совпадает |
|
||||||
|
| test | `3.0.0` | HTTP 200, 13 095 908 B | ✅ совпадает |
|
||||||
|
| prod | `1.0.0` | HTTP 200, 13 103 414 B | ✅ совпадает |
|
||||||
|
|
||||||
|
Дополнительно:
|
||||||
|
|
||||||
|
- API `/versions`: `nubes-dev` → `['2.0.0','2.0.21','2.0.22','2.0.23','2.0.24']`,
|
||||||
|
`nubes-test` → `['3.0.0']`, `nubes` → `['1.0.0']`;
|
||||||
|
- в S3 у каждой версии ровно 5 объектов (3 zip + `SHA256SUMS` + `SHA256SUMS.sig`).
|
||||||
|
|
||||||
|
## ⚠️ Последствия (приняты владельцем сознательно)
|
||||||
|
|
||||||
|
- Версии `1.0.0`, `2.0.0`, `3.0.0` **перезаписаны** — под теми же номерами теперь
|
||||||
|
другие бинарники. У всех, у кого есть `.terraform.lock.hcl`, будет
|
||||||
|
`checksum mismatch` при `terraform init`; лечится `terraform init -upgrade`.
|
||||||
|
- Старые версии **не удалялись** (кроме ранее вычищенного dev `< 2.0.21`):
|
||||||
|
в dev остаются `2.0.21`–`2.0.24`, в test `3.0.0` и в prod `1.0.0` — теперь уже как
|
||||||
|
свежие сборки.
|
||||||
|
- `VERSION` в `TOOLS/config/dev/profile.env` понижен `2.0.24` → `2.0.0`
|
||||||
|
(чтобы доки и артефакты генерировались с новой версией); закоммичено.
|
||||||
|
|
||||||
|
## Связанные документы
|
||||||
|
|
||||||
|
- `VERSIONS.md` — обновлённая таблица текущих версий.
|
||||||
|
- `HISTORY/2026-09-30_dev_registry_prune_versions.md` — предыдущая очистка dev-реестра.
|
||||||
|
- `HISTORY/2026-09-30_yaml_pipeline_hardening.md` — состояние пайплайна генерации.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 2026-09-30 — Регистр UUID: нормализация на ОТПРАВКЕ в API (create/modify/redeploy)
|
||||||
|
|
||||||
|
> Разбор: `docs/60_strategy/terraform_case_sensitivity_fix.md` §11 (главный документ по теме),
|
||||||
|
> `NOTES/30_analysis/ARCHITECTURE_NEW.md` §6.5.
|
||||||
|
|
||||||
|
## Что обнаружилось
|
||||||
|
|
||||||
|
Костыль `lower(...)` в конфиге стенда Штурвала — **не «просто проще», а обязателен**.
|
||||||
|
Без него `terraform apply` (create кластера) падает: платформа отвечает
|
||||||
|
«Edge не развёрнут в указанном vDC».
|
||||||
|
|
||||||
|
Обнаружено при запуске Terraform **из-под Windows**, на провайдере **2.0.23**
|
||||||
|
(то есть после всех «фиксов регистра», выпущенных 24.09).
|
||||||
|
|
||||||
|
Костыль живёт в примере (и в gitea `Nail/tf_examples`):
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# tf_examples/fullpipe_chain/shturval.tf:139-140
|
||||||
|
vdc_uid = lower(nubes_vc_vdc.vdc.id)
|
||||||
|
nsxt_uid = lower(nubes_vc_nsxt.edge.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Почему прошлые фиксы не помогли (главная мысль)
|
||||||
|
|
||||||
|
Провайдер `2.0.23` нормализует регистр UUID **только при СРАВНЕНИИ**:
|
||||||
|
план vs state, adopt/suspend/resume, modifier-compare, диагностика
|
||||||
|
(`jsonutil.LowercaseUUIDsInText` → `JSONStringsEquivalent`, `JsonNormalize()`,
|
||||||
|
`ParamsMatchForResume`, `normalizeCompareValue`).
|
||||||
|
|
||||||
|
**Путь ОТПРАВКИ в API остался без нормализации.** Все map-fixed JSON-параметры
|
||||||
|
собираются одной функцией `resources_core.BuildJSON`
|
||||||
|
(`provider/internal/resources_core/helpers.go`), а её вызывает сгенерированный код
|
||||||
|
(`NestedJSONExpr`, шаблон `TOOLS/resource-generator/internal/templates/instance.go`,
|
||||||
|
ветки Create / Modify / Redeploy). `BuildJSON` берёт `ValueString()` подполей **как есть**.
|
||||||
|
|
||||||
|
Ресурс `nubes_vc_nsxt` отдаёт `id` в UPPERCASE (`2C37FED1-…`), платформа хранит
|
||||||
|
UUID в lowercase и **сравнивает регистр при create** → `startupConfiguration.nsxtUid`
|
||||||
|
в верхнем регистре отвергается.
|
||||||
|
|
||||||
|
Почему не спас `resolveRefSvcParamValues` (`core/refsvc.go`,
|
||||||
|
`core/refsvc_resolve.go`): он нормализует только **top-level** refSvc-параметры и
|
||||||
|
`s3.*uid` **внутри** map-fixed. `vdcUid`/`nsxtUid` — обычные строковые подполя
|
||||||
|
JSON, refSvcId у них нет, под шаблон `s3.*uid` они не подпадают.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
| Файл | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| `provider/internal/resources_core/helpers.go` | `BuildJSON` оборачивает результат в `jsonutil.LowercaseUUIDsInText(...)` (+ импорт `core/jsonutil`, комментарий-обоснование) |
|
||||||
|
|
||||||
|
Одна точка → покрыты **все** map-fixed-параметры всех ресурсов на
|
||||||
|
create / modify / redeploy (19 сгенерированных ресурсов, `resources_gen/`).
|
||||||
|
Регенерация не требуется (логика сериализации одна).
|
||||||
|
|
||||||
|
## Оценка риска (почему это безопасно)
|
||||||
|
|
||||||
|
- `BuildJSON` используется **только для отправки** в API, не для построения state.
|
||||||
|
- Regex `uuidAnywhereRegex` = `[0-9a-f]{8}-xxxx-xxxx-xxxx-xxxxxxxxxxxx` — совпадает
|
||||||
|
только с UUID; пароли/имена/произвольные строки не задевает.
|
||||||
|
- Проверено по спекам: внутри map-fixed **нет** строковых секретных полей
|
||||||
|
(password/secret/token) — только `*Uid`-ссылки на ресурсы.
|
||||||
|
- Это **выравнивание** с уже принятым в провайдере правилом «регистр UUID незначим»
|
||||||
|
(то же приведение уже делается на сравнении), а не новое поведение.
|
||||||
|
|
||||||
|
Остаточный риск: если в map-fixed когда-нибудь появится строковое поле, где
|
||||||
|
пользователь хранит **свой** UUID, и регистр там семантически важен (не ссылка на
|
||||||
|
ресурс) — он будет приведён к lowercase. Сейчас таких полей нет.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
- `lower(...)` в HCL становится **не нужен** — убирать в конфигах и в примере
|
||||||
|
(отдельной командой, после релиза провайдера).
|
||||||
|
- **Выпущено 30.09.2026: dev `2.0.24`** — собрано (linux/windows/darwin amd64),
|
||||||
|
подписано GPG и залито в реестр (`nubes-dev/nubes/2.0.24/`), версия видна
|
||||||
|
в `/v1/providers/nubes-dev/nubes/versions`. `VERSIONS.md` обновлён.
|
||||||
|
Доки в реестр (шаг `04_build_and_publish_docs.sh`) **не публиковались**.
|
||||||
|
- В локальных стендах костыля нет: `DEV_STAND/FPipeGmail/shturval.tf:125-126` и
|
||||||
|
`DEV_STAND/FullPipe/shturval.tf1:123` передают `nubes_vc_vdc.vdc.id` /
|
||||||
|
`nubes_vc_nsxt.edge.id` напрямую → на create у них тот же риск.
|
||||||
|
|
||||||
|
## Процедура релиза (dev) — воспроизводимо (проверено 30.09.2026)
|
||||||
|
|
||||||
|
1. Поднять `VERSION` в `TOOLS/config/dev/profile.env` и закоммитить
|
||||||
|
(иначе доки генерируются со старой версией).
|
||||||
|
2. Собрать и залить:
|
||||||
|
```bash
|
||||||
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.24
|
||||||
|
```
|
||||||
|
Скрипт сам выполняет шаги `01` + `02`, собирает 3 платформы
|
||||||
|
(linux/windows/darwin amd64), подписывает GPG и заливает в S3.
|
||||||
|
3. Проверить публикацию:
|
||||||
|
```bash
|
||||||
|
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
|
||||||
|
```
|
||||||
|
4. Обновить `VERSIONS.md` и закоммитить.
|
||||||
|
5. Для локального `go build`/`go test`: `02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev`
|
||||||
|
и `TOOLS/scripts/dev-materialize.sh dev` (эфемерная копия в `provider/`).
|
||||||
|
|
||||||
|
⚠️ Доки в реестр (шаг `04_build_and_publish_docs.sh`) в этом релизе **не публиковались**.
|
||||||
|
|
||||||
|
## Где нормализация нужна (карта, чтобы не потерять)
|
||||||
|
|
||||||
|
Нормализация регистра UUID нужна в **двух независимых местах**:
|
||||||
|
|
||||||
|
1. **Сравнение** (план ↔ state, adopt, suspend/resume, modifier-compare, диагностика):
|
||||||
|
`jsonutil.LowercaseUUIDsInText` → `JSONStringsEquivalent`, `JsonNormalize()`,
|
||||||
|
`ParamsMatchForResume`, `normalizeCompareValue`.
|
||||||
|
2. **Отправка в API** — единственная точка `resources_core.BuildJSON`
|
||||||
|
(`provider/internal/resources_core/helpers.go`), вызывается сгенерированным кодом
|
||||||
|
через `NestedJSONExpr` (`TOOLS/resource-generator/internal/templates/instance.go`:
|
||||||
|
Create ~302, Modify ~488, Redeploy ~505).
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- ⛔ `lower(...)` в HCL — костыль, а не решение (был нужен только из-за ненормализованной отправки).
|
||||||
|
- ⛔ Не нормализовать план целиком (скаляры→строки, сортировка ключей) — вечный diff;
|
||||||
|
менять только регистр UUID-подстрок.
|
||||||
|
- `resolveRefSvcParamValues` (`core/refsvc.go`) покрывает только top-level `refSvcId`
|
||||||
|
и `s3.*uid` внутри map-fixed; `vdcUid`/`nsxtUid` — нет.
|
||||||
|
- Спеки map-fixed без строковых секретов (только `*Uid`) → regex `uuidAnywhereRegex` безопасен.
|
||||||
|
|
||||||
|
## Открытые вопросы (не закрыты)
|
||||||
|
|
||||||
|
1. Проверить на живом стенде: create кластера Штурвала **без** `lower(...)` на сборке
|
||||||
|
с этим фиксом — `apply` запускает только пользователь.
|
||||||
|
2. `core/params.go` → `normalizeUniversalValueV6`: скалярные UUID, попадающие в
|
||||||
|
дефолты create (`instance_create.go`) и в досылку modify (`operation_run.go`),
|
||||||
|
к lowercase не приводятся (вторично, нужен замер).
|
||||||
|
3. `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`): ref-параметр
|
||||||
|
внутри JSON не валидируется при adopt (открыто с 24.09).
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# 2026-09-30 — Ужесточение пайплайна генерации YAML (безопасная замена, чистка легаси)
|
||||||
|
|
||||||
|
> Связанные материалы:
|
||||||
|
> `TOOLS/README.md` (канонический пайплайн, порядок шагов),
|
||||||
|
> `TOOLS/ARCHITECTURE.md` (спецификация),
|
||||||
|
> память репозитория: `pipeline-legacy.md`.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
1. Разобрать, что в генерации YAML устарело.
|
||||||
|
2. Перегенерировать YAML по всем стендам так, чтобы **старое удалялось безопасно**,
|
||||||
|
а новое создавалось атомарно.
|
||||||
|
3. Задокументировать всё, чтобы история изменений прослеживалась.
|
||||||
|
|
||||||
|
Команда пользователя: «делай как ПОЛОЖЕНО, как в best practices».
|
||||||
|
|
||||||
|
## Что было не так (до правок)
|
||||||
|
|
||||||
|
### `TOOLS/scripts/01_generate_yamls.sh`
|
||||||
|
|
||||||
|
| Место (до) | Проблема |
|
||||||
|
|---|---|
|
||||||
|
| стр. 230 `rm -f "$output_glob"` | старый YAML удалялся **до** генерации → при сбое API файл исчезал, новый не создавался (неатомарно per-service) |
|
||||||
|
| стр. 122 «Полной очистки нет» | YAML исключённого сервиса оставался в каталоге и попадал в сборку |
|
||||||
|
| стр. 91 `ls -t "${ROOT_DIR}"/*.token` | легаси-фолбэк токена: в корне токенов нет; поиск «последнего» мог подхватить чужой токен |
|
||||||
|
| стр. 109 `API_ENDPOINT="${NUBES_API_ENDPOINT:-https://lk-api-gateway.ngcloud.ru/...}"` | молчаливый уход в **PROD**, если в профиле нет endpoint |
|
||||||
|
| стр. 167 `svc_name=""` | имя всегда пустое, хотя шапка обещала парсинг из списка → лишний HTTP-запрос на каждый сервис (2 запроса вместо 1) |
|
||||||
|
| стр. 46–50 | дубль `SERVICES_FILE_DEFAULT` (одинаковое присваивание в `if`) |
|
||||||
|
| шапка vs код | «REQUEST_DELAY по умолчанию 0.2», в коде 0.5; путь вывода указан как `provider/resources_yaml` (устарел) |
|
||||||
|
|
||||||
|
### `TOOLS/yaml-generator/internal/config/config.go`
|
||||||
|
|
||||||
|
| Место (до) | Проблема |
|
||||||
|
|---|---|
|
||||||
|
| `Load()` стр. 33 | свой дефолт endpoint = **PROD** gateway |
|
||||||
|
| `loadToken()` + `findLatestToken()` | легаси-фолбэк «последний `*.token` в корне репо» |
|
||||||
|
| `Load()` стр. 60 | путь `filepath.Join(repoRoot, "devops", "config", "services_list.txt")`, причём `FindRepoRoot()` возвращает каталог `provider/` → путь заведомо не существовал |
|
||||||
|
|
||||||
|
### Легаси-скрипты
|
||||||
|
|
||||||
|
`10_yaml_stability_run.sh`, `11_yaml_stability_run_latest.sh`,
|
||||||
|
`12_generate_yamls_latest.sh`, `13_generate_yamls_clean.sh`,
|
||||||
|
`02_generate_resources_and_docs_template_v2.sh` — **мертвы**: зовут `01` без
|
||||||
|
`--profile` (→ `exit 2`), ищут `*.token` в корне репо, а `13` вдобавок делал
|
||||||
|
`rm -f provider/resources_yaml/*.yaml`. Ни один рабочий скрипт их не вызывает
|
||||||
|
(ссылки есть только в исторических `HISTORY/`, `NOTES/`).
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
### 1. `01_generate_yamls.sh` — безопасная запись по принципу staging → атомарная замена
|
||||||
|
|
||||||
|
Новый алгоритм:
|
||||||
|
|
||||||
|
```
|
||||||
|
staging = generated/<stand>/resources_yaml.staging.<pid>
|
||||||
|
↓ генерация всех сервисов пачкой per-service в staging
|
||||||
|
↓ при пустом failures:
|
||||||
|
mv resources_yaml → resources_yaml.bak-<UTC> (бэкап, ротация KEEP_BACKUPS=5)
|
||||||
|
mv staging → resources_yaml (атомарный rename в том же FS)
|
||||||
|
↓ при непустом failures:
|
||||||
|
замена ОТМЕНЯЕТСЯ, рабочий каталог не тронут, staging оставлен для разбора, exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Прочие изменения:
|
||||||
|
|
||||||
|
- каталог помечается маркером `.stand`; генерация в каталог чужого стенда запрещена (`exit 2`);
|
||||||
|
- `embed.go` создаётся теперь в staging (обязательный `go:embed *.yaml`);
|
||||||
|
- `NUBES_API_ENDPOINT` обязателен, иначе `exit 2`;
|
||||||
|
- токен берётся только из `NUBES_API_TOKEN`/`TOKEN_FILE`; легаси-поиск удалён;
|
||||||
|
- имя сервиса — из 2-го поля `services_list.txt`; лишний python-запрос удалён;
|
||||||
|
- удалён дубль `SERVICES_FILE_DEFAULT`; синхронизированы комментарии.
|
||||||
|
|
||||||
|
### 2. `yaml-generator/internal/config/config.go`
|
||||||
|
|
||||||
|
- `NUBES_API_ENDPOINT` обязателен (нет PROD-дефолта);
|
||||||
|
- `loadToken()` больше не ищет `*.token` в корне репо; `findLatestToken` и `getenvDefault` удалены как мёртвые;
|
||||||
|
- при отсутствии `NUBES_SERVICE_ID` требуется явный `NUBES_SERVICES_FILE` (угадывание пути удалено).
|
||||||
|
|
||||||
|
### 3. Легаси-скрипты отключены (fail-fast)
|
||||||
|
|
||||||
|
В начало каждого добавлен guard: сообщение `DEPRECATED` + `exit 2`. Файлы **не удалены**
|
||||||
|
(удаление — отдельное решение владельца), но теперь они не могут сделать ничего вредного.
|
||||||
|
|
||||||
|
### 4. Документация
|
||||||
|
|
||||||
|
- `TOOLS/README.md` — добавлен раздел «Канонический пайплайн (порядок шагов)» и описание безопасной генерации;
|
||||||
|
- `TOOLS/ARCHITECTURE.md` — ссылки `devops/…` заменены на `TOOLS/config/<stand>/…`;
|
||||||
|
- память репозитория — `pipeline-legacy.md` уточнена.
|
||||||
|
|
||||||
|
## Прогон по всем стендам (результат)
|
||||||
|
|
||||||
|
Токены проверены прямыми запросами к API (с браузерным `User-Agent`, иначе DDoS-Guard отдаёт 403):
|
||||||
|
|
||||||
|
| Стенд | Endpoint | HTTP | YAML после генерации | Stale | Failures |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| dev | `lk-api-gateway-dev.ngcloud.ru` | 200 | 40 | нет | нет |
|
||||||
|
| test | `lk-api-gateway-test.ngcloud.ru` | 200 | 36 | нет | нет |
|
||||||
|
| prod | `lk-api-gateway.ngcloud.ru` | 200 | 35 | нет | нет |
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
|
||||||
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/test
|
||||||
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка целостности: `diff -rq` нового каталога dev с бэкапом даёт различия только
|
||||||
|
в случайных `default`-суффиксах, которые API генерирует при каждом запросе
|
||||||
|
(`db-ievgpdvu` → `db-ujama5rb`, `flask-seqtiq3t` → `flask-xwfdxdqh` и т.п.) —
|
||||||
|
структурной регрессии нет. Это же объясняет, почему побайтовое сравнение двух
|
||||||
|
прогонов не может быть использовано как «детектор дрейфа».
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
|
||||||
|
- `12b3932` — `fix(tools): безопасная генерация YAML — staging + атомарная замена, без легаси-фолбэков`
|
||||||
|
- `ad4daab` — `chore(tools): легаси-скрипты генерации отключены (fail-fast DEPRECATED)`
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- `bash -n` для `01_generate_yamls.sh` и всех guard-скриптов — OK;
|
||||||
|
- `go vet ./...` + `go build` для `yaml-generator` — OK;
|
||||||
|
- guard отдаёт `exit 2`;
|
||||||
|
- прогон dev/test/prod — 0 failures, stale отсутствует, staging не остаётся;
|
||||||
|
- бэкапы создаются: `generated/dev/resources_yaml.bak-20260930T063152Z` и т.д.
|
||||||
|
|
||||||
|
## Этап 2 — удаление мёртвого (по команде «удаляй всё старое, аккуратно»)
|
||||||
|
|
||||||
|
**Удалено (`1e796c8`)** — 100% мёртвый код/данные, ничего их не вызывает:
|
||||||
|
|
||||||
|
| Файл | Почему удалён |
|
||||||
|
|---|---|
|
||||||
|
| `TOOLS/scripts/10_yaml_stability_run.sh` | зовёт `01` без `--profile` (exit 2), ищет `*.token` в корне |
|
||||||
|
| `TOOLS/scripts/11_yaml_stability_run_latest.sh` | цепочка на `10`, та же поломка |
|
||||||
|
| `TOOLS/scripts/12_generate_yamls_latest.sh` | зовёт `01` без `--profile`, ищет `*.token` в корне |
|
||||||
|
| `TOOLS/scripts/13_generate_yamls_clean.sh` | цепочка на `12` + делал `rm -f provider/resources_yaml/*.yaml` |
|
||||||
|
| `TOOLS/scripts/02_generate_resources_and_docs_template_v2.sh` | легаси-дубль канонического `02_generate_resources_and_docs_v2.sh` |
|
||||||
|
| `TOOLS/config/services_list.txt` (общий) | код его не читает; как «объединение» устарел: активный `27` (в test/prod — «нет в UI»), нет `87/88/97/153`, которые есть в dev |
|
||||||
|
|
||||||
|
Проверка «ничего не вызывает»: `grep` по всему репо находил ссылки только в
|
||||||
|
исторических `HISTORY/`, `NOTES/`, `docs/` (не исполняются).
|
||||||
|
|
||||||
|
**Правки ссылок (`c822ae2`)**: `README.md`, `HOW_TO/README.md`,
|
||||||
|
`HOW_TO/DEVOPS_BUILD_PIPELINE.md`, `HOW_TO/HOWTO_ADD_NEW_SERVICE.md` (включая
|
||||||
|
переписанный блок «Быстрый старт» с `devops/` на `./TOOLS/scripts/*`),
|
||||||
|
`DOCS_PIPELINE/README.md`, `scripts/publish-doc-page.sh`, `.gitignore`.
|
||||||
|
|
||||||
|
**Проверка после удаления**: `bash -n` для всех `TOOLS/scripts/*.sh` и
|
||||||
|
`scripts/publish-doc-page.sh` — OK; smoke-прогон `01 --profile TOOLS/config/dev` —
|
||||||
|
40 YAML, замена атомарная, бэкап создан.
|
||||||
|
|
||||||
|
**Не удалено (осознанно):**
|
||||||
|
|
||||||
|
- поддержка легаси-прокси `index.cfm` в `01` и `yaml-generator` — это совместимость
|
||||||
|
с работающими пользователями старого API (провайдер v5.0.75, `secrets/stands.md`);
|
||||||
|
- `DOCS_PIPELINE/publish-docs.sh` — сам файл помечен «справочная копия, не подменяет пайплайн»;
|
||||||
|
- `HISTORY/`, `NOTES/`, `docs/` — исторические документы (в них `devops/` и легаси-скрипты
|
||||||
|
упоминаются как история, это нормально);
|
||||||
|
- `scripts/*.py` и `s3_notification_example.sh` — ручные утилиты, вызываются вручную.
|
||||||
|
|
||||||
|
## Этап 3 — сверка списков стендов с облачным каталогом (источник истины)
|
||||||
|
|
||||||
|
Принято: **истина — то, что перечислено в облаке**. Определяется эндпоинтом каталога:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# «перечислено в облаке» (продакшен-готовые сервисы стенда)
|
||||||
|
GET {NUBES_API_ENDPOINT}/services?limit=200&isProductionReady=true
|
||||||
|
# для сравнения: без фильтра отдаются ВСЕ сервисы платформы, включая
|
||||||
|
# DEPRECATED и не заявленные в каталоге (48 у prod, 49 у test, 60 у dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
Требуется браузерный `User-Agent` (иначе DDoS-Guard отдаёт 403) и `Referer`.
|
||||||
|
|
||||||
|
Результат на 2026-09-30:
|
||||||
|
|
||||||
|
| Стенд | Облако (`isProductionReady=true`) | Активных в `services_list.txt` | Лишние в файле | Не хватало |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| dev | 40 | 40 | нет | нет |
|
||||||
|
| test | 36 | 36 | нет | нет |
|
||||||
|
| prod | 36 | 35 → **36** | нет | **`151 k8sOpenbao` (Vault)** |
|
||||||
|
|
||||||
|
У остальных 12 закомментированных prod-сервисов, присутствующих в API, `isProductionReady=false` —
|
||||||
|
они закомментированы обоснованно. Четыре id в файле отсутствуют в каталоге prod вовсе
|
||||||
|
(`32 vmpostgre`, `87 k8svalkey`, `153 nifi`, `175 k8sGo`).
|
||||||
|
|
||||||
|
Исправлено коммитом `a68a36a`: `151 k8sOpenbao` раскомментирован (комментарий «нет в PROD UI»
|
||||||
|
устарел), prod перегенерирован — 36 YAML, ровно как в облаке.
|
||||||
|
|
||||||
|
## Этап 4 — перепроверка после полной перегенерации + подводные камни
|
||||||
|
|
||||||
|
Команда: «сгенери YAML для всех стендов, проследи чтобы старого ничего не осталось,
|
||||||
|
перепроверь после генерации всё». Выполнено три прогона `01`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in dev test prod; do ./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/$s; done
|
||||||
|
# все три: exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Результат перепроверки (2026-09-30)
|
||||||
|
|
||||||
|
| Стенд | YAML | = активных в списке | = облако (`isProductionReady=true`) | Stale | Дубли id | Failures |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| dev | 40 | ✅ 40 | ✅ 40 | нет | нет | пусто |
|
||||||
|
| test | 36 | ✅ 36 | ✅ 36 | нет | нет | пусто |
|
||||||
|
| prod | 36 | ✅ 36 | ✅ 36 | нет | нет | пусто |
|
||||||
|
|
||||||
|
Дополнительно проверено:
|
||||||
|
|
||||||
|
- staging-каталоги (`resources_yaml.staging.*`) — не осталось ни одного;
|
||||||
|
- в `resources_yaml/` только `*.yaml`, `.stand`, `embed.go` — посторонних файлов нет;
|
||||||
|
- `.stand` в каждом каталоге совпадает с профилем (`dev`/`test`/`prod`);
|
||||||
|
- бэкапы прошлых версий: dev 3, test 2, prod 2 (ротация `KEEP_BACKUPS=5`);
|
||||||
|
- `generated/<стенд>/tmp/yaml_gen_failures.txt` — пусты;
|
||||||
|
- `git status` — чисто.
|
||||||
|
|
||||||
|
### ⛔ Подводные камни, найденные при перепроверке (важно на будущее)
|
||||||
|
|
||||||
|
1. **API отдаёт случайные `default`.** Часть параметров приходит со случайным
|
||||||
|
суффиксом (`db-ievgpdvu` → `db-ujama5rb`, `kvname-grzjes7l` → `kvname-g3s0uof2`,
|
||||||
|
`flask-seqtiq3t` → `flask-xwfdxdqh`). Поэтому **побайтовое сравнение двух прогонов
|
||||||
|
не является детектором дрейфа** — различия в этих строках не регрессия.
|
||||||
|
2. **Случайный `default` вшивается в сгенерированный Go-код.**
|
||||||
|
Пример: `generated/dev/go/151_k8s_openbao_kv_resource.go` содержит
|
||||||
|
`Default: stringdefault.StaticString("kvname-XXXX")`. Следствие:
|
||||||
|
`check_generated_drift.sh dev` показывает **дрейф 15 файлов сразу после любой**
|
||||||
|
перегенерации YAML — это не ошибка оператора.
|
||||||
|
3. **Производные артефакты стареют молча.** `generated/<стенд>/go` и `generated/<стенд>/docs`
|
||||||
|
создаются шагом `02` и после нового `01` становятся старше своих источников
|
||||||
|
(на момент проверки: `go`/`docs` dev — 08:46, YAML dev — 10:18). Отдельно живёт
|
||||||
|
эфемерная копия `provider/internal/resources_gen` + `provider/resources_yaml`
|
||||||
|
(её кладёт `dev-materialize.sh`, маркер `.stand` = стенд). Их нужно обновлять
|
||||||
|
шагом `02` после каждого `01`.
|
||||||
|
4. **Прямые HTTP-запросы к API без браузерного `User-Agent` получают 403**
|
||||||
|
(DDoS-Guard). С `User-Agent` + `Referer` — 200.
|
||||||
|
|
||||||
|
### Актуальная карта пайплайна на 2026-09-30
|
||||||
|
|
||||||
|
- Единственный путь генерации YAML: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
|
||||||
|
(`--profile` обязателен, без него `exit 2`).
|
||||||
|
- Один универсальный движок на все стенды: `TOOLS/bin/yaml-generator`, стенд задаётся
|
||||||
|
переменными окружения (`NUBES_API_ENDPOINT`, `NUBES_API_TOKEN`, `NUBES_SERVICE_ID`,
|
||||||
|
`NUBES_SERVICE_NAME`, `NUBES_OUTPUT_DIR`); список сервисов — свой у каждого стенда.
|
||||||
|
- Стенд-специфичных хардкодов в коде нет — контролируется `check_hardcoded_service_ids.sh`.
|
||||||
|
- Токены: `secrets/{dev,test,prod}.token` (валидны на 2026-09-30, срок до 2026-12-27);
|
||||||
|
обновление — `TOOLS/scripts/00_token_manager.sh` (keycloak refresh, `THRESHOLD_MIN=10`).
|
||||||
|
- Удалены как мёртвые (`1e796c8`): `10/11/12/13_yaml_*.sh`,
|
||||||
|
`02_generate_resources_and_docs_template_v2.sh`, общий `TOOLS/config/services_list.txt`.
|
||||||
|
- Оставлены осознанно: поддержка легаси-прокси `index.cfm`, справочная копия
|
||||||
|
`DOCS_PIPELINE/publish-docs.sh`, исторические `HISTORY/`/`NOTES/`/`docs/`,
|
||||||
|
ручные утилиты `scripts/*.py`.
|
||||||
|
|
||||||
|
## Открытые вопросы (на решение владельца)
|
||||||
|
|
||||||
|
1. Поддержка легаси-прокси `index.cfm`: оставляем или выпиливаем (README уже помечает
|
||||||
|
закрытые API как «не использовать»)?
|
||||||
|
2. Прочие `.gitignore`-паттерны мёртвых каталогов (`universal_rebuild/*`, `provider/generated/`)
|
||||||
|
— чистить?
|
||||||
|
|
||||||
@@ -0,0 +1,933 @@
|
|||||||
|
# Диалог: Opus 4.8 — анализ архитектуры провайдера и ресурсов-модификаторов (2026-09-30)
|
||||||
|
|
||||||
|
> **Что это.** Полная, без сокращений, запись диалога: постановка задачи пользователем,
|
||||||
|
> ход работы агента (GitHub Copilot) до создания файла-промпта, содержимое промпта,
|
||||||
|
> ответ Opus 4.8 (приведён дословно в том виде, как получен) и открытый вопрос Opus.
|
||||||
|
> **Формат.** Хронология ходов. Реплики пользователя и ответ Opus — дословно, включая опечатки.
|
||||||
|
> **Статус.** Диалог НЕ завершён: Opus задал вопрос, ответ ему ещё не передан.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 1 — Задание пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
составь промпт в файл для Опус 4.8
|
||||||
|
пусть проанализирует архитектуру провайдера ... с дополнительными ресурсами-модификаторами
|
||||||
|
ограничь зону его доступносьти - пусть НИЧЕГО не читает из хистори, только файл с архитектурой
|
||||||
|
и точно выдай ему список файлов которые надо изучить, более пусть никуда не лезет
|
||||||
|
если что непончятно - пусть напишет тебе вопрос, я тебе передам
|
||||||
|
взаимодейтвуйте в редиме диалога, НО НЕ НАДО злоупотреблять !
|
||||||
|
ответы от него - МАКСИМАЛЬНО СЖАТО, без лирики и воды, лишь бы ты сам как агент понял
|
||||||
|
```
|
||||||
|
|
||||||
|
Контекст задания: активный файл — `DEV_STAND/FullPipe/shturval.tf1`; активное выделение — строка 117
|
||||||
|
(`suspend_on_destroy`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 2 — Работа агента до создания файла (разведка, только чтение)
|
||||||
|
|
||||||
|
Поиск архитектурных документов и существующих промптов для Opus:
|
||||||
|
|
||||||
|
- `NOTES/20_prompts/` — 20 файлов промптов; среди них `prompt_for_opus_modifier_architecture_full.md`,
|
||||||
|
`prompt_for_opus_modifiers_review.md`, `prompt_for_opus_review_modify_resources_2026-09-24.md`,
|
||||||
|
`prompt_for_opus_modifier_global_architecture.md`. Часть помечена баннером ⛔ «ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО».
|
||||||
|
- `HISTORY/OPUS/` — 16 файлов диалогов с Opus (напр. `2026-09-22_modifier_architecture_project.md`).
|
||||||
|
- Кандидаты на «файл архитектуры»: `TOOLS/ARCHITECTURE.md`, `NOTES/30_analysis/ARCHITECTURE_NEW.md`,
|
||||||
|
`docs/60_strategy/provider_philosophy.md`, `docs/60_strategy/modifier_resources_ideology_and_specification.md`.
|
||||||
|
|
||||||
|
Прочитано:
|
||||||
|
|
||||||
|
- `TOOLS/ARCHITECTURE.md` (246 строк) — в первых строках помечен «PRIMARY SOURCE OF TRUTH»,
|
||||||
|
«THIS FILE IS THE FOUNDATION».
|
||||||
|
- `NOTES/30_analysis/ARCHITECTURE_NEW.md` — «Universal Rebuild — Архитектура и рабочая цепочка»
|
||||||
|
(внутри — ссылки на пути `/home/naeel/terra/universal_rebuild`, т.е. иная раскладка репозитория).
|
||||||
|
- `NOTES/20_prompts/prompt_for_opus_modifier_architecture_full.md`, `…_modifiers_review.md`,
|
||||||
|
`…_review_modify_resources_2026-09-24.md`.
|
||||||
|
- `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` — карта файлов, разделение «актуально / LEGACY».
|
||||||
|
|
||||||
|
Проверка актуальности через git:
|
||||||
|
|
||||||
|
```
|
||||||
|
git log -3 -- TOOLS/ARCHITECTURE.md
|
||||||
|
cbd559d 2026-09-30 docs(tools): канонический пайплайн + история ужесточения генерации YAML
|
||||||
|
78f9dfb 2026-09-22 refactor(build): эфемерные generated-копии …
|
||||||
|
c14f7de 2026-09-22 docs: раздел «Реестр исключений» + диалог код-ревью opus/astra
|
||||||
|
```
|
||||||
|
|
||||||
|
Установленная фактическая реализация модификаторов (на 2026-09-30):
|
||||||
|
|
||||||
|
- База: YAML на сервис — «чистая выгрузка» из API, без доменных меток. В `TOOLS/yaml-generator/main.go`
|
||||||
|
(комментарий перед `CollectOperations`) прямо сказано: реестр `serviceSpecificModifiers`, ранее
|
||||||
|
помечавший `modify` как `kind=modifier`, удалён при рефакторинге 2026-09-23; модификаторы теперь —
|
||||||
|
«отдельный оверлей (см. `modifiers.yaml` + второй проход)», базовый YAML о них не знает.
|
||||||
|
- Реальные модификаторы — РУЧНЫЕ Go-ресурсы:
|
||||||
|
- `provider/internal/resources_core/org_ip_allocation_resource.go` —
|
||||||
|
`nubes_vc_org_ip_allocation` (сервис 19, `modify`, `vIPConfigure` id 662);
|
||||||
|
- `provider/internal/resources_core/nsxt_snat_resource.go` —
|
||||||
|
`nubes_vc_nsxt_snat` (сервис 22, `modify`, `ipSpaceName` id 372; канон-значение `no-needed`).
|
||||||
|
- Регистрация: `provider/internal/provider/provider.go`, функция `Resources()` (строки 182–190) —
|
||||||
|
`append(resources, resources_core.NewOrgIpAllocationResource, resources_core.NewNsxtSnatResource)`.
|
||||||
|
- Вызываемые методы ядра: `RunInstanceOperationUniversalByCode` (`provider/internal/core/operation_run_bycode.go`),
|
||||||
|
`GetInstanceStateParams` (`provider/internal/core/instance_params.go`),
|
||||||
|
`LockInstance`/`UniversalClient` (`provider/internal/core/client.go`),
|
||||||
|
`ResolveRefSvcParamValue` (`provider/internal/core/refsvc_resolve.go`),
|
||||||
|
`ShouldRemoveFromState` (`provider/internal/resources_core/resource_diagnostics.go`).
|
||||||
|
- Спеки операций/параметров: `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml`.
|
||||||
|
- Пример применения цепочки: `DEV_STAND/FullPipe/modifiers.tf`.
|
||||||
|
|
||||||
|
Агент задал пользователю 3 уточняющих вопроса (какой файл считать «архитектурой»; что именно должен
|
||||||
|
выдать Opus; имя файла промпта) — **пользователь вызов отклонил (cancelled)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 3 — Ответ пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
думай сам ! возможно несколько файлов ему дать
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 4 — Решение агента и создание файла
|
||||||
|
|
||||||
|
Агентом принято самостоятельно (без дополнительных вопросов):
|
||||||
|
|
||||||
|
- «Файл с архитектурой» — единственный: `TOOLS/ARCHITECTURE.md`.
|
||||||
|
- Формат выхода Opus — архитектурный разбор: устройство по слоям, спека ↔ код, жизненный цикл
|
||||||
|
модификаторов, риски с указанием `файл:строка`.
|
||||||
|
- Путь промпта — `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md`
|
||||||
|
(соответствует конвенции папки `NOTES/20_prompts/`).
|
||||||
|
|
||||||
|
Проверка существования всех 22 путей из списка (скрипт `test -f` по каждому) — результат:
|
||||||
|
`ALL FILES PRESENT`. Отсутствующих путей нет.
|
||||||
|
|
||||||
|
Коммит: `752244f` —
|
||||||
|
`docs(prompt): промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов`.
|
||||||
|
|
||||||
|
### Содержимое созданного файла `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md` (дословно)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
|
||||||
|
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роль и режим работы
|
||||||
|
|
||||||
|
- Ты — архитектор/ревьюер универсального Terraform-провайдера.
|
||||||
|
- Работаем **в диалоге**: я (агент) передаю твои вопросы пользователю и возвращаю его ответы.
|
||||||
|
- Вопрос задавай ТОЛЬКО если без него ответить нельзя. Максимум 1–2 вопроса за раз, предельно коротко.
|
||||||
|
- Не догадываться. Нет данных — вопрос, а не допущение.
|
||||||
|
- Область не расширять: отвечать ровно на поставленную задачу.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Проанализировать архитектуру универсального провайдера Nubes и встроенный в неё слой
|
||||||
|
**ресурсов-модификаторов** — отдельных ресурсов, которые вызывают операцию `modify`
|
||||||
|
у родительского инстанса (когда нужного параметра нет в операции `create`).
|
||||||
|
|
||||||
|
Оценить:
|
||||||
|
|
||||||
|
1. Как устроена архитектура по слоям и как течёт поток данных (API → YAML → код → API).
|
||||||
|
2. Соответствие заявленной спеки (`TOOLS/ARCHITECTURE.md`) фактической реализации — все
|
||||||
|
расхождения, с указанием `файл:строка`.
|
||||||
|
3. Корректность жизненного цикла модификаторов: `Create` / `Read` / `Update` / `Delete`,
|
||||||
|
идемпотентность, дрейф (drift), поведение при `replace` / повторном `apply`, импорт.
|
||||||
|
4. Место модификаторов в универсальном ядре: где и как нарушается принцип
|
||||||
|
«ядро универсально, доменные знания — только данные». Насколько оправдано текущее
|
||||||
|
решение (ручные Go-ресурсы, зарегистрированные поверх генерируемых).
|
||||||
|
5. Границы ответственности: что модификатор делает сам, что отдаёт платформе; как
|
||||||
|
выражается обратная операция (откат при `destroy`, значение «выключено»).
|
||||||
|
6. Риски и топ-проблемы — по убыванию критичности, каждое с `файл:строка`.
|
||||||
|
|
||||||
|
## Границы доступа (ЖЁСТКО)
|
||||||
|
|
||||||
|
Читать РАЗРЕШЕНО **только** файлы из списка ниже. Всё остальное — ЗАПРЕЩЕНО, в частности:
|
||||||
|
|
||||||
|
- `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, `site/**`, `site_test/**`,
|
||||||
|
`apps/**`, `charts/**`, `FIYR_MGU/**`, `gateway/**`, `scripts/**`, `secrets/**`,
|
||||||
|
`tfflaskcrud/**`, `tfluceecrud/**`, `tfnodejscrud/**`, `DEV_STAND/**` (кроме одного файла
|
||||||
|
из списка), `TEST_STAND/**`, `PROD_STAND/**`, `provider/artifacts/**`, `provider/bin/**`;
|
||||||
|
- история git (`git log`, `git show`, `git diff` с коммитами), коммиты, теги, ветки;
|
||||||
|
- любой файл репозитория, которого нет в списке ниже.
|
||||||
|
|
||||||
|
Нужен файл вне списка → НЕ читать, а задать мне вопрос.
|
||||||
|
|
||||||
|
## Файлы к изучению (исчерпывающий список)
|
||||||
|
|
||||||
|
### Группа 1. Архитектура (спека)
|
||||||
|
- `TOOLS/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
### Группа 2. Ресурсы-модификаторы и их регистрация
|
||||||
|
- `provider/internal/provider/provider.go`
|
||||||
|
- `provider/internal/resources_core/org_ip_allocation_resource.go`
|
||||||
|
- `provider/internal/resources_core/nsxt_snat_resource.go`
|
||||||
|
- `provider/internal/resources_core/org_ip_allocation_test.go`
|
||||||
|
|
||||||
|
### Группа 3. Рантайм-зависимости модификаторов (ядро)
|
||||||
|
- `provider/internal/core/client.go`
|
||||||
|
- `provider/internal/core/operation_run_bycode.go`
|
||||||
|
- `provider/internal/core/instance_params.go`
|
||||||
|
- `provider/internal/core/refsvc_resolve.go`
|
||||||
|
- `provider/internal/resources_core/resource_diagnostics.go`
|
||||||
|
- `provider/internal/resources_core/crud.go`
|
||||||
|
|
||||||
|
### Группа 4. Генератор (как рождается «универсальная» часть)
|
||||||
|
- `TOOLS/yaml-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/loader/loader.go`
|
||||||
|
|
||||||
|
### Группа 5. Факты API (спеки операций/параметров)
|
||||||
|
- `generated/dev/resources_yaml/19_vc_org.yaml`
|
||||||
|
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`
|
||||||
|
|
||||||
|
### Группа 6. Применение модификаторов (композиция цепочки)
|
||||||
|
- `DEV_STAND/FullPipe/modifiers.tf`
|
||||||
|
|
||||||
|
### Группа 7. Только если без них нельзя ответить (иначе не открывать)
|
||||||
|
- `provider/internal/resources_core/state_refresh.go`
|
||||||
|
- `provider/internal/resources_core/params_compare.go`
|
||||||
|
- `provider/internal/resources_core/helpers.go`
|
||||||
|
- `TOOLS/resource-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/instance.go`
|
||||||
|
|
||||||
|
## Что нужно на выходе
|
||||||
|
|
||||||
|
Структурированный отчёт, разделы строго в этом порядке:
|
||||||
|
|
||||||
|
1. **Устройство архитектуры** — слои и поток данных, 5–10 строк.
|
||||||
|
2. **Спека ↔ код** — список расхождений `ARCHITECTURE.md` с реализацией (`файл:строка`).
|
||||||
|
3. **Дефекты и риски модификаторов** — по убыванию критичности. По каждому:
|
||||||
|
суть → место (`файл:строка`) → последствие → предлагаемое направление (одна строка).
|
||||||
|
4. **Открытые вопросы** — списком, если есть.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
- Максимально сжато: тезисы, без вступлений, воды и «лирики».
|
||||||
|
- Каждое утверждение проверяемо: ссылка `файл:строка`.
|
||||||
|
- Код — только короткие фрагменты, и лишь если без них тезис не понятен.
|
||||||
|
- Никаких «а ещё могу», никаких предложений расширить работу.
|
||||||
|
|
||||||
|
## Правило «стоп»
|
||||||
|
|
||||||
|
Если задание неоднозначно или данных не хватает — **остановиться и задать один короткий
|
||||||
|
вопрос**. Не достраивать смысл и не действовать по догадке.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Итоговое сообщение агента пользователю (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
Готово.
|
||||||
|
|
||||||
|
`NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers.md` (коммит `752244f`).
|
||||||
|
|
||||||
|
Что внутри:
|
||||||
|
- **Задача** — анализ архитектуры провайдера + слоя модификаторов (`Create/Read/Update/Delete`, идемпотентность, drift, спека↔код, место в универсальном ядре, границы/откат, топ-рисков с `файл:строка`).
|
||||||
|
- **Границы (жёстко)** — запрет на `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, стенды, git-историю; нужен файл вне списка → вопрос мне, не чтение.
|
||||||
|
- **Список файлов** — 22 шт., исчерпывающий, 7 групп: спека (`TOOLS/ARCHITECTURE.md`) → модификаторы + регистрация → рантайм-ядро → генератор → YAML-спеки 19/22 → пример (`DEV_STAND/FullPipe/modifiers.tf`) → «только при необходимости».
|
||||||
|
- **Формат ответа** — сжато, тезисы, каждое утверждение с `файл:строка`, без «лирики».
|
||||||
|
- **Режим** — диалог, но без злоупотребления; при неясности один короткий вопрос и стоп.
|
||||||
|
|
||||||
|
Все 22 пути проверены — существуют.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 5 — Ответ Opus 4.8 (дословно, как получен от пользователя)
|
||||||
|
|
||||||
|
Ниже — полный сырой лог сессии Opus (включая служебные строки о чтениях и неудачных поисках),
|
||||||
|
затем его отчёт.
|
||||||
|
|
||||||
|
```
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю файлы строго по разрешённому списку. Начну с групп 1–3.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю группу 2 (модификаторы) и группу 3 (ядро).
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю группу 3 (ядро) и тест.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю `client.go` (ядро) и группу 4 (генератор).
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю группу 4 (генератор), группу 6 (modifiers.tf) и начну группу 5.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю группу 5 (API YAML). Сначала проверю размер.
|
||||||
|
|
||||||
|
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
|
||||||
|
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
|
||||||
|
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
|
||||||
|
|
||||||
|
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
|
||||||
|
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
|
||||||
|
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
|
||||||
|
|
||||||
|
Searched for files matching `**/generated/dev/resources_yaml/*vc_org*.yaml`, no matches
|
||||||
|
|
||||||
|
Searched for files matching `**/generated/dev/resources_yaml/*nsxt*.yaml`, no matches
|
||||||
|
|
||||||
|
Searched for files matching `**/generated/dev/**/19_*.yaml`, no matches
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Searched for files matching `**/generated/dev/resources_yaml/*.yaml`, no matches
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Файлы существуют; grep не сработал по паттерну. Прочитаю их напрямую.
|
||||||
|
|
||||||
|
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
|
||||||
|
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
|
||||||
|
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
|
||||||
|
|
||||||
|
No matches found. Your search pattern might be excluded completely by either the search.exclude settings or .*ignore files.
|
||||||
|
If you believe that it should have results, you can check into the .*ignore files and the exclude setting (here are some excluded patterns for reference:[**/node_modules,**/bower_components,**/*.code-search]).
|
||||||
|
Then if you want to include those files you can call the tool again by setting "includeIgnoredFiles" to true.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Проанализировал все файлы из разрешённого списка (группы 1–6; группа 7 не понадобилась). Отчёт ниже.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отчёт Opus (дословно)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 1. Устройство архитектуры
|
||||||
|
|
||||||
|
Поток данных: **API → YAML → Go-код → API**.
|
||||||
|
- Генератор YAML (`main.go`) делает **чистую выгрузку** операций сервиса из API (`create/delete/modify/suspend/...`) без доменных знаний — один файл на сервис (`19_vc_org.yaml`, `22_vc_nsxt.yaml`).
|
||||||
|
- Генератор кода (`loader.go`) читает YAML, классифицирует операции на `instance/subresource/action/modifier`, строит `GenResource/...` и рендерит универсальные ресурсы.
|
||||||
|
- Ядро (`core`) — универсальный HTTP-клиент: запуск операций, ожидание, резолв ref-параметров, досылка дефолтов.
|
||||||
|
- **Модификаторы** — это НЕ генерируемый слой: два ручных Go-ресурса (`org_ip_allocation_resource.go`, `nsxt_snat_resource.go`), зарегистрированных вручную в `provider.go:199-204`. Каждый дёргает `modify` родителя через `RunInstanceOperationUniversalByCode`.
|
||||||
|
|
||||||
|
## 2. Спека ↔ код (расхождения)
|
||||||
|
|
||||||
|
**S1. Реестр `serviceSpecificModifiers` в спеке указан не там, где он есть (его нет).**
|
||||||
|
`ARCHITECTURE.md` (таблица «Exception Registry») утверждает, что `serviceSpecificModifiers` живёт в `main.go`. Фактически этот реестр **удалён** (рефакторинг 2026-09-23) — прямо задокументировано в `main.go:70-90`. Названного реестра нет; модификаторы стали ручными ресурсами. Таблица-спека устарела.
|
||||||
|
|
||||||
|
**S2. «Service-specific Go code is fully generated… No manual edits» / «Core is universal» — нарушено.**
|
||||||
|
`ARCHITECTURE.md` (Core Principles 2, 3; «Provider Model»). Но `org_ip_allocation_resource.go` и `nsxt_snat_resource.go` — ручной сервис-специфичный Go с зашитыми `service_id`, именами параметров и канон-значениями, лежащий в слое `resources_core`. Это доменная логика внутри «ядра».
|
||||||
|
|
||||||
|
**S3. Заявленные «ровно ДВА реестра исключений» — по факту есть третье место.**
|
||||||
|
`ARCHITECTURE.md` («The ONLY allowed deviations… MUST live in exactly two named registries»). Третье место доменных данных/логики — `provider.go:199-204` + два файла модификаторов. Оно не покрыто правилом реестра и не отражается в его диффе.
|
||||||
|
|
||||||
|
**S4. «modify всегда через WithDefaults» — модификаторы идут через ByCode.**
|
||||||
|
`ARCHITECTURE.md` («modify всегда через WithDefaults / `RunInstanceOperationUniversalWithDefaults`»). Модификаторы намеренно используют `RunInstanceOperationUniversalByCode` (`operation_run_bycode.go:10-13`) с причиной в комментарии `nsxt_snat_resource.go:240-250`. Досылка дефолтов там своя (live → paramValue формы → default, `operation_run_bycode.go:105-150`). Буквальное утверждение спеки не выполняется.
|
||||||
|
|
||||||
|
**S5. Полноценный генерируемый слой модификаторов существует, но не задействован.**
|
||||||
|
`loader.go:55-90` полностью поддерживает `kind: modifier` с `delete_strategy` (`noop_warn/inverse/error`), `idempotency` (`none/check_before_run`), `delete_params`, валидацией (validateModifierOperation). Ядро под это имеет `RunInstanceOperationUniversalByIdempotent` (`operation_run_bycode.go:15-19`) и `RunOperationByCodeIdempotent` (`crud.go`). Но оба реальных модификатора — ручные и это всё **не используют**, переизобретая delete-стратегию вручную (`keep_on_destroy` + inverse). Базовые YAML (19, 22) `kind: modifier` не содержат — оверлей `modifiers.yaml`, упомянутый в `main.go:84-90`, в разрешённом списке отсутствует и в базовых спеках не проявлен.
|
||||||
|
|
||||||
|
## 3. Дефекты и риски модификаторов (по убыванию критичности)
|
||||||
|
|
||||||
|
**R1. Двойное владение одним и тем же параметром API.**
|
||||||
|
Суть: `vIPConfigure` (id 662) есть в `modify` генерируемого `nubes_vc_org` (`19_vc_org.yaml`, op modify), а `ipSpaceName` (id 372) — в `modify` генерируемого `nubes_vc_nsxt` (`22_vc_nsxt.yaml`). Те же поля пишет и модификатор.
|
||||||
|
Место: `org_ip_allocation_resource.go:316-340` / `nsxt_snat_resource.go:240-253`.
|
||||||
|
Последствие: если пользователь заводит и инстанс-ресурс, и модификатор — «война дрейфов»: два ресурса по очереди перезаписывают поле каждым apply.
|
||||||
|
Направление: явно исключать пересекающиеся коды из схемы генерируемого ресурса, если поле отдано модификатору (или запретить одновременное использование).
|
||||||
|
|
||||||
|
**R2. Зашитые сервис-специфичные данные обходят страж `check_hardcoded_service_ids.sh`.**
|
||||||
|
Суть: id 19/22, имена `vIPConfigure`/`ipSpaceName`, значение `"no-needed"` зашиты как литералы-аргументы, а не как `svc.ID == N`.
|
||||||
|
Место: `org_ip_allocation_resource.go:296-314` (`ResolveRefSvcParamValue(ctx, 19, …)`), `nsxt_snat_resource.go:43`.
|
||||||
|
Последствие: правило «никаких hardcoded service id вне реестров» формально соблюдено, фактически — нет; страж это не ловит.
|
||||||
|
Направление: вынести id/коды/каноны в один явный реестр-данные, покрытый чекером, либо расширить паттерн чекера.
|
||||||
|
|
||||||
|
**R3. Порядок «edge → аллокация» не гарантируется провайдером.**
|
||||||
|
Суть: платформа требует существующий vDC+Edge до `modify` орги, иначе «Can't cast Complex Object Type Struct to String».
|
||||||
|
Место: описано в `modifiers.tf:6-16`; в коде порядок не выражен — держится только на пользовательском `depends_on`.
|
||||||
|
Последствие: забытый `depends_on` → непонятная ошибка платформы на apply.
|
||||||
|
Направление: либо документировать как жёсткое требование в схеме/описании ресурса, либо проверять готовность edge в `Create` до modify.
|
||||||
|
|
||||||
|
**R4. Нельзя снять аллокацию через атрибут — только `destroy`.**
|
||||||
|
Суть: пустой массив запрещён (`org_ip_allocation_resource.go:328-331` `len==0 → error`), а Delete шлёт `count=0`, но `[]` не отправляется (`org_ip_allocation_resource.go:246-266`).
|
||||||
|
Последствие: «выключено» выражается двумя разными способами (count=0 при destroy vs невозможность `[]` при update) — асимметрия семантики.
|
||||||
|
Направление: определить единый канон «ноль аллокаций» и разрешить его через атрибут, либо явно задокументировать ограничение как намеренное.
|
||||||
|
|
||||||
|
**R5. Модификаторы не идемпотентны на уровне API (modify выполняется всегда).**
|
||||||
|
Суть: `ByCode` без pre-check — `Create`/`Update` всегда POST-ят modify, даже если live уже совпадает. Причина осознанная (комментарий applyAllocation).
|
||||||
|
Последствие: лишние операции modify при повторных apply/после ручных правок state; на больших инстансах — время и нагрузка. Корректности не нарушает (дрейф гасится в `Read`).
|
||||||
|
Направление: если появится надёжное сравнение desired↔live (не с paramValue формы) — переключить на idempotent-путь, уже имеющийся в ядре.
|
||||||
|
|
||||||
|
**R6. Импорт оставляет Required-атрибуты неинициализированными.**
|
||||||
|
Суть: `ImportState` ставит только `id` и `organization`/`nsxt_uid` (`org_ip_allocation_resource.go:288-292`, `nsxt_snat_resource.go:222-226`). `vip_configure`/`ip_space_name` (Required) наполняются лишь в `Read`, и то по условию (nsxt — только если live непустой `nsxt_snat_resource.go:160-166`).
|
||||||
|
Последствие: при импорте эджа с невыставленным SNAT `ip_space_name` останется null → требуется совпадение с конфигом на следующем плане; для пользователя неочевидно.
|
||||||
|
Направление: в `ImportState`/`Read` заполнять атрибут каноном (`no-needed`) при отсутствии значения.
|
||||||
|
|
||||||
|
## 4. Открытые вопросы
|
||||||
|
|
||||||
|
1. Оверлей `modifiers.yaml` и генерируемый слой `kind: modifier` (см. R5/S5) — в разрешённый список не входил. Нужно ли оценивать его как «мёртвый/параллельный» код к удалению, или он планируется как целевая замена ручных модификаторов? (файл вне списка — не читал).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 6 — Указание пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
сначала документируй ВЕСЬ диалог, без сокращений
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытый вопрос Opus (ожидает ответа пользователя)
|
||||||
|
|
||||||
|
1. Оверлей `modifiers.yaml` и генерируемый слой `kind: modifier` — «мёртвый/параллельный» код
|
||||||
|
к удалению, или планируемая целевая замена ручных модификаторов?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 7 — Запрос пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
твоё критическое мнение ?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Критическое мнение агента о отчёте Opus (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
**Сильное**
|
||||||
|
- **S1** — верно. `ARCHITECTURE.md:225` ссылается на реестр `serviceSpecificModifiers`, а в `TOOLS/yaml-generator/main.go` (~стр. 77–90) написано, что он удалён при рефакторинге. Спека устарела — цена находки низкая, но факт точный.
|
||||||
|
- **S5** — верно и важно. `loader.go` умеет `kind: modifier` (+ `delete_strategy`, `idempotency`) и в ядре есть idempotent-путь, но реальные модификаторы ручные. Это не «мёртвый код», а **незавершённая миграция**.
|
||||||
|
- **R1** — самое ценное. `nubes_vc_nsxt` в `Update` шлёт `ip_space_name` (372), и тот же параметр пишет `nubes_vc_nsxt_snat` → два ресурса перезаписывают одно поле. Аналогично `nubes_vc_org` / `v_ip_configure`.
|
||||||
|
- R4, R6 — по делу, но второго порядка.
|
||||||
|
|
||||||
|
**Слабое / спорное**
|
||||||
|
- **S2, S4 — натянуто.** Правила спеки («no manual edits», «core is universal») писаны про **сгенерированный** код; ручной оверлей в них вообще не описан. Это пробел спеки, а не нарушение «по букве». S4 ещё и относится к `Update` инстанс-ресурсов, к модификатору прямого отношения не имеет.
|
||||||
|
- **R2 — нарушение собственного правила.** Opus утверждает, что `check_hardcoded_service_ids.sh` «это не ловит», **не читая сам скрипт** (он вне списка). Надо было оформить вопросом, а не утверждением.
|
||||||
|
- **R5 — завышен по приоритету**: сам же пишет «корректности не нарушает».
|
||||||
|
- **Номера строк не сверены.** У меня из чтения `provider.go` функция `Resources` начинается со строки 182, и регистрация модификаторов идёт сразу за ней, а не 199–204. Одно из двух неверно → «проверяемость» отчёта под вопросом (а она была главным требованием).
|
||||||
|
|
||||||
|
**Упущено**
|
||||||
|
- Файла `modifiers.yaml` в репозитории **нет вообще** — только упоминания-комментарии. Значит это не «параллельный слой», а **задокументированное, но не созданное** наложение; его вопрос №1 надо переформулировать.
|
||||||
|
- Не названа корневая причина: генератор умеет модификаторы, но доменное знание (какая операция, имя, inverse) некуда положить — оверлей не сделан, поэтому написали руками.
|
||||||
|
- Не отмечена рассинхронизация семантики: у генерируемых ресурсов `suspend_on_destroy`, у модификаторов `keep_on_destroy` — два имени для одного смысла.
|
||||||
|
- То, что для Terraform опаснее всего — устойчивость `Read` (вечный diff), — разобрано тоньше всего, хотя именно там был реальный баг с порядком ключей JSON.
|
||||||
|
|
||||||
|
**Итог:** ~60 % полезного (S1, S5, R1, R4 + вопрос про оверлей), но с натяжками в S2/S4/R2 и без разбора `Read`. Главный дефект — поверхностный lifecycle-анализ.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 8 — Указание пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
так сделай ему замечание !!! пусть глубже копнёт ЕСЛИ ЭТО НАДО
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замечания агента (раунд 2) — текст, отправляемый Opus
|
||||||
|
|
||||||
|
Сохранён отдельным файлом: `NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers_round2.md`.
|
||||||
|
Содержимое (дословно):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Промпт для Opus 4.8 — РАУНД 2: замечания к отчёту (2026-09-30)
|
||||||
|
|
||||||
|
> Продолжение диалога. Раунд 1 — `prompt_for_opus_provider_architecture_modifiers.md`.
|
||||||
|
> Формат ответа тот же: сжато, тезисы, `файл:строка`, без догадок. Границы доступа — как в раунде 1
|
||||||
|
> (плюс список из §4 ниже). `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, git-история — по-прежнему ЗАПРЕЩЕНЫ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Зачтено (переделывать НЕ надо)
|
||||||
|
|
||||||
|
`S1`, `S5`, `R1`, `R4` — приняты. Не повторяй их в ответе.
|
||||||
|
|
||||||
|
## 2. Замечания — обязательны к отработке
|
||||||
|
|
||||||
|
**M1. Номера строк не сходятся.**
|
||||||
|
Ты дал `provider.go:199-204` для регистрации модификаторов. По моему чтению файла (начиная со
|
||||||
|
строки 180) функция `Resources()` находится примерно на строке 182, и регистрация идёт сразу за ней —
|
||||||
|
твои 199–204 не сходятся. Требование задания — «каждое утверждение проверяемо».
|
||||||
|
Действие: перепроверь **каждую** ссылку `файл:строка` в отчёте и дай точные номера; где не сверял —
|
||||||
|
пометь «не сверено». Без этого отчёт не принимается.
|
||||||
|
|
||||||
|
**M2. `R2` — нарушено правило «без догадок».**
|
||||||
|
Ты утверждаешь, что `check_hardcoded_service_ids.sh` «это не ловит», но этот скрипт **не читал**
|
||||||
|
(его не было в разрешённом списке). Это догадка, а не факт.
|
||||||
|
Действие: скрипт теперь разрешён (см. §4). Либо приведи факт из его кода, либо переформулируй в вопрос.
|
||||||
|
|
||||||
|
**M3. `S2`/`S4` — проверь основание, иначе они натянуты.**
|
||||||
|
Правила спеки («No manual edits to **generated** Go code», «Service-specific Go code is fully
|
||||||
|
**generated** from YAML») писаны про генерируемый код. Ресурсы в `resources_core` — ручные, не
|
||||||
|
генерируемые. Плюс `S4` («modify всегда через WithDefaults») относится к `Update` инстанс-ресурсов,
|
||||||
|
а не к отдельному ресурсу-модификатору.
|
||||||
|
Действие: для каждого из S2/S4 дай **текстуальную опору из спеки** (`TOOLS/ARCHITECTURE.md:строка`)
|
||||||
|
и переформулируй: это **пробел спеки** (нет категории для ручных оверлеев) или **нарушение**? Если
|
||||||
|
опоры нет — пункт снять.
|
||||||
|
|
||||||
|
**M4. `R5` — обоснуй приоритет или понизь.**
|
||||||
|
Ты сам пишешь «корректности не нарушает», но ставишь R5 выше R6.
|
||||||
|
Действие: назови шкалу ранжирования (например: вероятность × последствие × обнаружимость) и
|
||||||
|
пересчитай порядок; либо понизь R5.
|
||||||
|
|
||||||
|
**M5. Главный пробел: устойчивость `Read` и вечный diff.**
|
||||||
|
Для Terraform это опаснее всего, а разобрано тоньше всего (только R6/импорт).
|
||||||
|
Действие: разбери построчно, как `Read` модификатора формирует `vip_configure` / `ip_space_name`
|
||||||
|
из live-состояния и совпадёт ли это с тем, что вернёт `jsonencode` из конфига:
|
||||||
|
- порядок ключей JSON;
|
||||||
|
- тип `count` (строка vs число);
|
||||||
|
- снятие `null` и пустых значений;
|
||||||
|
- что видит `plan` после `Read` для `keep_on_destroy`.
|
||||||
|
Отдельно: может ли `Update` инстанс-ресурса (`nubes_vc_org` / `nubes_vc_nsxt`) вклиниться между
|
||||||
|
apply-ами модификатора и вернуть поле назад (пересечение — это R1, но нужен сценарий во времени).
|
||||||
|
Если считаешь, что глубокий разбор здесь не нужен — обоснуй одной строкой, почему.
|
||||||
|
|
||||||
|
## 3. Что упущено — доработать
|
||||||
|
|
||||||
|
**U1. Файла `modifiers.yaml` в репозитории НЕТ.**
|
||||||
|
Есть только упоминания-комментарии (в `TOOLS/yaml-generator/main.go` и док-заметках). Это не
|
||||||
|
«параллельный слой», а **задокументированное, но не созданное** наложение.
|
||||||
|
Действие: переформулируй свой открытый вопрос №1 с учётом этого факта.
|
||||||
|
|
||||||
|
**U2. Рассинхронизация словаря жизненного цикла.**
|
||||||
|
У генерируемых ресурсов — `suspend_on_destroy`, у модификаторов — `keep_on_destroy`. Два имени
|
||||||
|
для одного смысла, живут в разных ветках кода.
|
||||||
|
Действие: оцени как архитектурный дефект (единый словарь/контракт жизненного цикла), с `файл:строка`.
|
||||||
|
|
||||||
|
**U3. Корневая причина «ручных» модификаторов.**
|
||||||
|
Генератор умеет `kind: modifier`, но доменное знание (какая операция, имя, inverse) некуда положить —
|
||||||
|
оверлей не создан.
|
||||||
|
Действие: сформулируй вывод по вариантам (оверлей-данные vs ручной код vs иное) — одной строкой на вариант.
|
||||||
|
|
||||||
|
## 4. Дополнительно РАЗРЕШЕНО читать (только это, сверх раунда 1)
|
||||||
|
|
||||||
|
- `TOOLS/scripts/check_hardcoded_service_ids.sh`
|
||||||
|
- `TOOLS/resource-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/instance.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/modifier.go`
|
||||||
|
- `provider/internal/resources_core/state_refresh.go`
|
||||||
|
- `provider/internal/resources_core/helpers.go`
|
||||||
|
- `provider/internal/resources_core/params_compare.go`
|
||||||
|
|
||||||
|
Всё остальное — по-прежнему нельзя. Нужен файл вне списка → вопрос мне, не чтение.
|
||||||
|
|
||||||
|
## 5. Формат ответа
|
||||||
|
|
||||||
|
- **Только дельта**: что исправляешь, что снимаешь, чем обосновываешь; новые находки по M5/U2.
|
||||||
|
- Полный отчёт заново не переписывать.
|
||||||
|
- Как в раунде 1: тезисы, `файл:строка`, без «лирики» и без «а ещё могу».
|
||||||
|
|
||||||
|
## 6. Разрешение копать глубже
|
||||||
|
|
||||||
|
Если для честного ответа нужно копнуть глубже (в т.ч. по файлам вне §4) — **скажи прямо**, каких
|
||||||
|
файлов не хватает, и задай вопрос. Если глубина не нужна — так и напиши по конкретному пункту.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 9 — Ответ Opus на раунд 2 (дословно, как получен от пользователя)
|
||||||
|
|
||||||
|
### Сырой лог сессии
|
||||||
|
|
||||||
|
```
|
||||||
|
Read
|
||||||
|
|
||||||
|
Раунд 2. Перепроверю номера строк и прочту новые разрешённые файлы.
|
||||||
|
|
||||||
|
Read , lines 193 to 195
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read , lines 178 to 195
|
||||||
|
|
||||||
|
Searched for regex `ResolveRefSvcParamValue\(ctx, 19|noNeededIpSpace|func \(r \*|len\(items\) == 0|RunInstanceOperationUniversalByCode|liveItems, parseErr|if raw, ok := live\["ipSpaceName"\]|func parseVipConfigure|func formatVipConfigure|func vipAllocationsEqual` (`**/provider/internal/resources_core/org_ip_allocation_resource.go`), 21 results
|
||||||
|
|
||||||
|
Searched for regex `noNeededIpSpace|func \(r \*NsxtSnat|if raw, ok := live\["ipSpaceName"\]|func \(r \*NsxtSnatResource\) setSnat|RunInstanceOperationUniversalByCode|ImportState` (`**/provider/internal/resources_core/nsxt_snat_resource.go`), 18 results
|
||||||
|
|
||||||
|
Прочитаю новые разрешённые файлы: шаблоны генератора, helpers, params_compare, state_refresh.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю helpers.go, state_refresh.go, params_compare.go для проверки поведения `ParamFormat`/refresh.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Проверю точные номера строк в `ARCHITECTURE.md` для опоры S2/S4.
|
||||||
|
|
||||||
|
Searched for regex `must not contain service-specific|fully generated from YAML|No manual edits|no service-specific logic inside the core|modify всегда через WithDefaults|serviceSpecificModifiers|exactly two named registries` (`**/TOOLS/ARCHITECTURE.md`), 7 results
|
||||||
|
|
||||||
|
Searched for regex `for _, param := range cfsParams|func \(c \*UniversalClient\) RunInstanceOperationUniversalByCode|RunInstanceOperationUniversalByIdempotent|val, hasLive := lookupLiveParam` (`**/provider/internal/core/operation_run_bycode.go`), 5 results
|
||||||
|
|
||||||
|
Read , lines 174 to 193
|
||||||
|
|
||||||
|
Дельта по раунду 2.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Дельта (дословно)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## M1 — перепроверенные `файл:строка`
|
||||||
|
|
||||||
|
| Ссылка раунда 1 | Было | Факт (сверено) |
|
||||||
|
|---|---|---|
|
||||||
|
| Регистрация модификаторов | `provider.go:199-204` | `provider.go:182` — `func Resources()`; регистрация — `provider.go:186` и `provider.go:187` |
|
||||||
|
| Удалённый реестр в генераторе | `yaml-generator/main.go:70-90` | `main.go:80-90` (комментарий про снятый `serviceSpecificModifiers`) |
|
||||||
|
| hardcoded id 19 | `~300` | `org_ip_allocation_resource.go:308` |
|
||||||
|
| `noNeededIpSpace` | `43` | const — `nsxt_snat_resource.go:44`; `setSnat` — `nsxt_snat_resource.go:234`; inverse-modify — `nsxt_snat_resource.go:199-200` |
|
||||||
|
| Досылка дефолтов ByCode | `105-150` | `operation_run_bycode.go:112-150`; `ByCode` — `operation_run_bycode.go:11`, `ByIdempotent` — `operation_run_bycode.go:18` |
|
||||||
|
| `len(items)==0` → error | `328-331` | `org_ip_allocation_resource.go:333` |
|
||||||
|
| GenModifier-конвейер | `55-90` | `loader.go:57-86` (`op.Kind=="modifier"`) |
|
||||||
|
|
||||||
|
Прочее сверено: org_ip `ImportState` `org_ip_allocation_resource.go:290`; nsxt `ImportState` `nsxt_snat_resource.go:224`; nsxt `Read` overwrite `nsxt_snat_resource.go:152`; org_ip `Read` semantic-compare `org_ip_allocation_resource.go:177-188`.
|
||||||
|
|
||||||
|
## M2 — R2 переформулирован как ФАКТ (скрипт прочитан)
|
||||||
|
|
||||||
|
`check_hardcoded_service_ids.sh:15`: `grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS"`.
|
||||||
|
Два проверяемых факта:
|
||||||
|
1. **Область — только `TOOLS`.** Дерево `provider` не сканируется вовсе → ручные модификаторы в `resources_core` вне охвата стража по определению.
|
||||||
|
2. **Паттерн ловит только `.ServiceID==N` / `.ID==N`.** Литерал-аргумент `org_ip_allocation_resource.go:308` (`ResolveRefSvcParamValue(ctx, 19, …)`) под паттерн не подпадает даже теоретически.
|
||||||
|
|
||||||
|
Итог R2: hardcoded 19/22/`"no-needed"` не покрыты стражем по **двум** причинам (область + паттерн). Догадки убраны.
|
||||||
|
|
||||||
|
## M3 — S2/S4 пересмотрены
|
||||||
|
|
||||||
|
**S4 — СНИМАЮ.** Опора `ARCHITECTURE.md:134` находится в разделе «Generated Code Resilience» и относится к `Update` генерируемых инстанс-ресурсов, а не к ручному модификатору. К модификаторам правило неприменимо → расхождения нет.
|
||||||
|
|
||||||
|
**S2 — переклассифицирую в ПРОБЕЛ СПЕКИ (не «нарушение No manual edits»).**
|
||||||
|
Опоры: `ARCHITECTURE.md:13` и `ARCHITECTURE.md:213` говорят про *generated* Go — к ручным файлам не относятся (замечание верное). Но `ARCHITECTURE.md:12` («core … must not contain service-specific logic») и `ARCHITECTURE.md:110` («no service-specific logic inside the core») сформулированы про «core» без оговорок. Ручные модификаторы с зашитыми id 19/22 лежат в `resources_core` (`org_ip_allocation_resource.go:308`, `nsxt_snat_resource.go:44`). Спека **не содержит категории «ручной сервисный оверлей»** и не говорит, является ли `resources_core` частью «core». Поэтому S2 — пробел контракта (нет разрешённого места для такого кода), а по букве принципа 2 — пограничное противоречие. Не «нарушение No manual edits».
|
||||||
|
|
||||||
|
## M4 — шкала ранжирования и пересчёт
|
||||||
|
|
||||||
|
Шкала: **severity = вероятность × тяжесть_последствия × (1/обнаружимость)**. «Тихие» дефекты (низкая обнаружимость) поднимаются; то, что сразу видно в плане/диффе — опускается.
|
||||||
|
|
||||||
|
- **R2** — вер. высокая (любое добавление сервис-логики), тяжесть средняя (эрозия инварианта «ядро универсально»), обнаружимость низкая (страж молчит) → **верх**.
|
||||||
|
- **R3** — вер. средняя (забытый `depends_on`), тяжесть средняя (непонятная ошибка платформы), обнаружимость средняя → **середина**.
|
||||||
|
- **R6** — вер. средняя (импорт редок, но реален), тяжесть низкая, обнаружимость средняя → **ниже R3**.
|
||||||
|
- **R5** — «корректности не нарушает» (моё же слово), тяжесть минимальная, обнаружимость высокая → **низ**.
|
||||||
|
|
||||||
|
Новый порядок: **R2 > R3 > R6 > R5**. R5 понижен ниже R6 (замечание принято).
|
||||||
|
|
||||||
|
## M5 — устойчивость `Read` и вечный diff (главный разбор)
|
||||||
|
|
||||||
|
**org_ip, путь значения `vip_configure`:**
|
||||||
|
- Конфиг: `jsonencode([{name,count}])` → ключи по алфавиту (`count`,`name`); `count` — тип из `var.ip_count` (в `modifiers.tf:24` не квотирован → зависит от типа переменной).
|
||||||
|
- Канон провайдера `org_ip_allocation_resource.go:385`: `name` первым, `count` — всегда строка.
|
||||||
|
- Эти две формы **байт-различны** (порядок ключей; возможно число vs строка).
|
||||||
|
- Защита от вечного diff — `org_ip_allocation_resource.go:177-188`: сравнение **смысловое** (`org_ip_allocation_resource.go:398`, игнорирует порядок и формат), и при совпадении state **не перезаписывается** → в state остаётся байт-в-байт конфиг-форма → `plan` чист. `count`-число нормализуется в строку в `org_ip_allocation_resource.go:351` до сравнения, поэтому «3» (число) и «"3"» считаются равными. `null`/пустые/`[{}]` отбрасываются там же → не порождают фантомную аллокацию.
|
||||||
|
- Перезапись (реальный дрейф) даёт канон-форму (name-first) — она никогда не совпадёт байтово с `jsonencode`, но diff тогда **легитимен**; ближайший `Update` ставит `state=plan` (`org_ip_allocation_resource.go:119`) и вечного diff не создаёт.
|
||||||
|
|
||||||
|
**nsxt_snat, путь `ip_space_name`:** скаляр-строка. `nsxt_snat_resource.go:152` перезаписывает state только если live-значение непустое; иначе оставляет план (защита Required-атрибута от null). Форматных расхождений нет (простое имя) → вечного diff нет.
|
||||||
|
|
||||||
|
**`keep_on_destroy` в плане:** Optional+Computed, `Default=false` (`org_ip_allocation_resource.go:84`, `nsxt_snat_resource.go:78`). `Read` читает его из state в начале и не трогает (наружу его в API нет) → после первого apply стабилен, `plan` чист. В `modifiers.tf:30` задан `true` явно — diff отсутствует.
|
||||||
|
|
||||||
|
**Вывод M5-основной:** сами модификаторы вечного diff **не дают** — смысловое сравнение в `Read` его гасит.
|
||||||
|
|
||||||
|
**Временной сценарий R1 (инстанс-ресурс возвращает поле назад):**
|
||||||
|
Шаблон instance.go Update строит `params` из **всех** `ModifyParams` безусловно (не пропускает null для не-nested), и вызывает `UpdateResourceWithTimeout` → `RunInstanceOperationUniversalWithDefaults`. Гейт — `hasServiceParamChanges` (тот же шаблон): modify запускается, если изменился **любой** modify-параметр.
|
||||||
|
- `vc_org`: modify содержит **только** `vIPConfigure` (`19_vc_org.yaml`, op modify) → `nubes_vc_org` тронет поле, лишь если пользователь сам задал `vIPConfigure` на инстанс-ресурсе. Риск ниже.
|
||||||
|
- `vc_nsxt`: modify содержит `needEnableAVI`, `virtualServicesCount`, `ipSpaceName`, `qosProfile`, `routedNetConfiguration` (`22_vc_nsxt.yaml`). Сценарий: (t1) модификатор включил SNAT → (t2) пользователь на `nubes_vc_nsxt` меняет `needEnableAVI` → `hasServiceParamChanges=true` → `params[372]=ParamFormat(plan.IpSpaceName)` (запись присутствует всегда) → (t3) следующий `Read` модификатора видит дрейф и на очередном apply восстанавливает. Две сущности «пинают» поле по очереди.
|
||||||
|
|
||||||
|
**Не сверено** (нужны файлы вне §4): пошлёт ли `RunInstanceOperationUniversalWithDefaults` пустой `ipSpaceName` как `""` (затрёт SNAT) или дособерёт из live (как ByCode). Это решает, «затирание» или «no-op». Файлы: `core/operation_*` с реализацией `WithDefaults` и генератор-хелпер `ParamFormat` (funcs.go) для поведения null→"". См. M6.
|
||||||
|
|
||||||
|
## U1 — открытый вопрос №1 переформулирован
|
||||||
|
|
||||||
|
`modifiers.yaml` в репозитории **нет** — только упоминания-комментарии (`main.go:84-90`). Генерируемый слой модификаторов при этом **реален и готов**: шаблон `modifier.go` (полный CRUD + `reconcile` + `delete_strategy` + `idempotency`) и конвейер `loader.go:57-86`. То есть это **задокументированное-но-несозданное наложение**: механизм есть, данных для него нет. Вопрос: создавать `modifiers.yaml`-оверлей (данные) и перевести org_ip/nsxt_snat на генерацию — или узаконить ручные ресурсы отдельной категорией в спеке?
|
||||||
|
|
||||||
|
## U2 — рассинхронизация словаря жизненного цикла (архитектурный дефект)
|
||||||
|
|
||||||
|
Три несогласованных словаря «что делать при destroy», в разных ветках кода:
|
||||||
|
1. Генерируемый инстанс: `suspend_on_destroy` + `keep_on_destroy` (runtime-булевы, приоритет keep) — instance.go schema и `Delete` (ветки `suspend`/`state_only`).
|
||||||
|
2. Генерируемый модификатор: `delete_strategy` = `noop_warn`/`inverse`/`error` — **compile-time из YAML**, без `keep_on_destroy` — modifier.go Delete.
|
||||||
|
3. Ручной модификатор: снова `keep_on_destroy` (runtime) + ручной inverse — `org_ip_allocation_resource.go:84`, `org_ip_allocation_resource.go:195`; `nsxt_snat_resource.go:78`.
|
||||||
|
|
||||||
|
Дефект: один смысл («не трогать эффект при destroy / обратный откат») выражен тремя разными контрактами (runtime-флаг vs compile-time-стратегия), причём генерируемый и ручной модификаторы, решая одну задачу, используют **противоположные** механизмы. Нужен единый словарь жизненного цикла для всех трёх видов ресурсов.
|
||||||
|
|
||||||
|
## U3 — корневая причина «ручных» модификаторов (по вариантам, одной строкой)
|
||||||
|
|
||||||
|
- **Оверлей-данные (`modifiers.yaml` + второй проход):** доменное знание (сервис, операция, имя, inverse, idempotency) — декларативно; org_ip/nsxt_snat становятся генерируемыми; убирает S2/U2/R2 разом; цена — доделать загрузчик оверлея и миграцию.
|
||||||
|
- **Ручной Go-код (статус-кво):** максимум гибкости под нестандартную семантику (replace-массив, `no-needed`); цена — сервис-логика в `resources_core`, обход стража, тройной словарь.
|
||||||
|
- **Иное (гибрид):** генерировать каркас из оверлея, а нетиповые куски (канон vIPConfigure) выносить в маленькие data-хуки; цена — усложнение шаблона ради 1-2 сервисов.
|
||||||
|
|
||||||
|
## M6 — где нужна глубина (прошу доступ)
|
||||||
|
|
||||||
|
Для однозначного вердикта по временному R1 (затирание vs досбор) не хватает **двух** файлов вне §4:
|
||||||
|
1. Реализация `RunInstanceOperationUniversalWithDefaults` (ядро, `core/operation_run*.go` / `operation_cfs.go`) — шлёт ли пустой `ipSpaceName` как `""`.
|
||||||
|
2. Генератор-хелпер `ParamFormat` (`TOOLS/resource-generator/internal/.../funcs.go`) — во что разворачивается null-строка в `params`.
|
||||||
|
|
||||||
|
Разрешить чтение этих двух файлов? Без них пункт помечен «не сверено».
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 10 — Инструкция и пояснение пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
#attachment:Pasted text #1
|
||||||
|
докуменитируй сразу
|
||||||
|
|
||||||
|
пояснияю - модификаторы - модет и слодная, новая, но НЕБОЛЬШАЯ часть всего нашего сервиса
|
||||||
|
не нужно делать на них чрезмерный упор
|
||||||
|
главное - САМ провайдер, его устойчивость и правильность и тд
|
||||||
|
```
|
||||||
|
|
||||||
|
Разбор указания (для дальнейших раундов):
|
||||||
|
|
||||||
|
- **Документировать немедленно** — исполнено этим разделом.
|
||||||
|
- **Приоритет — САМ провайдер**: его устойчивость и корректность. Модификаторы — новая и, возможно,
|
||||||
|
сложная, но **НЕБОЛЬШАЯ** часть сервиса; чрезмерный упор на них делать не нужно.
|
||||||
|
- Следствие: темы `M5`/`M6`/`U2` (глубина по модификаторам) — второстепенны по отношению к вопросам
|
||||||
|
устойчивости/корректности ядра, генерации и всего провайдера в целом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 10a — Запрос пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
твоё мнение ?
|
||||||
|
```
|
||||||
|
|
||||||
|
Мнение агента по раунду 2 приведено ниже, внутри Хода 11 (раздел «Критическое мнение агента
|
||||||
|
о раунде 2»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 11 — Запрос пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
так дополни задание ему, пусть ещё анализирует НО не нало много - токены могути закончиться
|
||||||
|
```
|
||||||
|
|
||||||
|
### Критическое мнение агента о раунде 2 (дословно, отправлено пользователю)
|
||||||
|
|
||||||
|
```
|
||||||
|
**Что стало лучше**
|
||||||
|
- **M1** — Opus честно перепроверил и исправился: `provider.go:182/186/187`. Совпадает с моим чтением → доверие к остальным номерам выросло.
|
||||||
|
- **M2** — догадка устранена: приведена конкретная строка скрипта, область (только `TOOLS/`) и паттерн (`.ServiceID==N`/`.ID==N`). Это уже факт, и факт полезный: дерево `provider/` стражем не покрыто **вообще**.
|
||||||
|
- **M3** — `S4` снят с текстуальной опорой (`ARCHITECTURE.md:134` в разделе про generated-код), `S2` переклассифицирован в пробел спеки с опорой (`:12`, `:110` vs `:13`, `:213`). Честно и корректно.
|
||||||
|
- **U2** — новая ценная находка, которой у меня не было: **третий** словарь жизненного цикла — `delete_strategy` (compile-time в YAML) против runtime-флагов. Настоящий архитектурный дефект.
|
||||||
|
- **M4** — шкала задана, `R5` понижен. Замечание принято по существу.
|
||||||
|
|
||||||
|
**Что остаётся слабым**
|
||||||
|
- **M5 — это чтение кода, а не замер.** Вся защита от вечного diff держится на утверждении «в `Read` смысловое сравнение, и при совпадении state не перезаписывается». Это правдоподобно и проверяемо, но **не проверено прогоном** `terraform apply` ×2 / `plan`. По нашему же правилу «рекомендация без замера — догадка». Opus не предложил верификацию.
|
||||||
|
- **Риск переоценён/недооценён.** Самое опасное (R1: `Update` инстанс-ресурса затирает `ipSpaceName`) осталось **нерешённым** — за M6. При этом Opus сам пишет, что для `vc_nsxt` modify содержит 5 параметров, и запись `params[372]` присутствует **всегда**. Если так — это не «война дрейфов», а **молчаливое затирание SNAT** при любой правке эджа. Это надо было ставить вопросом №1 прошлого раунда, а не откладывать.
|
||||||
|
- **Ничего про сам провайдер.** Раунд целиком про модификаторы — ровно то, что вы просили не переоценивать. Ядро, генератор, устойчивость, корректность — не тронуто.
|
||||||
|
|
||||||
|
**Моё мнение**
|
||||||
|
Раунд 2 — честный и в плюс (правки по номерам, снятие `S4`, находка `U2`). Но по вашему приоритету он **мимо цели**: модификаторы, а не провайдер; и при этом главный вопрос (затирание `ipSpaceName` при `Update` эджа + отсутствие замера) остался открытым. Логичный следующий ход — развернуть раунд на «провайдер целиком», а `M6` **отложить**, а не разворачивать.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 12 — Задание агента (раунд 3) — текст, отправляемый Opus
|
||||||
|
|
||||||
|
Сохранён отдельным файлом: `NOTES/20_prompts/prompt_for_opus_provider_core_round3.md`.
|
||||||
|
Содержимое (дословно):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Промпт для Opus 4.8 — РАУНД 3: сам провайдер (устойчивость и корректность)
|
||||||
|
|
||||||
|
> Продолжение диалога. Раунды 1–2 — `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
|
||||||
|
> Режим и формат ответа — как раньше: сжато, тезисы, `файл:строка`, без догадок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вводная (смена приоритета)
|
||||||
|
|
||||||
|
Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
|
||||||
|
**НЕБОЛЬШАЯ** часть сервиса. Чрезмерный упор на них не нужен.
|
||||||
|
|
||||||
|
**Главное — САМ провайдер: его устойчивость и корректность.**
|
||||||
|
|
||||||
|
- `M6` (доступ к двум файлам для доразбора R1) — **снять**; углубление по модификаторам больше не требуется.
|
||||||
|
- Прошлые находки (`S1`, `S2`, `R2`–`R6`, `U1`–`U3`) **не повторять**.
|
||||||
|
|
||||||
|
## Бюджет (жёстко — экономим токены)
|
||||||
|
|
||||||
|
- Прочитать **не более 10 файлов** суммарно. Ранее прочитанные повторно не открывать.
|
||||||
|
- Ответ — **не более 5 находок**, каждая **не более 3 строк**.
|
||||||
|
- Никаких вступлений, повторения прошлых отчётов, «а ещё могу».
|
||||||
|
|
||||||
|
## Что анализировать (провайдер целиком)
|
||||||
|
|
||||||
|
1. **Жизненный цикл инстанс-ресурса:** `create` / adopt / `suspend` / resume / `modify` / redeploy /
|
||||||
|
`delete` и повторный `apply` — где теряется корректность состояния.
|
||||||
|
2. **Досылка и нормализация параметров** (`WithDefaults`, zero-value fallback, дефолты `map-fixed`,
|
||||||
|
регистр UUID): где риск затереть значение или получить ложный diff.
|
||||||
|
3. **`Read` / refresh:** устойчив ли state у генерируемых ресурсов; где возможен вечный diff.
|
||||||
|
4. **Устойчивость ядра:** ретраи и ошибки, `LockInstance`, идемпотентность, поведение при дубликатах
|
||||||
|
и параллельных `apply`.
|
||||||
|
5. **Генератор → код:** какие классы дефектов порождает шаблон.
|
||||||
|
|
||||||
|
## Границы доступа
|
||||||
|
|
||||||
|
Разрешено читать (только это):
|
||||||
|
|
||||||
|
- `provider/internal/core/**`
|
||||||
|
- `provider/internal/resources_core/**`
|
||||||
|
- `provider/internal/provider/provider.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/**`
|
||||||
|
- `TOOLS/resource-generator/internal/params/params.go`
|
||||||
|
- `TOOLS/resource-generator/internal/helpers/helpers.go`
|
||||||
|
- `TOOLS/resource-generator/internal/loader/loader.go`
|
||||||
|
- `TOOLS/resource-generator/internal/writers/writers.go`
|
||||||
|
|
||||||
|
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, `DEV_STAND/**`, `TEST_STAND/**`,
|
||||||
|
`PROD_STAND/**`, git-история. Нужен файл вне списка → вопрос мне, не чтение.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
- **ТОП-5 находок** по убыванию критичности: суть → место (`файл:строка`) → последствие (≤ 3 строк).
|
||||||
|
- Затем **одна строка**: что из этого проверяемо только замером (прогон `apply`/`plan`), а не чтением.
|
||||||
|
- Находок меньше пяти — столько, сколько есть. Водой не добивать.
|
||||||
|
|
||||||
|
## Стоп-правило
|
||||||
|
|
||||||
|
Не хватает файла или данных — один короткий вопрос. Не догадываться.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 12a — Сообщение пользователя и вопрос агента
|
||||||
|
|
||||||
|
Пользователь прислал **только путь к файлу**, без какой-либо инструкции:
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/naeel/TF/tf_provider/NOTES/20_prompts/prompt_for_opus_provider_core_round3.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Агент, не имея однозначной команды, задал уточняющий вопрос с вариантами (проверить пути из
|
||||||
|
списка доступа / поправить текст задания / пересмотреть состав и границы / ничего не делать).
|
||||||
|
**Пользователь вопрос отклонил (cancelled)** — никаких изменений не вносилось, уточнение осталось
|
||||||
|
без ответа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 13 — Ответ Opus на раунд 3 (дословно, как получен от пользователя)
|
||||||
|
|
||||||
|
### Сырой лог сессии
|
||||||
|
|
||||||
|
```
|
||||||
|
Read
|
||||||
|
|
||||||
|
Ограничусь бюджетом. Посмотрю состав `core/` и выберу ключевые непрочитанные файлы.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю ещё 4 ключевых файла ядра: ожидание операций, поиск (дубликаты), нормализацию параметров, cfs.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Прочитаю ещё 2: состояние инстанса (проверка ретрая 401) и нормализацию параметров.
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Read
|
||||||
|
|
||||||
|
Бюджет: прочитано 6 файлов. ТОП-5 находок по устойчивости/корректности ядра.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отчёт (дословно)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**1. Транзиентный 401 не ретраится нигде; `getInstanceStateWithRetry` — имя-обман (ретрая нет).**
|
||||||
|
`instance_state.go:39` просто зовёт `doRequest` один раз; `http.go:118` `isRetryable` = {429,502,503,504}, **без 401**.
|
||||||
|
Прямо противоречит ARCHITECTURE.md:105-108 («GetInstanceState retry 401», «doRequest treats 401 as retryable»). Кратковременный 401 Gateway роняет любой read → срыв plan/apply/поллинга.
|
||||||
|
|
||||||
|
**2. Ретраится только GET; любой POST падает намертво на транзиентном 502/503/сети.**
|
||||||
|
`http.go:88` — retry только при `method=="GET"`.
|
||||||
|
Сбой на `/instanceOperations`, `/instanceOperationCfsParams` или `run` рвёт операцию; при create остаётся осиротевший инстанс → следующий apply ловит страж дубликатов `instance_find.go:168`.
|
||||||
|
|
||||||
|
**3. Modify обрывается уже ПОСЛЕ создания операции, если чтение live-параметров сбойнуло.**
|
||||||
|
`operation_run.go:150` (и симметрично bycode `operation_run_bycode.go:108`): `instanceLiveParams` err → `return`, но POST `/instanceOperations` уже прошёл.
|
||||||
|
Любой транзиентный сбой чтения live → повисшая pending-операция, блокирующая инстанс до idle.
|
||||||
|
|
||||||
|
**4. Zero-value fallback угадывает тип по ПОДСТРОКЕ имени параметра.**
|
||||||
|
`params.go:47`: при пустом `dataType` тип берётся из `name/code/label`, содержащих `array/list/map/json`.
|
||||||
|
Строковый параметр с кодом вроде `whitelist`/`...json...` получит `"[]"`/`"{}"` вместо `""` → неверно-типизированное значение уходит в API на незаданном поле.
|
||||||
|
|
||||||
|
**5. Эквивалентность значения в `Read` захардкожена под `jsonEnv`/ref_svc — прочие строки пишутся как вернул API.**
|
||||||
|
`state_refresh.go:143` спец-ветка только для `jsonEnv`; остальное — `ParseString(value)` «как есть» `state_refresh.go:168`. Регистровая нормализация есть лишь в ref_svc-пути `refsvc_resolve.go:29`.
|
||||||
|
Для non-ref_svc строкового поля, где API канонизирует формат (регистр UUID и т.п.), plan≠state → вечный diff.
|
||||||
|
|
||||||
|
Проверяемо только замером (не чтением): №1–3 (поведение при флапающем Gateway, орфан-операции) — прогоном `apply`; №5 (вечный diff) — прогоном `plan`.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ход 14 — Запрос пользователя (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
мнение ? более вопросов в том чате задават не получится - он уже большой и жрёт токены
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мнение агента о раунде 3 (дословно)
|
||||||
|
|
||||||
|
```
|
||||||
|
**Сильное**
|
||||||
|
- **#1** — самое ценное: `ARCHITECTURE.md:105-108` буквально обещает ретрай 401 для GET, а код, по его словам, 401 не ретраит. Если верно — это не «мелочь», а срыв `plan`/`apply`.
|
||||||
|
- **#3** — правдоподобно и практично: обрыв modify уже **после** отправленного POST → висящая операция.
|
||||||
|
- **#5** — бьёт в самое больное место Terraform (вечный diff).
|
||||||
|
|
||||||
|
**Где не верю / нужна аккуратность**
|
||||||
|
- **#1 — слово «нигде» настораживает.** Спека говорит, что ретрай 401 живёт внутри `GetInstanceState`/`GetInstanceStateRaw`, а Opus цитирует `instance_state.go:39` — один вызов `doRequest`. Вполне возможно, что ретрай реализован в другой функции, и абсолютный вывод неверен. Проверять надо именно те функции, а не один call-site.
|
||||||
|
- **#2 — подано как дефект, а это может быть защита.** Слепой ретрай POST `/instanceOperations` **опаснее** обрыва: рискует создать дубликат операции. Настоящий дефект — не «нет ретрая», а «нет идемпотентности / безопасного продолжения». В нынешней формулировке вывод вводит в заблуждение.
|
||||||
|
- **#4 — нет оценки частоты.** Срабатывает только при **пустом** `dataType`. Без ответа «как часто он пуст» это теория, а не риск.
|
||||||
|
- **#5 — возможно, уже покрыто.** По §6.5 `ARCHITECTURE_NEW.md` нормализация UUID при **сравнении** есть в `JSONStringsEquivalent` / `JsonNormalize` / `normalizeCompareValue` / `ParamsMatchForResume`. Прежде чем считать это багом, надо убедиться, что расхождение не гасится там.
|
||||||
|
|
||||||
|
**Общее**
|
||||||
|
- Все пять — чтение кода; ни одна не измерена. Это **гипотезы**, и формально Opus это честно признал.
|
||||||
|
- Раз чат исчерпан по токенам — дальнейшая проверка возможна только **локально**, своими силами: `grep` по `isRetryable`/`GetInstanceState`, сверка `state_refresh.go` с `normalizeCompareValue`.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- Раунды 1–3: все три отчёта получены. Раунд 3 уложился в бюджет (прочитано 6 файлов, 5 находок).
|
||||||
|
- **Итог раунда 3 (ядро, устойчивость/корректность):**
|
||||||
|
1. 401 не ретраится нигде; `isRetryable` без 401 — противоречие `ARCHITECTURE.md:105-108`.
|
||||||
|
2. Ретрай только для GET; POST обрывается на 502/503/сети → риск осиротевших операций/инстансов.
|
||||||
|
3. Modify может оборваться **после** создания операции (сбой чтения live) → повисшая pending-операция.
|
||||||
|
4. Zero-value fallback определяет тип по подстроке имени → возможна неверная типизация незаданного поля.
|
||||||
|
5. Нормализация значения в `Read` — только `jsonEnv`/ref_svc → вечный diff на прочих канонизируемых строках.
|
||||||
|
- Находки №1–3 и №5 **не подтверждены замером** (по словам самого Opus) — перенесены как гипотезы.
|
||||||
|
- **Чат с Opus исчерпан по токенам** (сообщено пользователем 2026-09-30): новые вопросы в него
|
||||||
|
задавать нельзя; проверка находок возможна только локально.
|
||||||
|
- **Мнения агента записаны по всем раундам:** раунд 1 — Ход 7; раунд 2 — Ход 11 (и Ход 10a);
|
||||||
|
раунд 3 — Ход 14.
|
||||||
|
- Артефакты: `752244f` — промпт раунда 1; `ea75507` — замечания раунда 2;
|
||||||
|
`e46bc35` — задание раунда 3;
|
||||||
|
`NOTES/20_prompts/prompt_for_opus_provider_architecture_modifiers_round2.md`,
|
||||||
|
`NOTES/20_prompts/prompt_for_opus_provider_core_round3.md`.
|
||||||
|
- Следующий шаг (2026-09-30): промпт для **DeepSeek Pro** —
|
||||||
|
`NOTES/20_prompts/prompt_for_deepseek_pro_remediation_plan.md` (план правок кода/документации/архитектуры
|
||||||
|
+ план проверки/тестов; гипотезы групп A/B переданы ему на верификацию; исполнять будет Copilot).
|
||||||
|
- Настоящий документ ведётся по прямому указанию пользователя «документируй ВЕСЬ диалог,
|
||||||
|
без сокращений».
|
||||||
@@ -45,7 +45,7 @@ Provider naming defaults:
|
|||||||
Script: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
|
Script: `TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/<стенд>`
|
||||||
|
|
||||||
Input list of services:
|
Input list of services:
|
||||||
- `TOOLS/config/services_list.txt` (service_id only)
|
- `TOOLS/config/<стенд>/services_list.txt` (service_id + name; у каждого стенда свой список)
|
||||||
|
|
||||||
Token options:
|
Token options:
|
||||||
- `TOKEN_FILE=/home/naeel/terra/HH-MM-SS.token`, or
|
- `TOKEN_FILE=/home/naeel/terra/HH-MM-SS.token`, or
|
||||||
@@ -106,7 +106,7 @@ export S3_SECRET_KEY=...
|
|||||||
- The GPG private key must remain stable across releases. Do not regenerate per build.
|
- The GPG private key must remain stable across releases. Do not regenerate per build.
|
||||||
- If the key is regenerated, the registry server must be updated to serve the new public key.
|
- If the key is regenerated, the registry server must be updated to serve the new public key.
|
||||||
- Terraform will fail with `authentication signature from unknown issuer` if the registry public key does not match the signing key.
|
- Terraform will fail with `authentication signature from unknown issuer` if the registry public key does not match the signing key.
|
||||||
- `TOOLS/config/services_list.txt` — источник правды по тому, какие сервисы генерируются.
|
- `TOOLS/config/<стенд>/services_list.txt` — источник правды по тому, какие сервисы генерируются (у каждого стенда свой).
|
||||||
- Если меняется версия провайдера — обновить `provider/main.go` (ранее `universal_rebuild/main.go` — устаревший путь).
|
- Если меняется версия провайдера — обновить `provider/main.go` (ранее `universal_rebuild/main.go` — устаревший путь).
|
||||||
|
|
||||||
## One-time GPG bootstrap (do this once, keep the key stable)
|
## One-time GPG bootstrap (do this once, keep the key stable)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. Где прописывать
|
## 1. Где прописывать
|
||||||
|
|
||||||
Единственная точка входа — `devops/config/services_list.txt` (или профильный `profiles/{stand}/services_list.txt`).
|
Единственная точка входа — `TOOLS/config/<стенд>/services_list.txt` (у каждого стенда свой список).
|
||||||
|
|
||||||
Формат строки:
|
Формат строки:
|
||||||
```
|
```
|
||||||
@@ -158,21 +158,22 @@ operations:
|
|||||||
## 8. Быстрый старт: добавляем новый сервис
|
## 8. Быстрый старт: добавляем новый сервис
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Добавить строку в services_list.txt
|
# 1. Добавить строку в список сервисов нужного стенда
|
||||||
echo "200 my_new_service # Моя новая услуга" >> devops/config/services_list.txt
|
# (TOOLS/config/dev|test|prod/services_list.txt)
|
||||||
|
echo "200 my_new_service # Моя новая услуга" >> TOOLS/config/test/services_list.txt
|
||||||
|
|
||||||
# 2. Сгенерировать YAML (test-стенд)
|
# 2. Сгенерировать YAML (test-стенд)
|
||||||
devops/01_generate_yamls.sh --profile devops/profiles/test
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/test
|
||||||
|
|
||||||
# 3. Сгенерировать Go-код + доки
|
# 3. Сгенерировать Go-код + доки
|
||||||
devops/02_generate_resources_and_docs_v2.sh --profile devops/profiles/test
|
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/test
|
||||||
|
|
||||||
# 4. Проверить что появился файл
|
# 4. Проверить что появился файл
|
||||||
ls devops/profiles/test/generated/resources_yaml/200_my_new_service.yaml
|
ls generated/test/resources_yaml/200_my_new_service.yaml
|
||||||
ls devops/profiles/test/generated/go/200_my_new_service_resource.go
|
ls generated/test/go/200_my_new_service_resource.go
|
||||||
|
|
||||||
# 5. Собрать и задеплоить провайдер
|
# 5. Собрать и задеплоить провайдер
|
||||||
devops/03_build_and_upload_provider.sh --profile devops/profiles/test
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/test
|
||||||
|
|
||||||
# 6. Написать тестовый манифест в TEST_STAND/my_new_service/main.tf
|
# 6. Написать тестовый манифест в TEST_STAND/my_new_service/main.tf
|
||||||
# 7. terraform init && terraform plan && terraform apply
|
# 7. terraform init && terraform plan && terraform apply
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ cd /home/naeel/TF/tf_provider
|
|||||||
| Пайплайн публикации документации | `../DOCS_PIPELINE/README.md`, `../DOCS_PIPELINE/publish-docs.sh` |
|
| Пайплайн публикации документации | `../DOCS_PIPELINE/README.md`, `../DOCS_PIPELINE/publish-docs.sh` |
|
||||||
| Правила генерации кода провайдера (ОБЯЗАТЕЛЬНЫ для генератора) | `../TOOLS/ARCHITECTURE.md` |
|
| Правила генерации кода провайдера (ОБЯЗАТЕЛЬНЫ для генератора) | `../TOOLS/ARCHITECTURE.md` |
|
||||||
| Скрипты пайплайна | `../TOOLS/scripts/` |
|
| Скрипты пайплайна | `../TOOLS/scripts/` |
|
||||||
| Конфиги стендов и общий реестр | `../TOOLS/config/` (`registry.env`, `<стенд>/profile.env`, `services_list.txt`) |
|
| Конфиги стендов и общий реестр | `../TOOLS/config/` (`registry.env`, `<стенд>/profile.env`, `<стенд>/services_list.txt`) |
|
||||||
| Секреты (не коммитить) | `../secrets/` |
|
| Секреты (не коммитить) | `../secrets/` |
|
||||||
| Текущая задача по IaC/`modify` | `../NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` |
|
| Текущая задача по IaC/`modify` | `../NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Промпт для DeepSeek Pro — план правок кода/документации/архитектуры + стратегия проверки
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
|
||||||
|
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роль и исполнитель
|
||||||
|
|
||||||
|
- Ты — архитектор / ведущий инженер.
|
||||||
|
- Ты **НЕ пишешь код** и **НЕ меняешь файлы**. Только анализ + план.
|
||||||
|
- Результат твоей работы — **план правок** (код, документация, архитектура) и **план проверки/тестов**.
|
||||||
|
- **Исполнять будет другой агент (Copilot)** — строго по твоему плану. Поэтому план должен быть
|
||||||
|
исполнимым: точные пути файлов, функции, что именно менять, чем проверять.
|
||||||
|
|
||||||
|
## Вводная
|
||||||
|
|
||||||
|
- Главное — **сам провайдер**: устойчивость, корректность, отсутствие вечных diff.
|
||||||
|
- Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
|
||||||
|
**НЕБОЛЬШАЯ** часть сервиса. Не раздувай их.
|
||||||
|
- Ниже — **ГИПОТЕЗЫ** предыдущего анализа. Они **НЕ подтверждены замером**.
|
||||||
|
Твоя первая задача — проверить их по коду и разделить на «подтверждено / опровергнуто / нужен замер».
|
||||||
|
|
||||||
|
## Гипотезы к проверке
|
||||||
|
|
||||||
|
### Группа A — ядро (приоритет)
|
||||||
|
- **A1.** 401 не ретраится: `provider/internal/core/instance_state.go:39` зовёт `doRequest` один раз;
|
||||||
|
`isRetryable` в `provider/internal/core/http.go:118` = {429,502,503,504} без 401.
|
||||||
|
Противоречит `TOOLS/ARCHITECTURE.md:105-108` («GetInstanceState retry 401», «doRequest treats 401 as retryable»).
|
||||||
|
- **A2.** Ретрай только для GET (`http.go:88`); POST (`/instanceOperations`, `/instanceOperationCfsParams`,
|
||||||
|
`run`) обрывается на транзиентном 502/503/сети → возможны осиротевшая операция/инстанс; далее срабатывает
|
||||||
|
страж дубликатов (`instance_find.go:168`).
|
||||||
|
- **A3.** Modify может оборваться **после** создания операции, если чтение live-параметров сбойнуло:
|
||||||
|
`operation_run.go:150` и `operation_run_bycode.go:108` (`instanceLiveParams` err → `return`).
|
||||||
|
- **A4.** Zero-value fallback угадывает тип по **подстроке** имени параметра при пустом `dataType`:
|
||||||
|
`params.go:47` (ищет `array/list/map/json` в `name/code/label`).
|
||||||
|
- **A5.** В `Read` нормализация значения только для `jsonEnv`/ref_svc: `state_refresh.go:143` и `:168`;
|
||||||
|
регистровая нормализация только в ref_svc-пути (`refsvc_resolve.go:29`) → возможен вечный diff.
|
||||||
|
|
||||||
|
### Группа B — модификаторы (второстепенно)
|
||||||
|
- **B1.** `TOOLS/ARCHITECTURE.md:225` («Exception Registry») ссылается на реестр `serviceSpecificModifiers`
|
||||||
|
в `TOOLS/yaml-generator/main.go`, которого **нет** (удалён при рефакторинге; см. комментарий `main.go:80-90`).
|
||||||
|
- **B2.** Спека не описывает **ручные сервисные оверлеи** (`provider/internal/resources_core/org_ip_allocation_resource.go`,
|
||||||
|
`nsxt_snat_resource.go`) — нет категории «ручной сервисный ресурс», неясно, входит ли `resources_core` в «core».
|
||||||
|
- **B3.** Страж `TOOLS/scripts/check_hardcoded_service_ids.sh:15` сканирует **только** `TOOLS/`
|
||||||
|
и ловит **только** `.ServiceID==N`/`.ID==N` → hardcoded `19`/`22`/`"no-needed"` вне охвата.
|
||||||
|
- **B4.** Порядок «edge → аллокация» не гарантируется провайдером — держится на пользовательском `depends_on`
|
||||||
|
(`DEV_STAND/FullPipe/modifiers.tf`).
|
||||||
|
- **B5.** У `nubes_vc_org_ip_allocation` нет способа снять аллокацию через атрибут (пустой массив запрещён) — только `destroy`.
|
||||||
|
- **B6.** `modify` выполняется всегда (нет pre-check идемпотентности), хотя idempotent-путь в ядре есть.
|
||||||
|
- **B7.** `ImportState` модификаторов не заполняет Required-атрибуты.
|
||||||
|
- **B8.** `modifiers.yaml` (оверлей) **не существует**, при этом генерируемый слой модификаторов готов
|
||||||
|
(`TOOLS/resource-generator/internal/loader/loader.go:57-86`, `internal/templates/modifier.go`) и не используется.
|
||||||
|
- **B9.** Три несогласованных словаря жизненного цикла: у генерируемых ресурсов `suspend_on_destroy`/`keep_on_destroy`,
|
||||||
|
у генерируемых модификаторов `delete_strategy` (compile-time), у ручных модификаторов снова `keep_on_destroy`.
|
||||||
|
|
||||||
|
## Что нужно на выходе (строго в этом порядке)
|
||||||
|
|
||||||
|
**A. Верификация гипотез**
|
||||||
|
Таблица: `№ | подтверждено / опровергнуто / нужен замер | опора (файл:строка) | примечание`.
|
||||||
|
Опровергнутые — обосновать, почему вывод неверен.
|
||||||
|
|
||||||
|
**B. План правок кода**
|
||||||
|
Таблица: `№ | файл | функция/место | что изменить | зачем | риск (низк/сред/высок) | ломает ли совместимость`.
|
||||||
|
Только правки, вытекающие из подтверждённых пунктов. Никаких «заодно улучшим».
|
||||||
|
|
||||||
|
**C. План правок документации и архитектуры**
|
||||||
|
Что именно и в каком файле (`TOOLS/ARCHITECTURE.md`, `README.md`, `VERSIONS.md`, `HOW_TO/**`, `docs/**`).
|
||||||
|
Отдельно: какие **архитектурные решения** надо зафиксировать (напр. единый словарь жизненного цикла).
|
||||||
|
|
||||||
|
**D. План проверки и тестирования**
|
||||||
|
Для каждой правки — три уровня:
|
||||||
|
1. **Юнит/пакетный тест** — какой пакет, что проверяет, где лежит (есть примеры: `provider/internal/resources_core/*_test.go`,
|
||||||
|
`TOOLS/resource-generator/internal/loader/loader_modifier_test.go`).
|
||||||
|
2. **Интеграционная проверка** — `terraform plan` / `apply` на `DEV_STAND/FullPipe`: что запустить, что ожидать
|
||||||
|
в выводе, на что смотреть (с учётом: `apply`/`destroy` выполняет **владелец**, не агент).
|
||||||
|
3. **Регрессия** — что ещё может сломаться и как это поймать.
|
||||||
|
Плюс статические стражи: `TOOLS/scripts/check_generated_drift.sh`, `check_hardcoded_service_ids.sh`,
|
||||||
|
сборка `03_build_and_upload_provider.sh`, `dev-materialize.sh`.
|
||||||
|
|
||||||
|
**E. Порядок работ**
|
||||||
|
Шаги, сгруппированные в **отдельные коммиты**, от безопасных к рискованным. Для каждого шага — 1 строка:
|
||||||
|
что делаем и как проверяем, после чего фиксируем коммитом.
|
||||||
|
|
||||||
|
**F. Открытые вопросы**
|
||||||
|
Только то, что нельзя выяснить из кода (значения, решения владельца).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- **Ничего не менять**: не править файлы, не коммитить, не запускать `terraform`/`go`.
|
||||||
|
- `terraform apply` и `destroy` — **только владелец**.
|
||||||
|
- Каждое утверждение — с `файл:строка`. **Факт и предположение разделяй явно.**
|
||||||
|
- Бюджет: читать **не более ~20 файлов**; ответ — компактный, таблицами, **без воды и без «а ещё могу»**.
|
||||||
|
- Не предлагать переписывание с нуля без доказанной необходимости.
|
||||||
|
|
||||||
|
## Границы доступа
|
||||||
|
|
||||||
|
Разрешено читать:
|
||||||
|
- `TOOLS/**` (архитектура, генераторы, скрипты, конфиги)
|
||||||
|
- `provider/**` (кроме `artifacts/`, `bin/`, `generated/`)
|
||||||
|
- `generated/dev/resources_yaml/**`
|
||||||
|
- `docs/**`, `HOW_TO/**`, `README.md`, `VERSIONS.md`
|
||||||
|
- `DEV_STAND/FullPipe/**` (пример использования)
|
||||||
|
|
||||||
|
Запрещено: `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `secrets/**`, `! /`, `.git` (история коммитов).
|
||||||
|
Нужен файл вне списка — задай вопрос, не читай.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
- Сжато, тезисами, таблицами. Каждое утверждение проверяемо (`файл:строка`).
|
||||||
|
- Никаких вступлений, повторения вводной, «лирики».
|
||||||
|
|
||||||
|
## Стоп-правило
|
||||||
|
|
||||||
|
Задание неоднозначно или данных не хватает — **остановиться и задать один короткий вопрос**.
|
||||||
|
Не достраивать смысл и не действовать по догадке.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Opus — код-ревью правок (раунд 5, 2026-09-30)
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider` (Go, Terraform Plugin Framework). Правки уже влиты в `master`,
|
||||||
|
`go build ./...` и `go test ./internal/... -short` — зелёные. Нужно только ревью.
|
||||||
|
|
||||||
|
## Формат ответа (ЖЁСТКО)
|
||||||
|
|
||||||
|
- **Только дефекты.** По 1 строке: `файл:строка` → что не так → чем грозит.
|
||||||
|
- **Весь ответ ≤ 10 строк.** Дефектов нет — ответ «ок».
|
||||||
|
- Без похвал, пересказа, «а ещё можно», без предложений рефакторинга.
|
||||||
|
|
||||||
|
## Что ревьюить (ровно эти коммиты)
|
||||||
|
|
||||||
|
```
|
||||||
|
git --no-pager show 383f8ea c5a4499 ea75cac 8519ba0 9da9766
|
||||||
|
# или сразу:
|
||||||
|
git --no-pager diff 047d53a^..9da9766 -- provider/ TOOLS/
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
- `provider/internal/core/http.go` — 401 в `isRetryable`
|
||||||
|
- `provider/internal/core/modifier_compare.go` — `modifierDesiredEqualsLive`, `modifierValuesEqual`, `modifierCodeMap`
|
||||||
|
- `provider/internal/core/operation_run_bycode.go` — idempotency pre-check по live
|
||||||
|
- `provider/internal/core/instance_create.go` — `instanceUid` возвращается вместе с ошибкой
|
||||||
|
- `provider/internal/resources_core/nsxt_snat_resource.go`, `org_ip_allocation_resource.go` — `ImportState`, `ByIdempotent`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/instance.go` — partial state в `Create`
|
||||||
|
- `TOOLS/scripts/check_hardcoded_service_ids.sh` — расширение области/паттернов
|
||||||
|
|
||||||
|
## Вопросы (ответ — по 1 строке на каждый, дефект или «ок»)
|
||||||
|
|
||||||
|
1. **Q5 partial state.** `Create`: при `err != nil && id != ""` вызывается `resp.State.Set` с планом,
|
||||||
|
затем `AddError`. Не нарушает ли это контракт framework (допустим ли partial state при ошибке)?
|
||||||
|
2. **Live pre-check.** `modifierDesiredEqualsLive` ищет live-значение через `lookupLiveParam`
|
||||||
|
(ключи `Code`/`SvcOperationCfsParam`/`Name`/`Label`). Достаточно ли этого, чтобы не пропустить
|
||||||
|
нужный `modify`?
|
||||||
|
3. **`isRetryable` + 401.** Не создаёт ли ретрай 401 ложных повторов там, где это опасно (GET-пути)?
|
||||||
|
4. **`ImportState`.** Запись Required-атрибутов в `ImportState` не конфликтует ли с последующим `Read`?
|
||||||
|
5. Что-то ещё критичное в этих диффах — 1 строка.
|
||||||
|
|
||||||
|
## Границы доступа
|
||||||
|
|
||||||
|
Разрешено: `provider/internal/**`, `TOOLS/**`, `git show`/`git diff` по перечисленным коммитам.
|
||||||
|
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, стенды. Нужен файл вне списка → вопрос.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Промпт для Opus 4.8 — анализ архитектуры провайдера + ресурсов-модификаторов
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
|
||||||
|
**Дата:** 2026-09-30. **Стенд-источник фактов:** `dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роль и режим работы
|
||||||
|
|
||||||
|
- Ты — архитектор/ревьюер универсального Terraform-провайдера.
|
||||||
|
- Работаем **в диалоге**: я (агент) передаю твои вопросы пользователю и возвращаю его ответы.
|
||||||
|
- Вопрос задавай ТОЛЬКО если без него ответить нельзя. Максимум 1–2 вопроса за раз, предельно коротко.
|
||||||
|
- Не догадываться. Нет данных — вопрос, а не допущение.
|
||||||
|
- Область не расширять: отвечать ровно на поставленную задачу.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Проанализировать архитектуру универсального провайдера Nubes и встроенный в неё слой
|
||||||
|
**ресурсов-модификаторов** — отдельных ресурсов, которые вызывают операцию `modify`
|
||||||
|
у родительского инстанса (когда нужного параметра нет в операции `create`).
|
||||||
|
|
||||||
|
Оценить:
|
||||||
|
|
||||||
|
1. Как устроена архитектура по слоям и как течёт поток данных (API → YAML → код → API).
|
||||||
|
2. Соответствие заявленной спеки (`TOOLS/ARCHITECTURE.md`) фактической реализации — все
|
||||||
|
расхождения, с указанием `файл:строка`.
|
||||||
|
3. Корректность жизненного цикла модификаторов: `Create` / `Read` / `Update` / `Delete`,
|
||||||
|
идемпотентность, дрейф (drift), поведение при `replace` / повторном `apply`, импорт.
|
||||||
|
4. Место модификаторов в универсальном ядре: где и как нарушается принцип
|
||||||
|
«ядро универсально, доменные знания — только данные». Насколько оправдано текущее
|
||||||
|
решение (ручные Go-ресурсы, зарегистрированные поверх генерируемых).
|
||||||
|
5. Границы ответственности: что модификатор делает сам, что отдаёт платформе; как
|
||||||
|
выражается обратная операция (откат при `destroy`, значение «выключено»).
|
||||||
|
6. Риски и топ-проблемы — по убыванию критичности, каждое с `файл:строка`.
|
||||||
|
|
||||||
|
## Границы доступа (ЖЁСТКО)
|
||||||
|
|
||||||
|
Читать РАЗРЕШЕНО **только** файлы из списка ниже. Всё остальное — ЗАПРЕЩЕНО, в частности:
|
||||||
|
|
||||||
|
- `HISTORY/**`, `NOTES/**`, `TMP/**`, `HAR/**`, `docs/**`, `site/**`, `site_test/**`,
|
||||||
|
`apps/**`, `charts/**`, `FIYR_MGU/**`, `gateway/**`, `scripts/**`, `secrets/**`,
|
||||||
|
`tfflaskcrud/**`, `tfluceecrud/**`, `tfnodejscrud/**`, `DEV_STAND/**` (кроме одного файла
|
||||||
|
из списка), `TEST_STAND/**`, `PROD_STAND/**`, `provider/artifacts/**`, `provider/bin/**`;
|
||||||
|
- история git (`git log`, `git show`, `git diff` с коммитами), коммиты, теги, ветки;
|
||||||
|
- любой файл репозитория, которого нет в списке ниже.
|
||||||
|
|
||||||
|
Нужен файл вне списка → НЕ читать, а задать мне вопрос.
|
||||||
|
|
||||||
|
## Файлы к изучению (исчерпывающий список)
|
||||||
|
|
||||||
|
### Группа 1. Архитектура (спека)
|
||||||
|
- `TOOLS/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
### Группа 2. Ресурсы-модификаторы и их регистрация
|
||||||
|
- `provider/internal/provider/provider.go`
|
||||||
|
- `provider/internal/resources_core/org_ip_allocation_resource.go`
|
||||||
|
- `provider/internal/resources_core/nsxt_snat_resource.go`
|
||||||
|
- `provider/internal/resources_core/org_ip_allocation_test.go`
|
||||||
|
|
||||||
|
### Группа 3. Рантайм-зависимости модификаторов (ядро)
|
||||||
|
- `provider/internal/core/client.go`
|
||||||
|
- `provider/internal/core/operation_run_bycode.go`
|
||||||
|
- `provider/internal/core/instance_params.go`
|
||||||
|
- `provider/internal/core/refsvc_resolve.go`
|
||||||
|
- `provider/internal/resources_core/resource_diagnostics.go`
|
||||||
|
- `provider/internal/resources_core/crud.go`
|
||||||
|
|
||||||
|
### Группа 4. Генератор (как рождается «универсальная» часть)
|
||||||
|
- `TOOLS/yaml-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/loader/loader.go`
|
||||||
|
|
||||||
|
### Группа 5. Факты API (спеки операций/параметров)
|
||||||
|
- `generated/dev/resources_yaml/19_vc_org.yaml`
|
||||||
|
- `generated/dev/resources_yaml/22_vc_nsxt.yaml`
|
||||||
|
|
||||||
|
### Группа 6. Применение модификаторов (композиция цепочки)
|
||||||
|
- `DEV_STAND/FullPipe/modifiers.tf`
|
||||||
|
|
||||||
|
### Группа 7. Только если без них нельзя ответить (иначе не открывать)
|
||||||
|
- `provider/internal/resources_core/state_refresh.go`
|
||||||
|
- `provider/internal/resources_core/params_compare.go`
|
||||||
|
- `provider/internal/resources_core/helpers.go`
|
||||||
|
- `TOOLS/resource-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/instance.go`
|
||||||
|
|
||||||
|
## Что нужно на выходе
|
||||||
|
|
||||||
|
Структурированный отчёт, разделы строго в этом порядке:
|
||||||
|
|
||||||
|
1. **Устройство архитектуры** — слои и поток данных, 5–10 строк.
|
||||||
|
2. **Спека ↔ код** — список расхождений `ARCHITECTURE.md` с реализацией (`файл:строка`).
|
||||||
|
3. **Дефекты и риски модификаторов** — по убыванию критичности. По каждому:
|
||||||
|
суть → место (`файл:строка`) → последствие → предлагаемое направление (одна строка).
|
||||||
|
4. **Открытые вопросы** — списком, если есть.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
- Максимально сжато: тезисы, без вступлений, воды и «лирики».
|
||||||
|
- Каждое утверждение проверяемо: ссылка `файл:строка`.
|
||||||
|
- Код — только короткие фрагменты, и лишь если без них тезис не понятен.
|
||||||
|
- Никаких «а ещё могу», никаких предложений расширить работу.
|
||||||
|
|
||||||
|
## Правило «стоп»
|
||||||
|
|
||||||
|
Если задание неоднозначно или данных не хватает — **остановиться и задать один короткий
|
||||||
|
вопрос**. Не достраивать смысл и не действовать по догадке.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Промпт для Opus 4.8 — РАУНД 2: замечания к отчёту (2026-09-30)
|
||||||
|
|
||||||
|
> Продолжение диалога. Раунд 1 — `prompt_for_opus_provider_architecture_modifiers.md`.
|
||||||
|
> Формат ответа тот же: сжато, тезисы, `файл:строка`, без догадок. Границы доступа — как в раунде 1
|
||||||
|
> (плюс список из §4 ниже). `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, git-история — по-прежнему ЗАПРЕЩЕНЫ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Зачтено (переделывать НЕ надо)
|
||||||
|
|
||||||
|
`S1`, `S5`, `R1`, `R4` — приняты. Не повторяй их в ответе.
|
||||||
|
|
||||||
|
## 2. Замечания — обязательны к отработке
|
||||||
|
|
||||||
|
**M1. Номера строк не сходятся.**
|
||||||
|
Ты дал `provider.go:199-204` для регистрации модификаторов. По моему чтению файла (начиная со
|
||||||
|
строки 180) функция `Resources()` находится примерно на строке 182, и регистрация идёт сразу за ней —
|
||||||
|
твои 199–204 не сходятся. Требование задания — «каждое утверждение проверяемо».
|
||||||
|
Действие: перепроверь **каждую** ссылку `файл:строка` в отчёте и дай точные номера; где не сверял —
|
||||||
|
пометь «не сверено». Без этого отчёт не принимается.
|
||||||
|
|
||||||
|
**M2. `R2` — нарушено правило «без догадок».**
|
||||||
|
Ты утверждаешь, что `check_hardcoded_service_ids.sh` «это не ловит», но этот скрипт **не читал**
|
||||||
|
(его не было в разрешённом списке). Это догадка, а не факт.
|
||||||
|
Действие: скрипт теперь разрешён (см. §4). Либо приведи факт из его кода, либо переформулируй в вопрос.
|
||||||
|
|
||||||
|
**M3. `S2`/`S4` — проверь основание, иначе они натянуты.**
|
||||||
|
Правила спеки («No manual edits to **generated** Go code», «Service-specific Go code is fully
|
||||||
|
**generated** from YAML») писаны про генерируемый код. Ресурсы в `resources_core` — ручные, не
|
||||||
|
генерируемые. Плюс `S4` («modify всегда через WithDefaults») относится к `Update` инстанс-ресурсов,
|
||||||
|
а не к отдельному ресурсу-модификатору.
|
||||||
|
Действие: для каждого из S2/S4 дай **текстуальную опору из спеки** (`TOOLS/ARCHITECTURE.md:строка`)
|
||||||
|
и переформулируй: это **пробел спеки** (нет категории для ручных оверлеев) или **нарушение**? Если
|
||||||
|
опоры нет — пункт снять.
|
||||||
|
|
||||||
|
**M4. `R5` — обоснуй приоритет или понизь.**
|
||||||
|
Ты сам пишешь «корректности не нарушает», но ставишь R5 выше R6.
|
||||||
|
Действие: назови шкалу ранжирования (например: вероятность × последствие × обнаружимость) и
|
||||||
|
пересчитай порядок; либо понизь R5.
|
||||||
|
|
||||||
|
**M5. Главный пробел: устойчивость `Read` и вечный diff.**
|
||||||
|
Для Terraform это опаснее всего, а разобрано тоньше всего (только R6/импорт).
|
||||||
|
Действие: разбери построчно, как `Read` модификатора формирует `vip_configure` / `ip_space_name`
|
||||||
|
из live-состояния и совпадёт ли это с тем, что вернёт `jsonencode` из конфига:
|
||||||
|
- порядок ключей JSON;
|
||||||
|
- тип `count` (строка vs число);
|
||||||
|
- снятие `null` и пустых значений;
|
||||||
|
- что видит `plan` после `Read` для `keep_on_destroy`.
|
||||||
|
Отдельно: может ли `Update` инстанс-ресурса (`nubes_vc_org` / `nubes_vc_nsxt`) вклиниться между
|
||||||
|
apply-ами модификатора и вернуть поле назад (пересечение — это R1, но нужен сценарий во времени).
|
||||||
|
Если считаешь, что глубокий разбор здесь не нужен — обоснуй одной строкой, почему.
|
||||||
|
|
||||||
|
## 3. Что упущено — доработать
|
||||||
|
|
||||||
|
**U1. Файла `modifiers.yaml` в репозитории НЕТ.**
|
||||||
|
Есть только упоминания-комментарии (в `TOOLS/yaml-generator/main.go` и док-заметках). Это не
|
||||||
|
«параллельный слой», а **задокументированное, но не созданное** наложение.
|
||||||
|
Действие: переформулируй свой открытый вопрос №1 с учётом этого факта.
|
||||||
|
|
||||||
|
**U2. Рассинхронизация словаря жизненного цикла.**
|
||||||
|
У генерируемых ресурсов — `suspend_on_destroy`, у модификаторов — `keep_on_destroy`. Два имени
|
||||||
|
для одного смысла, живут в разных ветках кода.
|
||||||
|
Действие: оцени как архитектурный дефект (единый словарь/контракт жизненного цикла), с `файл:строка`.
|
||||||
|
|
||||||
|
**U3. Корневая причина «ручных» модификаторов.**
|
||||||
|
Генератор умеет `kind: modifier`, но доменное знание (какая операция, имя, inverse) некуда положить —
|
||||||
|
оверлей не создан.
|
||||||
|
Действие: сформулируй вывод по вариантам (оверлей-данные vs ручной код vs иное) — одной строкой на вариант.
|
||||||
|
|
||||||
|
## 4. Дополнительно РАЗРЕШЕНО читать (только это, сверх раунда 1)
|
||||||
|
|
||||||
|
- `TOOLS/scripts/check_hardcoded_service_ids.sh`
|
||||||
|
- `TOOLS/resource-generator/main.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/instance.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/modifier.go`
|
||||||
|
- `provider/internal/resources_core/state_refresh.go`
|
||||||
|
- `provider/internal/resources_core/helpers.go`
|
||||||
|
- `provider/internal/resources_core/params_compare.go`
|
||||||
|
|
||||||
|
Всё остальное — по-прежнему нельзя. Нужен файл вне списка → вопрос мне, не чтение.
|
||||||
|
|
||||||
|
## 5. Формат ответа
|
||||||
|
|
||||||
|
- **Только дельта**: что исправляешь, что снимаешь, чем обосновываешь; новые находки по M5/U2.
|
||||||
|
- Полный отчёт заново не переписывать.
|
||||||
|
- Как в раунде 1: тезисы, `файл:строка`, без «лирики» и без «а ещё могу».
|
||||||
|
|
||||||
|
## 6. Разрешение копать глубже
|
||||||
|
|
||||||
|
Если для честного ответа нужно копнуть глубже (в т.ч. по файлам вне §4) — **скажи прямо**, каких
|
||||||
|
файлов не хватает, и задай вопрос. Если глубина не нужна — так и напиши по конкретному пункту.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Промпт для Opus 4.8 — РАУНД 3: сам провайдер (устойчивость и корректность)
|
||||||
|
|
||||||
|
> Продолжение диалога. Раунды 1–2 — `HISTORY/OPUS/2026-09-30_provider_architecture_modifiers_dialog.md`.
|
||||||
|
> Режим и формат ответа — как раньше: сжато, тезисы, `файл:строка`, без догадок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вводная (смена приоритета)
|
||||||
|
|
||||||
|
Модификаторы (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`) — новая, возможно сложная, но
|
||||||
|
**НЕБОЛЬШАЯ** часть сервиса. Чрезмерный упор на них не нужен.
|
||||||
|
|
||||||
|
**Главное — САМ провайдер: его устойчивость и корректность.**
|
||||||
|
|
||||||
|
- `M6` (доступ к двум файлам для доразбора R1) — **снять**; углубление по модификаторам больше не требуется.
|
||||||
|
- Прошлые находки (`S1`, `S2`, `R2`–`R6`, `U1`–`U3`) **не повторять**.
|
||||||
|
|
||||||
|
## Бюджет (жёстко — экономим токены)
|
||||||
|
|
||||||
|
- Прочитать **не более 10 файлов** суммарно. Ранее прочитанные повторно не открывать.
|
||||||
|
- Ответ — **не более 5 находок**, каждая **не более 3 строк**.
|
||||||
|
- Никаких вступлений, повторения прошлых отчётов, «а ещё могу».
|
||||||
|
|
||||||
|
## Что анализировать (провайдер целиком)
|
||||||
|
|
||||||
|
1. **Жизненный цикл инстанс-ресурса:** `create` / adopt / `suspend` / resume / `modify` / redeploy /
|
||||||
|
`delete` и повторный `apply` — где теряется корректность состояния.
|
||||||
|
2. **Досылка и нормализация параметров** (`WithDefaults`, zero-value fallback, дефолты `map-fixed`,
|
||||||
|
регистр UUID): где риск затереть значение или получить ложный diff.
|
||||||
|
3. **`Read` / refresh:** устойчив ли state у генерируемых ресурсов; где возможен вечный diff.
|
||||||
|
4. **Устойчивость ядра:** ретраи и ошибки, `LockInstance`, идемпотентность, поведение при дубликатах
|
||||||
|
и параллельных `apply`.
|
||||||
|
5. **Генератор → код:** какие классы дефектов порождает шаблон.
|
||||||
|
|
||||||
|
## Границы доступа
|
||||||
|
|
||||||
|
Разрешено читать (только это):
|
||||||
|
|
||||||
|
- `provider/internal/core/**`
|
||||||
|
- `provider/internal/resources_core/**`
|
||||||
|
- `provider/internal/provider/provider.go`
|
||||||
|
- `TOOLS/resource-generator/internal/templates/**`
|
||||||
|
- `TOOLS/resource-generator/internal/params/params.go`
|
||||||
|
- `TOOLS/resource-generator/internal/helpers/helpers.go`
|
||||||
|
- `TOOLS/resource-generator/internal/loader/loader.go`
|
||||||
|
- `TOOLS/resource-generator/internal/writers/writers.go`
|
||||||
|
|
||||||
|
Запрещено: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, `TMP/**`, `DEV_STAND/**`, `TEST_STAND/**`,
|
||||||
|
`PROD_STAND/**`, git-история. Нужен файл вне списка → вопрос мне, не чтение.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
- **ТОП-5 находок** по убыванию критичности: суть → место (`файл:строка`) → последствие (≤ 3 строк).
|
||||||
|
- Затем **одна строка**: что из этого проверяемо только замером (прогон `apply`/`plan`), а не чтением.
|
||||||
|
- Находок меньше пяти — столько, сколько есть. Водой не добивать.
|
||||||
|
|
||||||
|
## Стоп-правило
|
||||||
|
|
||||||
|
Не хватает файла или данных — один короткий вопрос. Не догадываться.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Opus — раунд 4: короткие решения (2026-09-30)
|
||||||
|
|
||||||
|
**Репо:** `/home/naeel/TF/tf_provider` — `terraform-provider-nubes` (Go, Terraform Plugin Framework).
|
||||||
|
|
||||||
|
## Формат ответа (ЖЁСТКО)
|
||||||
|
|
||||||
|
- На каждый вопрос — **1–3 строки**, с `файл:строка`.
|
||||||
|
- **Весь ответ ≤ 25 строк.** Без вступлений, пересказа, «а ещё могу», без повторения вопросов.
|
||||||
|
- Не хватает данных — одна короткая строка-вопрос, не догадка.
|
||||||
|
|
||||||
|
## Уже сделано — не обсуждать
|
||||||
|
|
||||||
|
401 в `isRetryable`; страж хардкодов покрыл `provider/`; idempotency pre-check переведён на live
|
||||||
|
(`core/modifier_compare.go`); `ImportState` модификаторов заполняет Required.
|
||||||
|
|
||||||
|
## Вопросы
|
||||||
|
|
||||||
|
**Q1. Retry POST.** `core/http.go:87-108` ретраит только GET. Слепой retry создающего
|
||||||
|
`POST /instanceOperations` рискует дубликатом операции. Как правильно: (а) не ретраить +
|
||||||
|
задокументировать; (б) ретраить только безопасный POST (`validate-cfs`); (в) иное?
|
||||||
|
|
||||||
|
**Q2. Осиротевшая операция.** `core/operation_run.go:150`, `core/operation_run_bycode.go:108`: при
|
||||||
|
сбое `instanceLiveParams` уже созданная операция остаётся невыполненной. Продолжение с fallback
|
||||||
|
вернёт reset-баг (защита поставлена сознательно). Есть ли безопасный третий путь (cancel/delete
|
||||||
|
операции через API) или оставить как есть? Нужен API-метод — назови его.
|
||||||
|
|
||||||
|
**Q3. Zero-value по подстроке имени.** `core/params.go:47-59`: при пустом `dataType` тип угадывается
|
||||||
|
по `name/code/label`. Пути применения: `instance_create.go:105-113` (required без значения), досылка
|
||||||
|
modify. Менять (убрать угадывание) или оставить?
|
||||||
|
|
||||||
|
**Q4. Словарь жизненного цикла.** Три несогласованных контракта: `suspend_on_destroy`/`keep_on_destroy`
|
||||||
|
(генерируемые ресурсы), `delete_strategy` (генерируемые модификаторы), `keep_on_destroy` (ручные).
|
||||||
|
Какой единый контракт зафиксировать в `TOOLS/ARCHITECTURE.md`?
|
||||||
|
|
||||||
|
**Q5. Что упущено.** Назови **один** самый критичный для устойчивости/корректности **ядра** дефект,
|
||||||
|
не упомянутый выше: 1 строка — суть → `файл:строка`.
|
||||||
|
|
||||||
|
## Границы доступа
|
||||||
|
|
||||||
|
Читать: `provider/internal/**`, `TOOLS/ARCHITECTURE.md`,
|
||||||
|
`TOOLS/resource-generator/internal/{templates,loader,params,helpers}/**`.
|
||||||
|
|
||||||
|
Не читать: `HISTORY/**`, `NOTES/**`, `docs/**`, `HAR/**`, стенды, git-историю. Нужен файл вне списка → вопрос.
|
||||||
@@ -145,6 +145,27 @@ go build -o terraform-provider-nubes
|
|||||||
- Генератор может создавать «разбитые» snake_case для CamelCase (например `resource_c_p_u`).
|
- Генератор может создавать «разбитые» snake_case для CamelCase (например `resource_c_p_u`).
|
||||||
- Это ожидаемо, но если критично — нужен отдельный маппинг (по согласованию).
|
- Это ожидаемо, но если критично — нужен отдельный маппинг (по согласованию).
|
||||||
|
|
||||||
|
### 6.5 Регистр UUID в JSON-параметрах (map-fixed) — ОБЯЗАТЕЛЬНО ЗНАТЬ
|
||||||
|
|
||||||
|
- Платформа хранит UUID в lowercase, но **сравнивает регистр при create**. Ресурс
|
||||||
|
`nubes_vc_nsxt` отдаёт `id` в UPPERCASE → `startupConfiguration.vdcUid/nsxtUid`
|
||||||
|
в верхнем регистре → ошибка «Edge не развёрнут в указанном vDC».
|
||||||
|
- **Нормализация нужна в ДВУХ разных местах, и они не взаимозаменяемы:**
|
||||||
|
1. **Сравнение** (план vs state, adopt/suspend/resume, modifier-compare, диагностика) —
|
||||||
|
`jsonutil.LowercaseUUIDsInText` внутри `JSONStringsEquivalent`, `JsonNormalize()`,
|
||||||
|
`ParamsMatchForResume`, `normalizeCompareValue`.
|
||||||
|
2. **Отправка в API** — единственная точка: `resources_core.BuildJSON`
|
||||||
|
(`provider/internal/resources_core/helpers.go`), её вызывает генератор
|
||||||
|
(`NestedJSONExpr` в `templates/instance.go`: Create / Modify / Redeploy).
|
||||||
|
С 30.09 результат оборачивается в `LowercaseUUIDsInText(...)`.
|
||||||
|
- ⛔ **Не «лечить» это в HCL** (`lower(...)` в конфиге стенда) — это костыль, который
|
||||||
|
существовал только потому, что путь отправки не нормализовал UUID. Он ломался при
|
||||||
|
работе из-под Windows на провайдере `2.0.23`.
|
||||||
|
- Детали, аудит всех мест и границы применимости: `docs/60_strategy/terraform_case_sensitivity_fix.md`
|
||||||
|
(§10 — регистр при сравнении, §11 — регистр при отправке).
|
||||||
|
- Не покрыто: скалярные UUID в `normalizeUniversalValueV6` (дефолты create / досылка modify)
|
||||||
|
и валидация ref-параметров внутри JSON при adopt — см. §11 и HISTORY/2026-09-30.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7) Проверенная цепочка (dummy)
|
## 7) Проверенная цепочка (dummy)
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# CHAT RESUME — добавляем ВМ в пайплайн (состояние на 2026-09-27)
|
||||||
|
|
||||||
|
> Цель файла: новый чат стартует **без уточняющих вопросов** — здесь всё, что уже сделано,
|
||||||
|
> что где лежит, что проверено на живых стендах и что решать по ВМ.
|
||||||
|
> Репозиторий: `/home/naeel/TF/tf_provider`, ветка `master`.
|
||||||
|
> Remote: `https://gitea.services.ngcloud.ru/terraform/tf_provider.git`.
|
||||||
|
> Репозиторий примеров: `tf_examples/` (отдельный git) → `https://gitea.services.ngcloud.ru/Nail/tf_examples.git`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Где что лежит (точки входа)
|
||||||
|
|
||||||
|
| Тема | Путь |
|
||||||
|
|---|---|
|
||||||
|
| Предыдущее резюме (freeze/adopt, состояние Штурвала) | `NOTES/40_chat_summaries/CHAT_RESUME_2026-09-25_shturval_freeze_state.md` |
|
||||||
|
| Разбор диагностики + дизайн «заморозки» | `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` |
|
||||||
|
| Зависимости сервисов DEV + чек-листы Штурвал/ВМ | `docs/60_strategy/dev_stand_service_dependencies.md` |
|
||||||
|
| Диаграмма зависимостей + генератор | `docs/diagrams/README.md`, `docs/diagrams/infra_services_flow.mmd`, `render_infra_diagram.py` |
|
||||||
|
| Страница для пользователя: поведение провайдера | `docs/30_registry/guides/provider-behavior.md` |
|
||||||
|
| Страница-инструкция пайплайна | `docs/curated/pipeline/vdc_edge_ip_snat.md` |
|
||||||
|
| Пример в репе примеров | `tf_examples/fullpipe_chain/` (в т.ч. `shturval.tf`) |
|
||||||
|
| Регламент публикации доков | `DOCS_PIPELINE/README.md`, `TOOLS/scripts/04_build_and_publish_docs.sh`, `scripts/publish-docs.sh` |
|
||||||
|
| Инструкции «как сделать руками» | `HOW_TO/README.md` (индекс), `HOW_TO/howitwasdone.md`, `HOW_TO/DEVOPS_BUILD_PIPELINE.md` |
|
||||||
|
| Версии провайдеров по стендам (источник правды) | `VERSIONS.md` |
|
||||||
|
| Память (репозиторий) | `/memories/repo/shturval-destroy-freeze.md`, `/memories/repo/registry-versions.md` |
|
||||||
|
| **ВМ: старые материалы** | `docs/20_discovery/vm_service.md`, `vm-ui-verification.md`, `vm_provisioning_failures.md`, `vapp-service.md`, `docs/40_analysis/vm_creation_failure_analysis.md`, `docs/50_history/04_vm_hang_fix_and_500_error.md`, `docs/50_history/12_vm_resource_hardening.md`, `docs/50_history/23_vapp_uid_inconsistency_displayname_resolve_bug.md` |
|
||||||
|
| **ВМ: старое резюме (ЛЕГАСИ)** | `NOTES/40_chat_summaries/CHAT_RESUME_PLAN_VM.md` — другой репозиторий (`/home/naeel/terra`), ресурс `nubes_vm`, пути неактуальны; только как история |
|
||||||
|
| **ВМ: спеки услуг** | `generated/dev/resources_yaml/26_vapp.yaml` (услуга 26, vApp), `generated/dev/resources_yaml/28_vc_vm_v3.yaml` (услуга 28, ВМ) |
|
||||||
|
| Референс от платформы (Cloud Director) | каталог `!/` в корне: `vmware_org.tf`, `vdc.tf`, `network.tf.tmpl` (цепочка ipSpace → providerGateway → providerVdc) |
|
||||||
|
| Стенды | `DEV_STAND/FullPipe/` (проверенный, аккаунт `tazet@narod.ru`), `DEV_STAND/FPipeGmail/` (аккаунт `tazetdinovn@gmail.com`, организация `kontra`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что уже сделано (только факты, по коммитам)
|
||||||
|
|
||||||
|
**21–24.09 — ресурсы-модификаторы и релизы 2.0.18 → 2.0.23**
|
||||||
|
- `nubes_vc_org_ip_allocation` (modify `vIPConfigure`) и `nubes_vc_nsxt_snat` (modify `ipSpaceName`, обратный `no-needed`) — генератор + код + тесты (`22cf259`, `80d82a1`, `73a7459`, `4b497e6`, `ba6c4f5`, `1236c59`).
|
||||||
|
- Ресурс аллокации принимает **имя** организации (резолв в UUID) — `5bd197f`.
|
||||||
|
- Убран plan-modifier, менявший пользовательское значение (Terraform: planned value must match config) — `807dfde`.
|
||||||
|
|
||||||
|
**24.09 — Штурвал: adopt + «заморозка»**
|
||||||
|
- Всё про Штурвал собрано в один файл `shturval.tf` (`b8adeb6`); правильный сервис — **150**, не 148 (`1a90c77`, `d98f603`).
|
||||||
|
- `worker_configuration` — JSON с **camelCase** (`groupName`, `sizingPolicy`, `sizingDisk`, `labelDeck`) — snake_case валит платформу (`72d5771`).
|
||||||
|
- `adopt_existing_on_create = true` для кластера (`57abb7b`).
|
||||||
|
- Реализован **третий режим destroy** `keep_on_destroy` → `state_only` для всех instance-ресурсов + предупреждения в Delete (`22c6c83`), релиз **2.0.22** (`c29df21`).
|
||||||
|
- Конфиг стенда переведён в «заморозку» (`40aef87`): квота IP/SNAT/эдж — `keep_on_destroy = true`, кластер — `suspend_on_destroy = true`, у vDC — `suspend` + adopt.
|
||||||
|
- Живая проверка `destroy` = заморозка: `5 destroyed`, кластер и vDC → `suspended`, эдж/SNAT/квота не тронуты (`c9d7345`).
|
||||||
|
|
||||||
|
**24.09 — фикс регистра UUID, релиз 2.0.23**
|
||||||
|
- Adopt suspended-инстанса падал на регистре UUID внутри JSON (`startupConfiguration`: план `2c37fed1-…` против живого `2C37FED1-…`). Добавлены `jsonutil.LowercaseUUIDsInText`, нормализация строк внутри JSON, `JsonNormalize()` приводит UUID-подстроки к lowercase; покрыто тестами (`621280a`), релиз **2.0.23** (`208d97e`, `03fff05`).
|
||||||
|
|
||||||
|
**24–25.09 — документация**
|
||||||
|
- Новая страница «Как работает провайдер Nubes: поведение и отличия от канонического Terraform» (`aaf87d9`, читаемость `c808a3b`): §1 суть, §2 ресурсы, §3 отличия от Terraform, §4 CRUD, §5 delete/suspend/state_only, **§6 конкретный пайплайн стенда vDC → Edge → IP → SNAT → Штурвал**, §7 регистр UUID, §8 чек-лист DevOps, §9 FAQ, §10 ссылки.
|
||||||
|
- Страница пайплайна переписана (`f8d6494`): требования, чек-лист услуги 150 (ALB + AVI ≥ 3, внешних IP ≥ 3), проверка результата (адреса API/Ingress), таблица «заморозки» при destroy, полное удаление, состав файлов.
|
||||||
|
- `provider-behavior.md` добавлена в навигацию `mkdocs.yml` («Руководства → Как работает провайдер (отличия от Terraform)») и связана ссылкой со страницей пайплайна (`8d25de9`).
|
||||||
|
|
||||||
|
**25.09 — стенд второго пользователя `FPipeGmail`**
|
||||||
|
- Организация `kontra` (аккаунт `tazetdinovn@gmail.com`, компания `naeel_test`, ClientID `WZ03709`), токен — `secrets/dev.token`; в `.tfvars` было закомментировано устаревшее `kontora` (`e20c22e`).
|
||||||
|
- Имена Штурвала сделаны свои: `shturval-dev1` / `shturval-dev-01` — занятое имя кластера (даже созданное другим пользователем) платформа не принимает (`e72eb75`).
|
||||||
|
- Проверено на 25.09: `terraform init` (провайдер 2.0.23), `validate` — ок, `plan` = `5 to add, 0 change, 0 destroy`. **apply не запускался** (запускает только пользователь).
|
||||||
|
|
||||||
|
**25.09 — репозиторий примеров `tf_examples`**
|
||||||
|
- `fullpipe_chain`: добавлен `shturval.tf` (переменные + locals + ресурс, самодостаточно), пин провайдера `2.0.21 → 2.0.23`, флаги «заморозки» как в рабочем стенде (эдж/SNAT/квота `keep_on_destroy = true`, vDC `suspend` + adopt), выводы Штурвала, блок параметров в `terraform.tfvars.example`, переписаны README примера и корневой (`21618a7`); добавлено предупреждение про уникальность имени кластера (`435a685`).
|
||||||
|
- Всё запушено: последний коммит примеров — `435a685`.
|
||||||
|
|
||||||
|
**26.09 (не в этой сессии, но уже в `master` — чтобы не переспрашивать)**
|
||||||
|
- Диаграмма зависимостей сервисов: `docs/diagrams/*` (`ea725bd`, `ff38352`, `6791e8f`), генератор `render_infra_diagram.py`, README с инструкцией перегенерации.
|
||||||
|
- Сверка цепочек зависимостей с YAML + чек-листы Штурвала и ВМ: `docs/60_strategy/dev_stand_service_dependencies.md` (`dc837e8`), перенос артефактов диаграмм + гайд по созданию vApp (`a366f47`).
|
||||||
|
- Закоммичены бэкапы от 25.09: `TMP/backup_2026-09-25/**` (`4800958`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Текущее состояние (проверено 2026-09-27)
|
||||||
|
|
||||||
|
**Terraform**
|
||||||
|
- `DEV_STAND/FullPipe`: `terraform state list` — **пусто**.
|
||||||
|
- `DEV_STAND/FPipeGmail`: `terraform state list` — **пусто**; при этом конфиг валиден, `plan` = `5 to add` (по состоянию на 25.09).
|
||||||
|
- Terraform `1.9.5`; `go build ./...` и `go test ./...` — чисто (проверено 25.09).
|
||||||
|
|
||||||
|
**Облако, аккаунт `tazet@narod.ru` (токен `secrets/narodDEV.token`)**
|
||||||
|
|
||||||
|
| Услуга | Имя | UID | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 150 Штурвал | `shturval-dev` | `94627ff4-33a5-48f2-aca1-695741e0b6a2` | `suspended` |
|
||||||
|
| 21 vDC | `fullpipe-vdc` | `d0937335-276b-475b-baa4-d8e6d16bad51` | `suspended` |
|
||||||
|
| 22 Edge | `fullpipe-edge` | `2c37fed1-e8f8-4a84-8434-7851c7c8b5d6` | `running`, SNAT `internet-ipv4-v1` |
|
||||||
|
| 19 Организация | `organ` | `57eeacd1-dc7f-4a52-b903-7e5f7d3c1164` | `running`, `vIPConfigure` = `[{internet-ipv4-v1, count: 3}]` |
|
||||||
|
|
||||||
|
Всего в аккаунте 13 инстансов, в том числе: `dummy-5`/`dummy-6` (1), `External ip` и `vIp` (25, `running`), `A-record` (111, `running`), инстансы mgmt-кластера (21/22, `deleted`). Из 3 инстансов `fullpipe-edge` — 2 `deleted` (история), 1 `running` — не пугаться.
|
||||||
|
|
||||||
|
**Облако, аккаунт `tazetdinovn@gmail.com` (токен `secrets/dev.token`, срок до 2026-12-27)**
|
||||||
|
- Организация `kontra` (`df5ec5f2-5f5d-4c82-952c-3dffa91c61d3`, услуга 19): `running`, `isTrial`, создана 2026-09-24 08:20, `vIPConfigure` = `count: 3`, realm `sandbox.nubes.ru`.
|
||||||
|
- В этом аккаунте **уже созданы объекты стенда FPipeGmail**: `fullpipe-vdc` (21) — `suspended`, `fullpipe-edge` (22) — `running`, `shturval-dev1` (150) — `suspended` (локальный `state` при этом пуст → следующий `apply` усыновит их по имени и разморозит). Всего 12 инстансов, плюс `kontra` (19), S3-бакеты (13), `s3naeeldev` (12), `mariamdb` (115), `vc_v`/`vc_vdc-1`/`tedj` — `deleted`.
|
||||||
|
- ⛔ **Исправление ранее ложного наблюдения** (в первой версии этого резюме было «список отдаёт 0 инстансов»): это была **ошибка парсинга ответа с моей стороны** — API отдаёт список в ключе `results`, а не `instances`. Оба аккаунта отдают данные корректно (narod — 13, gmail — 12). Никакой аномалии платформы нет.
|
||||||
|
|
||||||
|
**Провайдер / реестр / доки**
|
||||||
|
- DEV в реестре: **`2.0.23`** (последняя), пины `2.0.23` в `DEV_STAND/FullPipe/versions.tf`, `DEV_STAND/FPipeGmail/versions.tf`, `tf_examples/fullpipe_chain/versions.tf`, `.terraform.lock.hcl` FullPipe.
|
||||||
|
- PROD `1.0.0`, TEST `3.0.0` (новая схема: prod `1.*`, dev `2.*`, test `3.*`).
|
||||||
|
- Доки dev опубликованы (`2.0.23`) и зеркало на ВМ обновлено; живые URL:
|
||||||
|
- https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/vdc_edge_ip_snat/ (в меню: «Проверенные примеры»)
|
||||||
|
- https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/30_registry/guides/provider-behavior/ (в меню: «Руководства»)
|
||||||
|
|
||||||
|
**Git**
|
||||||
|
- Основной репо: `master` = `origin/master` = `e72eb75`; примеры: `435a685` = `origin/master`.
|
||||||
|
- В истории уже лежат коммиты от 26.09 (диаграммы, чек-листы, бэкапы) — см. §1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что делать дальше: **добавляем ВМ в пайплайн** (главная цель нового чата)
|
||||||
|
|
||||||
|
**Целевая цепочка (текущая + ВМ):**
|
||||||
|
организация (руками в ЛК) → vDC (21) → Edge (22) → внешние IP (modify) → SNAT (modify) → **[vApp (26) → ВМ (28)]** → Штурвал (150)
|
||||||
|
|
||||||
|
**Подтверждённые факты (сверено с YAML, коммит `dc837e8`)**
|
||||||
|
- D-06: vApp (26) → Edge (22), параметр `nsxtUid` — vApp использует Edge для сети.
|
||||||
|
- D-07: vApp (26) → vDC (21), параметр `vdcUid`.
|
||||||
|
- D-08: ВМ (28) → vApp (26), параметр `vappUid` — ВМ размещается в vApp.
|
||||||
|
- ВМ можно создавать в **уже существующих** vApp / vDC / Edge — ссылками `vappUid` / `vdcUid` / `nsxtUid`.
|
||||||
|
- Ресурсы (CPU/RAM/Storage) ВМ: `vmCpu`, `vmRam`, `vmDisk` (доп. диск); квоты выделяются на уровне vDC (21) и услуги ВМ (28).
|
||||||
|
- Чек-лист создания ВМ (28) по create-параметрам, обязательные: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `accessPortList`, `imageVm`, `userLogin`, `userPublicKey`, `ipSpaceName`. Полный текст — `docs/60_strategy/dev_stand_service_dependencies.md` (§«Чек-лист создания ВМ (28)»).
|
||||||
|
- Спеки ресурсов: `generated/dev/resources_yaml/26_vapp.yaml`, `28_vc_vm_v3.yaml` (**руками не править** — перезапишет `01_generate_yamls.sh`).
|
||||||
|
- Имена ресурсов в провайдере: `nubes_vc_vapp`, `nubes_vc_vm_v3` (услуга 148 «Менеджмент Kubernetes Штурвал» — **не** то, что нужно; для кластера только 150).
|
||||||
|
|
||||||
|
**Известные проблемы ВМ (читать до работ)**
|
||||||
|
- `docs/20_discovery/vm_provisioning_failures.md` — падения на создании, разбор payload Terraform vs ручного запроса.
|
||||||
|
- `docs/40_analysis/vm_creation_failure_analysis.md`, `docs/50_history/04_vm_hang_fix_and_500_error.md` — зависание на FW и 500 («String[] → GUID»).
|
||||||
|
- `docs/50_history/12_vm_resource_hardening.md` — правки по надёжности ресурса ВМ.
|
||||||
|
- `docs/50_history/23_vapp_uid_inconsistency_displayname_resolve_bug.md` — резолв по имени мог вернуть **удалённый** инстанс (лечится фильтром `isDeleted=false`); проверить, что фикс жив в 2.0.23.
|
||||||
|
|
||||||
|
**Что уже выяснено фактами (проверено 27.09 — чтобы не гадать)**
|
||||||
|
- vApp (26), обязательные параметры create: `vappName`, `vdcUid`, `nsxtUid`. Операции: `create`, `delete`, `suspend`, `resume`; в YAML — `suspend_on_destroy_default: true`. Из инструкции услуги: `delete` требует **предварительного `suspend`**, полное удаление — через 14 дней. Отдельных параметров сети/подсети/gateway у vApp нет — сеть даёт Edge (`nsxtUid`).
|
||||||
|
- ВМ (28), обязательные параметры create: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `ipSpaceName`, `imageVm`, `userLogin`, `userPublicKey`, `accessPortList` (`vmDisk` — дополнительный диск, не обязателен). Операции: `create`, `delete`, `modify`, `reconcile`, `redeploy`, `resume`, `suspend`; в YAML — `suspend_on_destroy_default: true`. В modify-наборе есть параметр `sameSnat` (разобраться, что даёт).
|
||||||
|
- `ipSpaceName` у ВМ — «Public IP setting», допускает значение `no-needed` (без публичного адреса). Если ВМ нужен внешний IP, квоту организации придётся увеличивать: сейчас `count = 3` и все три держит кластер Штурвала, а `suspend` адреса не освобождает.
|
||||||
|
- Соответствие кодов и ID параметров ВМ (`docs/20_discovery/vm_service.md`): `vappUid` 407, `vmName` 408, `vmCpu` 409/493, `vmRam` 410/494, `vmDisk` 411/495, `ipSpaceName` 412/496, `accessIpList` 413/497, `imageVm` 414, `cloudInit` 415, `userLogin` 416, `userPublicKey` 417, `accessPortList` 448/498, `needAddZabbixTemplate` 449/499.
|
||||||
|
- **Список образов (`imageVm`) в репозитории отсутствует**: в документации зафиксирован только пример `Ubuntu_22-20G`; HAR с созданием ВМ (`vmOK.har`, `vmsuspendresumemodify.har`) были в старом репо `/home/naeel/terra` — в этом репо в `HAR/` их нет (там `org*`, `vdc`, `edge_`, `ipSpace0`, `pgmodify`, `dummy`, `globak`). Значит: смотреть список в ЛК либо снять свежий HAR.
|
||||||
|
- Живых инстансов vApp (26) и ВМ (28) сейчас **нет ни в одном аккаунте** → `availableOperations` по ним снять не с чего, проверять после первого создания.
|
||||||
|
- Наличие `suspend` у обоих сервисов означает, что режим «заморозка» (`suspend_on_destroy` / `keep_on_destroy`) у них применим — как у кластера и vDC.
|
||||||
|
|
||||||
|
**Что предстоит решить (НЕ выбрано, требует решения пользователя)**
|
||||||
|
1. Где создавать vApp: в `DEV_STAND/FullPipe` (проверенный стенд, аккаунт `narod`) или отдельным файлом-аналогом `shturval.tf` (например `vm.tf`) — по сложившейся конвенции «всё про сервис в одном файле».
|
||||||
|
2. Очерёдность: ВМ до Штурвала или после (влияет и на внешние адреса, и на время apply).
|
||||||
|
3. `imageVm` — откуда брать список образов (в текущих заметках не зафиксировано) → проверять в ЛК/HAR.
|
||||||
|
4. `ipSpaceName` для ВМ — тот же `internet-ipv4-v1` или другой; хватит ли квоты (сейчас `count = 3`, занято кластером).
|
||||||
|
5. `userPublicKey` — какой ключ использовать (в репо есть `secrets/id_ed25519.pub`).
|
||||||
|
6. Режим destroy для vApp и ВМ (есть ли `suspend` у 26/28 — проверить `availableOperations` на живом инстансе перед выбором флагов).
|
||||||
|
7. Обновить после работ: `docs/curated/pipeline/vdc_edge_ip_snat.md` (или новую страницу), `tf_examples/fullpipe_chain` (файл ВМ + README + выводы), `docs/60_strategy/dev_stand_service_dependencies.md`, диаграмму `docs/diagrams/*` (перегенерировать `render_infra_diagram.py`).
|
||||||
|
|
||||||
|
**Правило порядка (из инструкций ЛК):** платформа строит список ipSpace из состояния `job.vcd.networkProvider` / `providerGateway`, то есть аллокация IP и SNAT идут **после** готовых vDC и Edge; иначе modify падает с «Can't cast Complex Object Type Struct to String».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Факты платформы (проверено, не перепроверять)
|
||||||
|
|
||||||
|
- 1 кластер Штурвала = 1 vDC; vDC удаляется только через **14 дней** после `suspend`.
|
||||||
|
- Квоту IP нельзя опустить ниже занятых адресов; `suspend` кластера адреса **не** освобождает.
|
||||||
|
- У Edge операции `suspend` нет вообще: доступны `delete`, `modify`, `reconcile`.
|
||||||
|
- Доступные операции: организация (19) — `delete`, `suspend`, `resume`, `modify`, `reconcile`, `create_user`, `delete_user`; vDC (21) — `delete`, `modify`, `suspend`, `resume`, `reconcile`; Штурвал (150) — то же + `create_user`, `delete_user`.
|
||||||
|
- API ЛК (dev): `GET /api/v1/svc/instances/{uid}`; поля: `explainedStatus`, `state.isSuspended`, `state.isDeleted`, `state.params`, `availableOperations`; ошибки операций приходят **в теле** (`isSuccessful=false`, `errorLog`) при HTTP 200/201.
|
||||||
|
- Обязательные заголовки: браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/`, `Authorization: Bearer <токен>`; иначе 403.
|
||||||
|
- Регистр UUID: облако отдаёт один и тот же UUID в разных регистрах → сравнивать **без учёта регистра, в том числе внутри JSON** (учтено в 2.0.23).
|
||||||
|
- Имя кластера Штурвала должно быть **уникальным**: занятое (даже другим пользователем) не подойдёт; имя услуги и имя кластера — свои у каждого пользователя.
|
||||||
|
- `plan` не видит существующие объекты: adopt/конфликт проверяются в `Create` (в плане всегда «will be created»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Шпаргалка: режимы destroy («заморозка»)
|
||||||
|
|
||||||
|
| Ресурс | Флаг в стенде | Поведение при destroy | Предупреждение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nubes_k8s_sthutrval_cluster` | `suspend_on_destroy = true` | `suspend` | «Ресурс заморожен, а не удалён» |
|
||||||
|
| `nubes_vc_vdc` | `suspend_on_destroy = true` | `suspend` | то же |
|
||||||
|
| `nubes_vc_nsxt` (эдж) | `keep_on_destroy = true` | не трогается | «Ресурс оставлен как есть, а не удалён» |
|
||||||
|
| `nubes_vc_nsxt_snat` | `keep_on_destroy = true` | не трогается | «SNAT не выключался» |
|
||||||
|
| `nubes_vc_org_ip_allocation` | `keep_on_destroy = true` | не трогается | «Аллокация IP не снималась» |
|
||||||
|
|
||||||
|
Приоритет: `keep_on_destroy` > `suspend_on_destroy` > обычное удаление. Дефолты генератора: `suspend_on_destroy = true` (для сервисов с операцией `suspend`), `keep_on_destroy = false`, `adopt_existing_on_create = false`. Обратный ход: `apply` усыновляет объект по имени и делает `resume`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Релизы и публикация (команды)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Релиз провайдера (dev)
|
||||||
|
TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev <версия>
|
||||||
|
# → сам прогоняет 01 (YAML) + 02 (Go/доки), собирает, подписывает, заливает в реестр
|
||||||
|
# → VERSIONS.md обновить вручную
|
||||||
|
|
||||||
|
# Локальная проверка без релиза
|
||||||
|
cd provider && go build -o ../TMP/devbin/terraform-provider-nubes .
|
||||||
|
TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate|plan # dev_overrides
|
||||||
|
|
||||||
|
# Документация: собрать и залить в S3
|
||||||
|
TOOLS/scripts/04_build_and_publish_docs.sh --profile TOOLS/config/dev 2.0.23
|
||||||
|
# ⚠️ версию передавать АРГУМЕНТОМ: в TOOLS/config/dev/profile.env всё ещё VERSION="2.0.17"
|
||||||
|
# docker недоступен → сборка идёт локальным mkdocs 1.6.1 + mkdocs-material
|
||||||
|
|
||||||
|
# Довезти до домена (сейчас обязательно руками: с ВМ нет маршрута до S3)
|
||||||
|
tar -C site -cf - . | ssh vps 'rm -rf ~/tmp-docs-site && mkdir -p ~/tmp-docs-site && tar -C ~/tmp-docs-site -xf -'
|
||||||
|
ssh vps 'rsync -a --delete ~/tmp-docs-site/ /var/www/tf-docs/nubes-dev/'
|
||||||
|
# vps = naeel@5.172.178.213 (алиас в ~/.ssh/config, ключ ~/.ssh/naeel_vm_id_ed25519)
|
||||||
|
# путь из secrets/HOW_TO_SSH.md устарел (файла /home/naeel/remote_dev/... нет)
|
||||||
|
|
||||||
|
# Примеры
|
||||||
|
cd tf_examples && git add -A && git commit -m "..." && git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка доков: `curl -s -o /dev/null -w "%{http_code}" <URL>` — должно быть 200; содержимое локальной сборки лежит в `site/` (каталог в `.gitignore`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Правила работы (не нарушать)
|
||||||
|
|
||||||
|
- `terraform apply` и `destroy` — **только пользователь**. Мне доступны `init/plan/validate/show/state list/state show`.
|
||||||
|
- Никаких правок, коммитов, релизов и публикаций без прямой команды; команда непонятна — **спросить, не гадать**.
|
||||||
|
- После каждой правки — коммит с осмысленным сообщением; перед правкой важных файлов — бэкап в `TMP/backup_<дата>/`.
|
||||||
|
- Секреты (`terraform.tfvars`, токены) **не печатать** в терминал и не коммитить (`*.tfvars` в `.gitignore`); сравнивать по хешу/маскировать.
|
||||||
|
- Не расширять область задач (scope creep), не «улучшать» соседние стенды и репозитории.
|
||||||
|
- Не зацикливаться: два вызова инструмента с близкими аргументами без результата — стоп и доклад.
|
||||||
|
- Проверка примера — на копии в `/tmp`, не в `tf_examples/` (там нет gitignore для `.terraform/`, служебные файлы уехали бы в публичный репозиторий).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Быстрые команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# состояние стендов
|
||||||
|
cd DEV_STAND/FullPipe && terraform state list && terraform plan
|
||||||
|
cd DEV_STAND/FPipeGmail && terraform state list && terraform plan
|
||||||
|
|
||||||
|
# кластер (когда running)
|
||||||
|
kubectl config current-context # tazet@narod.ru@shturval-dev-00
|
||||||
|
kubectl get nodes
|
||||||
|
kubectl get pods -A | grep -v -E "Running|Completed"
|
||||||
|
|
||||||
|
# API ЛК (dev), народ-аккаунт
|
||||||
|
TOK=$(tr -d '\n' < secrets/narodDEV.token)
|
||||||
|
curl -s -H "Authorization: Bearer $TOK" \
|
||||||
|
-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
|
||||||
|
-H 'Referer: https://deck-dev.ngcloud.ru/' \
|
||||||
|
'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances?page=1&size=200'
|
||||||
|
|
||||||
|
# реестр провайдера (dev)
|
||||||
|
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
|
||||||
|
|
||||||
|
# услуги каталога: 19 орга, 21 vDC, 22 Edge/SNAT, 26 vApp, 28 ВМ, 150 Штурвал, 148 — не то
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Открытые вопросы и техдолги
|
||||||
|
|
||||||
|
1. Ref-параметр **внутри JSON** не валидируется при adopt — `provider/internal/resources_core/ref_validation.go`.
|
||||||
|
2. Регистр ключей в `lookupLiveParam` — `provider/internal/core/operation_cfs.go:38` (в прежних заметках был указан неверный файл); нужна живая проверка.
|
||||||
|
3. Тикеты в платформу: TTL/очистка Failed-подов установщика Штурвала (порядок установки компонентов до готовности Cilium); у Edge нет операции `suspend`.
|
||||||
|
4. `TOOLS/config/dev/profile.env`: `VERSION="2.0.17"` — конфиг отстаёт от реестра (`2.0.23`); правка конфига без команды не делалась.
|
||||||
|
5. `DEV_STAND/FPipeGmail`: локальный `state` пуст, но объекты в облаке **уже есть** (`fullpipe-vdc` suspended, `fullpipe-edge` running, `shturval-dev1` suspended) — при применении Terraform их усыновит.
|
||||||
|
6. `DEV_STAND/FPipeGmail` — применять или нет (сейчас `plan` = 5 ресурсов, `state` пуст) — решение за пользователем.
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
|
|
||||||
| Файл | Тема | Статус |
|
| Файл | Тема | Статус |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CHAT_RESUME_IAC_2026-09-24.md` | **Текущая линия: IaC + `modify` (Штурвал).** Задача, позиции участников (Георгий/Дмитрий/Виталий), 10 подтверждённых фактов, почему не работает простое, каноничное решение, развилка A–E, **исправленные ошибки**, карта файлов, открытые вопросы | ✅ **Актуально — начинать отсюда** |
|
| `CHAT_RESUME_2026-09-27_vm_in_pipeline.md` | **Актуальная линия: добавляем ВМ (услуги 26 vApp + 28 ВМ) в пайплайн.** Всё сделанное (freeze/adopt, 2.0.19→2.0.23, пример, доки), состояние стендов и облака на 27.09, точки входа по ВМ, известные проблемы, развилки | ✅ **Актуально — начинать отсюда** |
|
||||||
|
| `CHAT_RESUME_2026-09-25_shturval_freeze_state.md` | Состояние Штурвал/freeze на 25.09: диагностика стенда, режимы destroy, релиз 2.0.23, схема публикаций | ⚠️ История (всё вошло в резюме от 27.09) |
|
||||||
|
| `CHAT_RESUME_2026-09-24_shturval_freeze.md` | Adopt кластера + дизайн freeze-on-destroy | ⚠️ История |
|
||||||
|
| `CHAT_RESUME_IAC_2026-09-24.md` | Линия IaC + `modify` (Штурвал): факты, развилка A–E, карта файлов | ⚠️ История |
|
||||||
| `CHAT_RESUME_2026-09-22.md` | Предыдущая сессия (дата в имени) | ⚠️ История |
|
| `CHAT_RESUME_2026-09-22.md` | Предыдущая сессия (дата в имени) | ⚠️ История |
|
||||||
| `CHAT_RESUME_2026-09-21.md` | Предыдущая сессия | ⚠️ История |
|
| `CHAT_RESUME_2026-09-21.md` | Предыдущая сессия | ⚠️ История |
|
||||||
| `CHAT_RESUME_2026-09-20.md` | Предыдущая сессия | ⚠️ История |
|
| `CHAT_RESUME_2026-09-20.md` | Предыдущая сессия | ⚠️ История |
|
||||||
@@ -17,8 +20,8 @@
|
|||||||
|
|
||||||
## Как пользоваться
|
## Как пользоваться
|
||||||
|
|
||||||
1. Открыть `CHAT_RESUME_IAC_2026-09-24.md` — это сводка всей линии по IaC.
|
1. Открыть `CHAT_RESUME_2026-09-27_vm_in_pipeline.md` — это сводка всей текущей линии (и всё, что уже сделано ранее).
|
||||||
2. Внутри него есть ссылки на детальные документы (`../30_analysis/*`, `../20_prompts/*`).
|
2. Внутри него есть ссылки на детальные документы (`../30_analysis/*`, `../10_plans/*`, `../20_prompts/*`).
|
||||||
3. Устаревшие рестюме **не удалять** — они фиксируют состояние на свою дату (полезно для хронологии).
|
3. Устаревшие рестюме **не удалять** — они фиксируют состояние на свою дату (полезно для хронологии).
|
||||||
|
|
||||||
## Важно про даты
|
## Важно про даты
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ API стенда ──01──▶ generated/<стенд>/resources_yaml/*.yaml
|
|||||||
- Реестр: `tf-registry.containerk8s.services.ngcloud.ru`; бакет бинарников `nubes-terraform-registry`.
|
- Реестр: `tf-registry.containerk8s.services.ngcloud.ru`; бакет бинарников `nubes-terraform-registry`.
|
||||||
- Общий конфиг реестра: `TOOLS/config/registry.env`; стенд-специфика: `TOOLS/config/<стенд>/profile.env`
|
- Общий конфиг реестра: `TOOLS/config/registry.env`; стенд-специфика: `TOOLS/config/<стенд>/profile.env`
|
||||||
(`NUBES_API_ENDPOINT`, `TOKEN_FILE`, `NAMESPACE`, `VERSION`).
|
(`NUBES_API_ENDPOINT`, `TOKEN_FILE`, `NAMESPACE`, `VERSION`).
|
||||||
- Список сервисов для генерации: `TOOLS/config/services_list.txt`.
|
- Список сервисов для генерации: `TOOLS/config/<стенд>/services_list.txt` (у каждого стенда свой;
|
||||||
|
общего списка нет — см. `HISTORY/2026-09-30_yaml_pipeline_hardening.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Как скачать
|
## Как скачать
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
|
git clone https://gitea.services.ngcloud.ru/terraform/tf_examples.git
|
||||||
cd tf_examples/CRUD
|
cd tf_examples/CRUD
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+36
-36
@@ -9,52 +9,52 @@ locals {
|
|||||||
# PostgreSQL — общая БД для всех трёх приложений
|
# PostgreSQL — общая БД для всех трёх приложений
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
|
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
|
||||||
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
|
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
|
||||||
pg_memory = 512 # память в MB
|
pg_memory = 512 # память в MB
|
||||||
pg_replicas = 1 # количество реплик
|
pg_replicas = 1 # количество реплик
|
||||||
pg_disk = 10 # диск в GB
|
pg_disk = 10 # диск в GB
|
||||||
pg_version = "17" # версия PostgreSQL
|
pg_version = "17" # версия PostgreSQL
|
||||||
pg_retain = 14 # дней хранения бэкапов
|
pg_retain = 14 # дней хранения бэкапов
|
||||||
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
|
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
|
||||||
pg_timeout = "11m" # таймаут операций create/modify
|
pg_timeout = "11m" # таймаут операций create/modify
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PostgreSQL — пользователь и база данных
|
# PostgreSQL — пользователь и база данных
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_username = "user4crudpg" # имя пользователя БД
|
pg_username = "user4crudpg" # имя пользователя БД
|
||||||
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
|
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
|
||||||
pg_db_name = "db4crudpg" # имя базы данных
|
pg_db_name = "db4crudpg" # имя базы данных
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Lucee — CFML-приложение (сервис 94)
|
# Lucee — CFML-приложение (сервис 94)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
|
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
|
||||||
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
lucee_version = "5.4" # версия Lucee (CFML engine)
|
lucee_version = "5.4" # версия Lucee (CFML engine)
|
||||||
lucee_git_path = "https://gitea.services.ngcloud.ru/Nail/tfluceecrud.git"
|
lucee_git_path = "https://gitea.services.ngcloud.ru/terraform/tfluceecrud.git"
|
||||||
lucee_cpu = 300 # CPU в millicores
|
lucee_cpu = 300 # CPU в millicores
|
||||||
lucee_memory = 512 # память в MB
|
lucee_memory = 512 # память в MB
|
||||||
lucee_replicas = 1 # количество реплик
|
lucee_replicas = 1 # количество реплик
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Таблица CRUD — общая для всех трёх приложений
|
# Таблица CRUD — общая для всех трёх приложений
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
|
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Flask — Python-приложение (сервис 89)
|
# Flask — Python-приложение (сервис 89)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
flask_resource_name = "crud-flask" # имя ресурса в Nubes
|
flask_resource_name = "crud-flask" # имя ресурса в Nubes
|
||||||
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
flask_git_path = "https://gitea.services.ngcloud.ru/Nail/tfflaskcrud.git"
|
flask_git_path = "https://gitea.services.ngcloud.ru/terraform/tfflaskcrud.git"
|
||||||
flask_cpu = 300 # CPU в millicores
|
flask_cpu = 300 # CPU в millicores
|
||||||
flask_memory = 512 # память в MB
|
flask_memory = 512 # память в MB
|
||||||
flask_replicas = 1 # количество реплик
|
flask_replicas = 1 # количество реплик
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Node.js — Express-приложение (сервис 95)
|
# Node.js — Express-приложение (сервис 95)
|
||||||
@@ -62,11 +62,11 @@ locals {
|
|||||||
|
|
||||||
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
|
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
|
||||||
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
nodejs_git_path = "https://gitea.services.ngcloud.ru/Nail/tfnodejscrud.git"
|
nodejs_git_path = "https://gitea.services.ngcloud.ru/terraform/tfnodejscrud.git"
|
||||||
nodejs_cpu = 300 # CPU в millicores
|
nodejs_cpu = 300 # CPU в millicores
|
||||||
nodejs_memory = 512 # память в MB
|
nodejs_memory = 512 # память в MB
|
||||||
nodejs_replicas = 1 # количество реплик
|
nodejs_replicas = 1 # количество реплик
|
||||||
nodejs_timeout = "15m" # таймаут операций create/modify
|
nodejs_timeout = "15m" # таймаут операций create/modify
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# JDBC — параметры подключения Lucee к PostgreSQL
|
# JDBC — параметры подключения Lucee к PostgreSQL
|
||||||
@@ -75,14 +75,14 @@ locals {
|
|||||||
jdbc_class = "org.postgresql.Driver"
|
jdbc_class = "org.postgresql.Driver"
|
||||||
jdbc_bundle_name = "org.postgresql.jdbc"
|
jdbc_bundle_name = "org.postgresql.jdbc"
|
||||||
jdbc_bundle_version = "42.6.0"
|
jdbc_bundle_version = "42.6.0"
|
||||||
jdbc_conn_limit = "5" # макс. количество соединений
|
jdbc_conn_limit = "5" # макс. количество соединений
|
||||||
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
|
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
|
||||||
jdbc_validate = "false" # валидация соединения при выдаче из пула
|
jdbc_validate = "false" # валидация соединения при выдаче из пула
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PostgreSQL — общие параметры подключения
|
# PostgreSQL — общие параметры подключения
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_port = "5432" # порт PostgreSQL
|
pg_port = "5432" # порт PostgreSQL
|
||||||
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
|
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
resource "nubes_vc_nsxt" "edge" {
|
||||||
|
resource_name = var.nsxt_resource_name
|
||||||
|
|
||||||
|
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
|
||||||
|
vdc_type = var.nsxt_vdc_type
|
||||||
|
|
||||||
|
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
|
||||||
|
# чтобы Edge гарантированно создавался после vDC.
|
||||||
|
vdc_uid = nubes_vc_vdc.vdc.id
|
||||||
|
|
||||||
|
need_enable_avi = var.nsxt_need_enable_avi
|
||||||
|
virtual_services_count = var.nsxt_virtual_services_count
|
||||||
|
|
||||||
|
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
|
||||||
|
routed_net_configuration = {
|
||||||
|
ip_addr_pool = var.nsxt_ip_addr_pool
|
||||||
|
main_dns = var.nsxt_main_dns
|
||||||
|
second_dns = var.nsxt_second_dns
|
||||||
|
}
|
||||||
|
|
||||||
|
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
|
||||||
|
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
|
||||||
|
keep_on_destroy = true
|
||||||
|
|
||||||
|
# Повторный apply усыновляет уже работающий эдж, а не падает с
|
||||||
|
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
|
||||||
|
#
|
||||||
|
# Порядок строго такой:
|
||||||
|
# орга (создана вручную в ЛК)
|
||||||
|
# -> nubes_vc_vdc.vdc
|
||||||
|
# -> nubes_vc_nsxt.edge
|
||||||
|
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
|
||||||
|
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
|
||||||
|
#
|
||||||
|
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
|
||||||
|
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
|
||||||
|
# созданный vDC и Edge. Иначе modify на орге падает
|
||||||
|
# («Can't cast Complex Object Type Struct to String»).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||||
|
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||||
|
organization = var.organization
|
||||||
|
|
||||||
|
vip_configure = jsonencode([
|
||||||
|
{
|
||||||
|
name = var.ip_space_name
|
||||||
|
count = var.ip_count
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
|
||||||
|
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
|
||||||
|
# (и только после удаления кластера).
|
||||||
|
keep_on_destroy = true
|
||||||
|
|
||||||
|
depends_on = [nubes_vc_nsxt.edge]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. SNAT на эдже (modify: ipSpaceName)
|
||||||
|
resource "nubes_vc_nsxt_snat" "snat" {
|
||||||
|
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||||
|
ip_space_name = var.ip_space_name
|
||||||
|
|
||||||
|
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
|
||||||
|
keep_on_destroy = true
|
||||||
|
|
||||||
|
# ipSpace должен быть уже выделен на организации
|
||||||
|
depends_on = [nubes_vc_org_ip_allocation.org_ip]
|
||||||
|
}
|
||||||
|
|
||||||
|
output "allocated_org_ip" {
|
||||||
|
description = "Выделено внешних IP на организации"
|
||||||
|
value = {
|
||||||
|
organization = var.organization
|
||||||
|
ip_space_name = var.ip_space_name
|
||||||
|
ip_count = var.ip_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "snat_ip_space" {
|
||||||
|
description = "ipSpace, включённый как SNAT на эдже"
|
||||||
|
value = nubes_vc_nsxt_snat.snat.ip_space_name
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
output "vdc_id" {
|
||||||
|
description = "UID созданного VDC"
|
||||||
|
value = nubes_vc_vdc.vdc.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vdc_name" {
|
||||||
|
description = "Имя VDC"
|
||||||
|
value = nubes_vc_vdc.vdc.resource_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vdc_state_params" {
|
||||||
|
description = "Параметры состояния VDC из API"
|
||||||
|
value = nubes_vc_vdc.vdc.state_params
|
||||||
|
}
|
||||||
|
|
||||||
|
output "nsxt_id" {
|
||||||
|
description = "UID созданного Edge (vc_nsxt)"
|
||||||
|
value = nubes_vc_nsxt.edge.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "nsxt_name" {
|
||||||
|
description = "Имя Edge (vc_nsxt)"
|
||||||
|
value = nubes_vc_nsxt.edge.resource_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "nsxt_state_params" {
|
||||||
|
description = "Параметры состояния Edge (vc_nsxt) из API"
|
||||||
|
value = nubes_vc_nsxt.edge.state_params
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
provider "nubes" {
|
||||||
|
api_token = var.api_token
|
||||||
|
api_endpoint = var.api_endpoint
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
|
||||||
|
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
|
||||||
|
#
|
||||||
|
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
|
||||||
|
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
|
||||||
|
# или закомментировать ресурс.
|
||||||
|
#
|
||||||
|
# Порядок (чек-лист из инструкции на услугу в ЛК):
|
||||||
|
# 1) Организация в Cloud Director — создана вручную в ЛК
|
||||||
|
# 2) nubes_vc_vdc.vdc — есть
|
||||||
|
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
|
||||||
|
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
|
||||||
|
# 5) SNAT на Edge — nubes_vc_nsxt_snat
|
||||||
|
# 6) Kubernetes кластер Штурвал — этот ресурс
|
||||||
|
#
|
||||||
|
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
|
||||||
|
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Переменные Штурвала ---
|
||||||
|
|
||||||
|
variable "shturval_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "shturval-dev1"
|
||||||
|
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_cluster_name" {
|
||||||
|
type = string
|
||||||
|
default = "shturval-dev-01"
|
||||||
|
description = "Имя кластера внутри Штурвала"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_app_version" {
|
||||||
|
type = string
|
||||||
|
default = "2.14.0"
|
||||||
|
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_cp_sizing_policy" {
|
||||||
|
type = string
|
||||||
|
default = "TKG 4CPU 8RAM"
|
||||||
|
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_cp_sizing_disk" {
|
||||||
|
type = number
|
||||||
|
default = 50
|
||||||
|
description = "Диск control plane, ГБ (минимум 50)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_cp_count" {
|
||||||
|
type = number
|
||||||
|
default = 1
|
||||||
|
description = "Количество мастер-нод: 1, 3 или 5"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_worker_group_name" {
|
||||||
|
type = string
|
||||||
|
default = "workers-shturval-dev"
|
||||||
|
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_worker_sizing_policy" {
|
||||||
|
type = string
|
||||||
|
default = "TKG 4CPU 8RAM"
|
||||||
|
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_worker_sizing_disk" {
|
||||||
|
type = number
|
||||||
|
default = 50
|
||||||
|
description = "Диск воркеров, ГБ (минимум 50)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shturval_worker_count" {
|
||||||
|
type = number
|
||||||
|
default = 1
|
||||||
|
description = "Количество воркер-нод (минимум 1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Значения, которые собираются из переменных ---
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
|
||||||
|
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
|
||||||
|
# sizingDisk, count, autoscale, labelDeck.
|
||||||
|
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
|
||||||
|
# в snake_case — это ошибка генератора, платформа на них падает с
|
||||||
|
# «Cannot invoke method split() on null object» (не находит groupName → null).
|
||||||
|
shturval_worker_config = jsonencode([
|
||||||
|
{
|
||||||
|
groupName = var.shturval_worker_group_name
|
||||||
|
sizingPolicy = var.shturval_worker_sizing_policy
|
||||||
|
sizingDisk = var.shturval_worker_sizing_disk
|
||||||
|
count = var.shturval_worker_count
|
||||||
|
autoscale = false # автоскейл выключен
|
||||||
|
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Ресурс Штурвала ---
|
||||||
|
|
||||||
|
resource "nubes_k8s_sthutrval_cluster" "shturval" {
|
||||||
|
resource_name = var.shturval_resource_name
|
||||||
|
|
||||||
|
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
|
||||||
|
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
|
||||||
|
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
|
||||||
|
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
|
||||||
|
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
|
||||||
|
# Следующий apply усыновит его и разморозит (resume).
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
|
||||||
|
# иначе провайдер сдаётся на дефолтных 600 с.
|
||||||
|
operation_timeout = "60m"
|
||||||
|
|
||||||
|
startup_configuration = {
|
||||||
|
# vDC и Edge из этого же конфига (обязательные поля)
|
||||||
|
vdc_uid = nubes_vc_vdc.vdc.id
|
||||||
|
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||||
|
|
||||||
|
cluster_name = var.shturval_cluster_name
|
||||||
|
|
||||||
|
# Дополнительные возможности кластера (в ЛК — галочки при создании)
|
||||||
|
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
|
||||||
|
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
|
||||||
|
ex_local_csi = true
|
||||||
|
ex_vip = true
|
||||||
|
ex_update = true
|
||||||
|
ex_ingress = true
|
||||||
|
ex_named_csi = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster_configuration = {
|
||||||
|
app_version = var.shturval_app_version
|
||||||
|
}
|
||||||
|
|
||||||
|
control_plane_configuration = {
|
||||||
|
sizing_policy = var.shturval_cp_sizing_policy
|
||||||
|
sizing_disk = var.shturval_cp_sizing_disk
|
||||||
|
count = var.shturval_cp_count
|
||||||
|
}
|
||||||
|
|
||||||
|
worker_configuration = local.shturval_worker_config
|
||||||
|
|
||||||
|
access_configuration = {
|
||||||
|
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
|
||||||
|
access_ip_list_api = jsonencode([]) # пусто = доступ всем
|
||||||
|
need_external_address_ingress = true # внешний адрес для Ingress
|
||||||
|
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
|
||||||
|
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
variable "api_token" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
description = "API-токен Nubes"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "api_endpoint" {
|
||||||
|
type = string
|
||||||
|
default = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
|
||||||
|
description = "API Gateway URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имя (display_name, напр. "kontora") ИЛИ UUID организации из ЛК
|
||||||
|
variable "organization" {
|
||||||
|
type = string
|
||||||
|
description = "Имя или UUID организации (vc_org)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
||||||
|
|
||||||
|
variable "ip_space_name" {
|
||||||
|
type = string
|
||||||
|
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ip_count" {
|
||||||
|
type = string
|
||||||
|
default = "3"
|
||||||
|
description = "Сколько внешних IP выделить на организации (count — строка)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vdc"
|
||||||
|
description = "Имя VDC"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_network_provider" {
|
||||||
|
type = string
|
||||||
|
default = null
|
||||||
|
description = "Сетевой провайдер. Заполнить значением из текущей страницы ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_provider_vdc" {
|
||||||
|
type = string
|
||||||
|
default = null
|
||||||
|
description = "Provider VDC. Заполнить значением из текущей страницы ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_cpu_allocated" {
|
||||||
|
type = number
|
||||||
|
default = 8
|
||||||
|
description = "vCPU (шт.)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_cpu_guaranteed" {
|
||||||
|
type = number
|
||||||
|
default = 0
|
||||||
|
description = "Резервирование vCPU (%, допустимо: 0, 50, 80)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_mem_allocated" {
|
||||||
|
type = number
|
||||||
|
default = 32
|
||||||
|
description = "RAM (GB)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vdc_storage_config" {
|
||||||
|
type = string
|
||||||
|
default = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
|
||||||
|
description = "Дисковое хранилище (JSON-массив, size в GB). Имя политики должно существовать в ресурсном пуле (например, SATA, SSD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- vc_nsxt (Сетевой шлюз периметра / Edge) ---
|
||||||
|
|
||||||
|
variable "nsxt_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-edge"
|
||||||
|
description = "Имя Edge (vc_nsxt)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_vdc_type" {
|
||||||
|
type = string
|
||||||
|
default = "vdc"
|
||||||
|
description = "Тип родительской услуги: vdc или vdcGroup"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_need_enable_avi" {
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
description = "Включить AVI Load Balancer (ALB)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_virtual_services_count" {
|
||||||
|
type = number
|
||||||
|
default = 3
|
||||||
|
description = "Кол-во виртуальных сервисов на AVI (1..4; Штурвал: ≥ 3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_ip_addr_pool" {
|
||||||
|
type = string
|
||||||
|
default = "10.10.102.0/24"
|
||||||
|
description = "Адресный пул routed-сети (маска /24 обязательна)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_main_dns" {
|
||||||
|
type = string
|
||||||
|
default = "81.22.46.22"
|
||||||
|
description = "Основной DNS"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nsxt_second_dns" {
|
||||||
|
type = string
|
||||||
|
default = "185.247.187.77"
|
||||||
|
description = "Второй DNS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
resource "nubes_vc_vdc" "vdc" {
|
||||||
|
resource_name = var.vdc_resource_name
|
||||||
|
|
||||||
|
# Организация: имя из ЛК ("kontora") или точный UUID
|
||||||
|
organization_uid = var.organization
|
||||||
|
|
||||||
|
network_provider = var.vdc_network_provider
|
||||||
|
provider_vdc = var.vdc_provider_vdc
|
||||||
|
|
||||||
|
cpu_allocated = var.vdc_cpu_allocated
|
||||||
|
cpu_guaranteed = var.vdc_cpu_guaranteed
|
||||||
|
mem_allocated = var.vdc_mem_allocated
|
||||||
|
|
||||||
|
# JSON-массив дисковых политик (size в GB)
|
||||||
|
storage_config = var.vdc_storage_config
|
||||||
|
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
nubes = {
|
||||||
|
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
|
||||||
|
version = "2.0.23"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Виртуальная машина внутри vApp — услуги 26 (vApp) и 28 (ВМ)
|
||||||
|
#
|
||||||
|
# Всё, что относится к ВМ, лежит ТОЛЬКО в этом файле: переменные, их значения
|
||||||
|
# по умолчанию, оба ресурса и выводы. Чтобы выключить ВМ — удалить или
|
||||||
|
# закомментировать этот файл (по аналогии с shturval.tf).
|
||||||
|
#
|
||||||
|
# Место в цепочке:
|
||||||
|
# орга (вручную в ЛК) → vDC (21) → Edge (22) → внешние IP → SNAT
|
||||||
|
# → [ vApp (26) → ВМ (28) ] → Штурвал (150)
|
||||||
|
#
|
||||||
|
# Зависимости (из манифестов услуг, сгенерированные ресурсы):
|
||||||
|
# vApp (26) — nubes_vapp: требует vdc_uid (21) и nsxt_uid (22)
|
||||||
|
# ВМ (28) — nubes_vc_vm_v3: требует vapp_uid (26)
|
||||||
|
#
|
||||||
|
# Внешний доступ: ВМ публикуется за общим SNAT эджа (same_snat = false), для
|
||||||
|
# этого ipSpace должен быть выделен на организации и включён как SNAT
|
||||||
|
# (см. modifiers.tf). Нужен ВЫДЕЛЕННЫЙ внешний адрес — same_snat = true.
|
||||||
|
# ipSpace для ВМ берём тот же, что у SNAT (var.ip_space_name).
|
||||||
|
#
|
||||||
|
# Режим destroy: у обеих услуг операция delete требует предварительного
|
||||||
|
# suspend, поэтому по умолчанию suspend_on_destroy = true («заморозка»).
|
||||||
|
# Полное удаление vApp возможно только через 14 дней после suspend.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Переменные vApp ---
|
||||||
|
|
||||||
|
variable "vapp_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vapp"
|
||||||
|
description = "Имя услуги «Виртуальный каталог ВМ (vApp)» в ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vapp_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vapp-01"
|
||||||
|
description = "Имя vApp. Маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации; участвует в DNS-имени ВМ. НЕ оставлять дефолтом платформы."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Переменные ВМ ---
|
||||||
|
|
||||||
|
variable "vm_resource_name" {
|
||||||
|
type = string
|
||||||
|
default = "fullpipe-vm-01"
|
||||||
|
description = "Имя услуги «Виртуальная машина» в ЛК"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_name" {
|
||||||
|
type = string
|
||||||
|
default = "web01"
|
||||||
|
description = "Имя ВМ. Маска ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$. Определяет имя NSX-T IP Set: {vapp_name}-{vm_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_image" {
|
||||||
|
type = string
|
||||||
|
default = "Ubuntu_22-20G"
|
||||||
|
description = "Образ ОС. Доступные значения: RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G. Не изменяется после создания"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_cpu" {
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
description = "vCPU (1..64), шт"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_ram" {
|
||||||
|
type = number
|
||||||
|
default = 2
|
||||||
|
description = "RAM (1..256), GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_disk" {
|
||||||
|
type = number
|
||||||
|
default = 20
|
||||||
|
description = "Дополнительный диск, GB (основной диск зависит от образа)"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_user_login" {
|
||||||
|
type = string
|
||||||
|
default = "ubuntu"
|
||||||
|
description = "Учётка SSH. Не изменяется после создания"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_user_public_key" {
|
||||||
|
type = string
|
||||||
|
description = "Публичная часть SSH-ключа в формате OpenSSH. Не изменяется после создания. Значение — в terraform.tfvars"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_access_port_list" {
|
||||||
|
type = list(object({
|
||||||
|
port = string
|
||||||
|
type = string
|
||||||
|
}))
|
||||||
|
default = [
|
||||||
|
{ port = "22", type = "tcp" }
|
||||||
|
]
|
||||||
|
description = "Белый список портов для доступа извне; type: tcp | udp | all"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_access_ip_list" {
|
||||||
|
type = list(string)
|
||||||
|
default = ["0.0.0.0/0"]
|
||||||
|
description = "Белый список адресов, которым разрешён доступ к ВМ. Требует выделенного внешнего IP"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vm_same_snat" {
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
description = "false — публикация за общим SNAT эджа; true — за выделенным внешним IP услуги"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- vApp (услуга 26) ---
|
||||||
|
|
||||||
|
resource "nubes_vapp" "vapp" {
|
||||||
|
resource_name = var.vapp_resource_name
|
||||||
|
vapp_name = var.vapp_name
|
||||||
|
|
||||||
|
vdc_uid = nubes_vc_vdc.vdc.id # ref 21 — вычислительная инфраструктура
|
||||||
|
nsxt_uid = nubes_vc_nsxt.edge.id # ref 22 — сеть/маршрутизация
|
||||||
|
|
||||||
|
# «Заморозка»: destroy переводит vApp в suspend (delete требует suspend).
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
# Повторный apply усыновляет существующий vApp, а не падает с
|
||||||
|
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ».
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
|
||||||
|
# vApp требует готовую сеть (ipSpace на организации + SNAT на эдже).
|
||||||
|
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- ВМ (услуга 28) ---
|
||||||
|
|
||||||
|
resource "nubes_vc_vm_v3" "vm" {
|
||||||
|
resource_name = var.vm_resource_name
|
||||||
|
vm_name = var.vm_name
|
||||||
|
|
||||||
|
vapp_uid = nubes_vapp.vapp.id # ref 26 — ВМ размещается в vApp
|
||||||
|
image_vm = var.vm_image
|
||||||
|
|
||||||
|
vm_cpu = var.vm_cpu
|
||||||
|
vm_ram = var.vm_ram
|
||||||
|
vm_disk = var.vm_disk
|
||||||
|
|
||||||
|
user_login = var.vm_user_login
|
||||||
|
user_public_key = var.vm_user_public_key
|
||||||
|
|
||||||
|
# Внешний доступ
|
||||||
|
ip_space_name = var.ip_space_name # тот же ipSpace, что у SNAT эджа
|
||||||
|
same_snat = var.vm_same_snat
|
||||||
|
access_port_list = jsonencode(var.vm_access_port_list)
|
||||||
|
access_ip_list = jsonencode(var.vm_access_ip_list)
|
||||||
|
|
||||||
|
# «Заморозка»: destroy переводит ВМ в suspend.
|
||||||
|
suspend_on_destroy = true
|
||||||
|
|
||||||
|
adopt_existing_on_create = true
|
||||||
|
|
||||||
|
# ВМ создаётся платформой долго — поднимаем таймаут ожидания.
|
||||||
|
operation_timeout = "15m"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Выводы ---
|
||||||
|
|
||||||
|
output "vapp_id" {
|
||||||
|
description = "UID созданного vApp (услуга 26)"
|
||||||
|
value = nubes_vapp.vapp.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vapp_name" {
|
||||||
|
description = "Имя vApp"
|
||||||
|
value = nubes_vapp.vapp.vapp_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vm_id" {
|
||||||
|
description = "UID созданной ВМ (услуга 28)"
|
||||||
|
value = nubes_vc_vm_v3.vm.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vm_state_flat" {
|
||||||
|
description = "Плоский state ВМ — IP-адреса, статус и т.д."
|
||||||
|
value = nubes_vc_vm_v3.vm.state_out_flat
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
НИКАКОЙ САМОДЕЙТЕЛЬНОСТИ !!! делать ТОЛЬКО ТО НА ЧТО ПОЛУЧЕНО РАЗРЕШЕНИЕ !!!!
|
||||||
|
НИКАКИХ ДОГАДОК !!! ЕСТь сомнения - СПРОСИ !!!
|
||||||
|
НИКОГДА НЕ ДЕЛАЙ ПРЕДПОЛОЖЕНИЙ !!!
|
||||||
|
ВСЕГДА СПРАШИВАЙ, ЕСЛИ НЕ УВЕРЕН !!!
|
||||||
|
НИКОГДА НЕ ИГНОРИРУЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
|
||||||
|
ВСЕГДА ПОДТВЕРЖДАЙ ПОЛУЧЕННЫЕ ИНСТРУКЦИИ !!!
|
||||||
|
НИКОГДА НЕ ИЗМЕНЯЙ ИНСТРУКЦИИ БЕЗ РАЗРЕШЕНИЯ !!!
|
||||||
|
ВСЕГДА СОБЛЮДАЙ ПОРЯДОК И ПОСЛЕДОВАТЕЛЬНОСТЬ В ИНСТРУКЦИЯХ !!!
|
||||||
|
НИКОГДА НЕ ПРЕВЫШАЙ СВОИ ПОЛНОМОЧИЯ !!!
|
||||||
|
ВСЕГДА СОБЛЮДАЙ БЕЗОПАСНОСТЬ И КОНФИДЕНЦИАЛЬНОСТЬ !!!
|
||||||
|
НИКОГДА НЕ ПЕРЕДАВАЙ СЕКРЕТЫ ИНТЕРНЕТУ БЕЗ РАЗРЕШЕНИЯ !!!
|
||||||
|
НЕ ВЫЗЫВАТЬ ДРУГИЕ АГЕНТЫ БЕЗ РАЗРЕШЕНИЯ !!!
|
||||||
|
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
|
||||||
|
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
||||||
|
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
||||||
|
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||||
|
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
|
||||||
|
ЕСЛИ В ЧЁМ совменваешься - ОСТАНОВИСЬ И СПРОСИ !!!! НИКОГДА не действуй по догадкам и преположенгиям !!! ПЕРЕПРОВЕРЯЙ ВСЁ НЕСКОЛЬКО РАЗ !!!
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Provider Architecture (PRIMARY SOURCE OF TRUTH)
|
||||||
|
|
||||||
|
**⛔ THIS FILE IS THE FOUNDATION. ALL CODE AND SCRIPTS ARE DERIVED FROM IT.**
|
||||||
|
|
||||||
|
This document defines the project-wide architecture and rules for generation,
|
||||||
|
provider behavior, and documentation. Any change to provider logic MUST be
|
||||||
|
reflected here FIRST, then implemented in `gen_v2` and other tools.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1) YAML per service is generated ONLY from API data.
|
||||||
|
2) The provider core (`provider/internal/core`) is universal and must not contain
|
||||||
|
service-specific logic.
|
||||||
|
3) Service-specific Go code (`provider/internal/resources_gen`) is fully generated
|
||||||
|
from YAML. No manual edits to generated code.
|
||||||
|
4) Documentation is generated from the same YAML.
|
||||||
|
5) Build artifacts for 3 OS targets are published to the registry, and docs are
|
||||||
|
published to the website.
|
||||||
|
6) `TOOLS/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
|
||||||
|
|
||||||
|
## Service Selection
|
||||||
|
|
||||||
|
- The inclusion list is defined by: `TOOLS/config/<dev|test|prod>/services_list.txt`
|
||||||
|
(по одному списку на стенд; repo-relative path)
|
||||||
|
- Each line starts with service_id, followed by service name/alias.
|
||||||
|
- Operation timeouts source is defined by: `TOOLS/config/<dev|test|prod>/operation_timeouts.json`.
|
||||||
|
|
||||||
|
## API Endpoint
|
||||||
|
|
||||||
|
The provider supports two API styles, auto-detected by `NUBES_API_ENDPOINT`:
|
||||||
|
- **Legacy proxy**: contains `index.cfm` → `?endpoint=/path`
|
||||||
|
- **REST Gateway**: no `index.cfm` → direct path concatenation
|
||||||
|
|
||||||
|
## Unified YAML (Per Service)
|
||||||
|
|
||||||
|
One YAML file per service. This is the only input for:
|
||||||
|
- Provider code generation
|
||||||
|
- Documentation generation
|
||||||
|
- Validation rules
|
||||||
|
|
||||||
|
Required top-level fields:
|
||||||
|
- name
|
||||||
|
- service_id
|
||||||
|
- service_display_name
|
||||||
|
- service_short_name
|
||||||
|
- lifecycle
|
||||||
|
- outputs
|
||||||
|
- operations
|
||||||
|
- service_man
|
||||||
|
|
||||||
|
### Operations: Kinds and Rules
|
||||||
|
|
||||||
|
Each operation has a kind:
|
||||||
|
|
||||||
|
**instance** — CRUD for the service instance, plus suspend/resume:
|
||||||
|
| API action | Terraform behavior |
|
||||||
|
|---|---|
|
||||||
|
| create | `terraform apply` (new resource) |
|
||||||
|
| delete | `terraform destroy` |
|
||||||
|
| modify | `terraform apply` (params changed) |
|
||||||
|
| suspend | `terraform destroy` when `suspend_on_destroy=true` |
|
||||||
|
| resume | `terraform apply` when `adopt_existing_on_create=true` |
|
||||||
|
|
||||||
|
**subresource** — CRUD for objects inside the service (users, databases, topics):
|
||||||
|
- Exposed as separate resources: `nubes_{service}_{subresource}`
|
||||||
|
- Identity = `{parent_instance_uid, subresource_key}` (e.g. `{postgres_id, username}`)
|
||||||
|
- Supports `adopt_existing_on_create` — if subresource already exists, adopt it instead of failing
|
||||||
|
|
||||||
|
**action** — one-shot operations. **Only `redeploy` is included**:
|
||||||
|
- `redeploy` → **inline**: field `git_revision` in the main resource. When it changes, call redeploy instead of (or after) modify
|
||||||
|
- `restart`, `recovery`, `reconcile` → **excluded**. These are manual operational tasks, performed via UI only. Reason:
|
||||||
|
- `restart` — modify handles pod restart when needed
|
||||||
|
- `recovery` — creates a new instance from backup, not a modification of existing
|
||||||
|
- `reconcile` — sync after manual changes; Terraform owns its own state
|
||||||
|
|
||||||
|
### Idempotency Rules
|
||||||
|
|
||||||
|
- instance CRUD is idempotent via standard Terraform behavior.
|
||||||
|
- subresource CRUD is idempotent by resource identity.
|
||||||
|
- `redeploy`: idempotent via `git_revision` field — if unchanged, no redeploy
|
||||||
|
|
||||||
|
### Lifecycle Behavior
|
||||||
|
|
||||||
|
- For `instance` resources with `suspend`/`resume`, use explicit flags:
|
||||||
|
`adopt_existing_on_create` (default `false`) and `suspend_on_destroy` (default `true`).
|
||||||
|
- Apply decision matrix for suspend-capable services:
|
||||||
|
- cloud status `missing` or `deleted` -> `create`
|
||||||
|
- `suspend` + `adopt_existing_on_create=true` + key params match -> `resume` + `adopt`
|
||||||
|
- `suspend` + `adopt_existing_on_create=false` -> error (explicitly require flag for resume/adopt)
|
||||||
|
- `suspend` + key params mismatch -> error
|
||||||
|
- `running` + `adopt_existing_on_create=true` -> adopt/import behavior
|
||||||
|
- `running` + `adopt_existing_on_create=false` -> error
|
||||||
|
- `not created` -> error, no auto-adopt/create
|
||||||
|
- `creating`/`pending`/`failed` -> error
|
||||||
|
- Conflict diagnostics requirement:
|
||||||
|
- When `resource_name` already exists and `adopt_existing_on_create=false`, diagnostics must explicitly offer two choices:
|
||||||
|
1) change `resource_name` to create a new resource;
|
||||||
|
2) import/adopt existing one by setting `adopt_existing_on_create=true` and re-running `apply`.
|
||||||
|
- Destroy behavior for suspend-capable services:
|
||||||
|
- `suspend_on_destroy=true` -> call `suspend`
|
||||||
|
- `suspend_on_destroy=false` -> remove from Terraform state only (no API call)
|
||||||
|
|
||||||
|
### Diagnostics Format
|
||||||
|
|
||||||
|
- Lifecycle diagnostics for plan/apply must be multiline and human-readable.
|
||||||
|
- Include decision reason and controlling flag in message body.
|
||||||
|
- Print details as separate lines: `resource_name`, `service_id`, `instance_uid`,
|
||||||
|
`status`, `status_raw`, `operation_pending`, `operation_in_progress`.
|
||||||
|
|
||||||
|
## Provider Model
|
||||||
|
|
||||||
|
- Core (`provider/internal/core`) is universal: no service-specific logic inside it.
|
||||||
|
- Generated service resources (`provider/internal/resources_gen`) contain only
|
||||||
|
schema/params and references.
|
||||||
|
- Hand-written service resources live in `provider/internal/resources_core` and are
|
||||||
|
registered in `provider/internal/provider/provider.go` `Resources()`. They are
|
||||||
|
NOT generated; they must not contain arbitrary service logic, only:
|
||||||
|
- a schema, and
|
||||||
|
- wiring between schema fields and the universal core API
|
||||||
|
(`RunInstanceOperationUniversalByCode`, `ResolveRefSvcParamValue`, etc.).
|
||||||
|
|
||||||
|
### API Resilience
|
||||||
|
|
||||||
|
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
|
||||||
|
Gateway may temporarily reject valid JWT tokens.
|
||||||
|
- **CURRENTLY NOT IMPLEMENTED** — `isRetryable` (`core/http.go`) retries only
|
||||||
|
{429, 502, 503, 504}, NOT 401, and only for GET. This is a known gap versus the
|
||||||
|
intent above; fix in `core/http.go` `isRetryable`.
|
||||||
|
|
||||||
|
### Generated Code Resilience
|
||||||
|
|
||||||
|
- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует
|
||||||
|
в пользовательском `.tf`, подставлять zero-value по `dataType`:
|
||||||
|
- `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""`
|
||||||
|
- Это предотвращает NullPointerException на стороне API при добавлении новых полей.
|
||||||
|
- **map-fixed default из DataDescriptor** (`buildMapFixedDefault`): если API возвращает
|
||||||
|
`dataDescriptor` для `map-fixed`-параметра, а значение отсутствует или равно `"{}"`,
|
||||||
|
строится JSON из дефолтов sub-параметров (`{"type":"off","durationCA":"175200",...}`).
|
||||||
|
Это гарантирует что API получит все обязательные sub-поля с их значениями по умолчанию.
|
||||||
|
- **s3Uid-резолв в map-fixed** (`resolveS3UidInMapFixed`): для map-fixed-параметров
|
||||||
|
парсится JSON, ищутся ключи по паттерну `s3.*uid` (case-insensitive), значения-не-UUID
|
||||||
|
резолвятся в UUID через S3 (сервис 12). Не зависит от DataDescriptor API.
|
||||||
|
Соглашение об именах: любой sub-param с `s3`+`uid` в имени → S3 (12).
|
||||||
|
- **modify всегда через WithDefaults** (`RunInstanceOperationUniversalWithDefaults`):
|
||||||
|
modify-операции запрашивают `cfsParams` у API и отправляют все параметры,
|
||||||
|
включая новые, с дефолтами из API.
|
||||||
|
- **Nil-guard для nested-параметров** (шаблон `instance.go`): `map-fixed`-параметры
|
||||||
|
(указатели на вложенные структуры) проверяются на nil перед доступом к sub-полям.
|
||||||
|
Если состояние создано до добавления нового `map-fixed`-параметра — он будет nil,
|
||||||
|
и код не должен падать с nil pointer dereference. Вместо этого параметр пропускается,
|
||||||
|
и zero-value fallback подставит `{}`.
|
||||||
|
- **Nested-атрибуты с default — Optional без Computed** (`NestedSchemaBlock` в `helpers.go`):
|
||||||
|
`map-fixed`-параметры с default генерируются как `Optional: true` (без `Computed`),
|
||||||
|
потому что провайдер не вычисляет nested-значения из API. `Computed` приводил бы к
|
||||||
|
`unknown`-значению, которое нельзя декодировать в конкретный тип `*Struct`.
|
||||||
|
- **Merge SubParams union** (`params.go`): при совпадении `Code` параметра в разных
|
||||||
|
операциях (create/modify) с разными наборами sub-params, `Merge()` объединяет их
|
||||||
|
union'ом, а не отбрасывает второй. Это гарантирует что структура содержит все поля.
|
||||||
|
|
||||||
|
### Subresource Resources
|
||||||
|
|
||||||
|
Subresource operations are exposed as standard resources.
|
||||||
|
Example mapping:
|
||||||
|
- create_user/delete_user/modify_user => nubes_<service>_user
|
||||||
|
- create_database/delete_database => nubes_<service>_database
|
||||||
|
|
||||||
|
Example HCL:
|
||||||
|
|
||||||
|
resource "nubes_postgres_user" "user1" {
|
||||||
|
postgres_id = nubes_postgres.db.id
|
||||||
|
username = "app_user"
|
||||||
|
role = "app_user"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Redeploy (inline action)
|
||||||
|
|
||||||
|
Services with `redeploy` operation get a `git_revision` field in the main resource.
|
||||||
|
Changing `git_revision` triggers `redeploy` instead of `modify`.
|
||||||
|
|
||||||
|
Example HCL:
|
||||||
|
|
||||||
|
resource "nubes_flask" "app" {
|
||||||
|
resource_name = "my-flask"
|
||||||
|
git_revision = "abc123" # ← change this to trigger redeploy
|
||||||
|
app_configuration = jsonencode({...})
|
||||||
|
}
|
||||||
|
|
||||||
|
## Concurrency and State Locking
|
||||||
|
|
||||||
|
**Provider-level guarantees:**
|
||||||
|
- The provider does NOT implement distributed locking for instance operations.
|
||||||
|
- Two concurrent `terraform apply` with the same `resource_name` may create duplicate
|
||||||
|
instances, leading to a «multiple instances found» error on subsequent applies.
|
||||||
|
|
||||||
|
**User responsibility:**
|
||||||
|
- Use Terraform backend with state locking (S3+DynamoDB, etc.).
|
||||||
|
- Do NOT run `terraform apply` from two workspaces against the same state simultaneously.
|
||||||
|
- If duplicates occur: delete extras via Cloud Console and re-apply.
|
||||||
|
|
||||||
|
**API-side limitations:**
|
||||||
|
- Nubes API does not enforce unique `displayName` per serviceId.
|
||||||
|
- The provider cannot atomically guarantee «create-or-adopt» without API support
|
||||||
|
for conditional creation or name uniqueness constraints.
|
||||||
|
|
||||||
|
## Documentation Model
|
||||||
|
|
||||||
|
From the unified YAML, generate:
|
||||||
|
- Resource page: CRUD params, outputs, lifecycle defaults, operations summary
|
||||||
|
- MAN page: service_man + parameter man blocks
|
||||||
|
- Resources index: each resource links to its page and its MAN page
|
||||||
|
|
||||||
|
## Pipeline Overview (DevOps)
|
||||||
|
|
||||||
|
1) Generate unified YAML from API for services_list.txt.
|
||||||
|
2) Generate provider code from YAML.
|
||||||
|
3) Generate documentation from YAML.
|
||||||
|
4) Build provider for linux/windows/darwin.
|
||||||
|
5) Upload provider artifacts to registry.
|
||||||
|
6) Build and publish docs to site.
|
||||||
|
|
||||||
|
## Lifecycle Vocabulary (single contract)
|
||||||
|
|
||||||
|
⚠️ The destroy-behaviour vocabulary is currently INCONSISTENT across resource kinds:
|
||||||
|
|
||||||
|
1. generated instance resources: runtime flags `suspend_on_destroy` / `keep_on_destroy`;
|
||||||
|
2. generated modifiers: compile-time `delete_strategy` (no runtime flag);
|
||||||
|
3. hand-written modifiers: runtime `keep_on_destroy` only.
|
||||||
|
|
||||||
|
All three express the same intent ("what happens to the platform effect on destroy").
|
||||||
|
Canonical direction: one unified vocabulary/contract for all resource kinds.
|
||||||
|
|
||||||
|
## Non-Negotiable Rules
|
||||||
|
|
||||||
|
- No manual edits to generated YAML or generated Go code.
|
||||||
|
- Any change to generated code must come from API or generator logic updates.
|
||||||
|
- The generator must enforce these rules and fail fast on drift.
|
||||||
|
|
||||||
|
## Exception Registry (service-specific DATA, never logic)
|
||||||
|
|
||||||
|
Principle: provider core and generator logic are universal for all stands and
|
||||||
|
services. Service-specific deviations are of two kinds:
|
||||||
|
|
||||||
|
1. **Generated modifiers** — a `modify` op that should become a dedicated modifier
|
||||||
|
resource. The generator (`TOOLS/resource-generator/internal/loader/loader.go`)
|
||||||
|
supports `kind: modifier` with `delete_strategy` (`noop_warn`/`inverse`/`error`)
|
||||||
|
and `idempotency` (`none`/`check_before_run`). The overlay data file
|
||||||
|
`modifiers.yaml` that would drive this is **documented but NOT yet created**;
|
||||||
|
until then, modifiers are hand-written in `provider/internal/resources_core/`.
|
||||||
|
(The legacy registry `serviceSpecificModifiers` in `TOOLS/yaml-generator/main.go`
|
||||||
|
was REMOVED during the 2026-09-23 refactoring.)
|
||||||
|
2. **Doc examples** — named registry:
|
||||||
|
|
||||||
|
| Registry | File | Declares |
|
||||||
|
|---|---|---|
|
||||||
|
| `serviceSpecificDocExamples` | `TOOLS/docs-generator/internal/writers/writers.go` | per-service doc examples, gated on service name + required state/vault keys |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Key by stable service NAME (slug), never by raw numeric ID.
|
||||||
|
- Each entry answers WHAT / WHAT IT DOES / WHY / WHERE (see code comments).
|
||||||
|
- Doc-example exception = editing the named registry above → visible in diff.
|
||||||
|
- Modifier exception (until `modifiers.yaml` exists) = hand-written resource in
|
||||||
|
`provider/internal/resources_core/` + registration in `provider.go`.
|
||||||
|
- Never annotate API-YAML: it is machine-regenerated and edits would be lost.
|
||||||
|
|
||||||
|
Enforced by scripts (run before build/commit):
|
||||||
|
|
||||||
|
- `TOOLS/scripts/check_generated_drift.sh <stand>` — generated Go vs provider copy.
|
||||||
|
- `TOOLS/scripts/check_hardcoded_service_ids.sh` — forbids `svc.ID == N` /
|
||||||
|
`ServiceID == N` outside the registries.
|
||||||
|
|
||||||
|
Build rule: `provider/internal/resources_gen` and `provider/resources_yaml` are
|
||||||
|
ephemeral by design and never a build source. Canonical build is
|
||||||
|
`03_build_and_upload_provider.sh` (temp copy from `generated/<stand>/go`);
|
||||||
|
`build-provider.sh` refuses direct build from `provider/`. For local IDE,
|
||||||
|
`go build` and `go test`, materialize one stand first:
|
||||||
|
`TOOLS/scripts/dev-materialize.sh <stand>` (output is git-ignored).
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID.
|
||||||
|
#
|
||||||
|
# Ищет сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
|
||||||
|
# в Go-коде TOOLS/. Исключения должны жить ТОЛЬКО в именованных реестрах (данные):
|
||||||
|
# - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)
|
||||||
|
# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)
|
||||||
|
#
|
||||||
|
# Выход: 0 — хардкодов нет; 1 — найдены.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
||||||
|
|
||||||
|
matches="$(grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS" --include='*.go' || true)"
|
||||||
|
|
||||||
|
if [[ -n "$matches" ]]; then
|
||||||
|
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a registry):" >&2
|
||||||
|
echo "$matches" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Вынеси исключение в один из реестров:" >&2
|
||||||
|
echo " - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)" >&2
|
||||||
|
echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2
|
||||||
|
echo "См. TOOLS/ARCHITECTURE.md, раздел «Реестр исключений»." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: no hardcoded service IDs in TOOLS/."
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Internal HTTP helpers =====
|
||||||
|
|
||||||
|
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
|
||||||
|
// User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md).
|
||||||
|
// Go-http-client по умолчанию блокируется фильтром ddos-guard.
|
||||||
|
const maxRetries = 3
|
||||||
|
baseDelay := c.RetryBaseDelay
|
||||||
|
if baseDelay <= 0 {
|
||||||
|
baseDelay = 2 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body = bytes.NewBuffer(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := c.buildURL(path)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive.
|
||||||
|
// GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard.
|
||||||
|
if method != "GET" {
|
||||||
|
req.Close = true
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if c.ApiToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG
|
||||||
|
if isHTTPDebugEnabled() {
|
||||||
|
reqForDump := req.Clone(req.Context())
|
||||||
|
reqForDump.Header = sanitizeAuthHeader(req.Header)
|
||||||
|
dump, _ := httputil.DumpRequestOut(reqForDump, body != nil)
|
||||||
|
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG в файл
|
||||||
|
if isHTTPDebugEnabled() {
|
||||||
|
f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if f != nil {
|
||||||
|
fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String())
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
// Сетевые ошибки: retry только для идемпотентного GET.
|
||||||
|
if attempt < maxRetries && method == "GET" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if attempt < maxRetries {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry только для transient-ошибок и только для GET
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) {
|
||||||
|
lastErr = formatAPIError(resp.StatusCode, respBody)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, nil, formatAPIError(resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
return respBody, resp.Header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRetryable returns true for transient HTTP errors that can be retried.
|
||||||
|
func isRetryable(statusCode int) bool {
|
||||||
|
return statusCode == http.StatusTooManyRequests || // 429
|
||||||
|
statusCode == http.StatusServiceUnavailable || // 503
|
||||||
|
statusCode == http.StatusBadGateway || // 502
|
||||||
|
statusCode == http.StatusGatewayTimeout // 504
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {
|
||||||
|
respBody, headers, err := c.doRequest(ctx, "POST", path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if returnLocation {
|
||||||
|
if loc := headers.Get("Location"); loc != "" {
|
||||||
|
return extractUIDFromLocation(loc), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var justId string
|
||||||
|
if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" {
|
||||||
|
return justId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUIDFromLocation(loc string) string {
|
||||||
|
if loc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(loc, "./")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение.
|
||||||
|
// Если тело не является JSON с полем ERROR — возвращает сырой текст.
|
||||||
|
func formatAPIError(statusCode int, body []byte) error {
|
||||||
|
var parsed struct {
|
||||||
|
Error string `json:"ERROR"`
|
||||||
|
Detail string `json:"DETAIL"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" {
|
||||||
|
msg := parsed.Error
|
||||||
|
if d := strings.TrimSpace(parsed.Detail); d != "" {
|
||||||
|
msg += ": " + d
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ошибка API %d: %s", statusCode, msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Запуск операций инстанса (params по ID) =====
|
||||||
|
//
|
||||||
|
// - RunInstanceOperationUniversal — операция с params по ID (без досылки дефолтов)
|
||||||
|
// - RunInstanceOperationUniversalWithDefaults — то же + досылка required-дефолтов
|
||||||
|
// - RunRedeployOperation — redeploy
|
||||||
|
//
|
||||||
|
// Операции с params по code — см. operation_run_bycode.go.
|
||||||
|
// Схема cfsParams (с fallback) — см. operation_cfs.go.
|
||||||
|
|
||||||
|
// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible.
|
||||||
|
func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error {
|
||||||
|
state, err := c.GetInstanceState(ctx, instanceUid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.OperationIsPending || state.OperationIsInProgress {
|
||||||
|
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var opId int
|
||||||
|
for _, op := range state.AvailableOperations {
|
||||||
|
if strings.EqualFold(op.Operation, action) {
|
||||||
|
opId = op.SvcOperationId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opId == 0 {
|
||||||
|
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"instanceUid": instanceUid,
|
||||||
|
"svcOperationId": opId,
|
||||||
|
"operation": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||||||
|
}
|
||||||
|
if opUid == "" {
|
||||||
|
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
for paramId, value := range params {
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: paramId,
|
||||||
|
ParamValue: value,
|
||||||
|
}
|
||||||
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||||||
|
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInstanceOperationUniversalWithDefaults runs an operation and submits required params (including defaults).
|
||||||
|
func (c *UniversalClient) RunInstanceOperationUniversalWithDefaults(ctx context.Context, instanceUid string, action string, params map[int]string) error {
|
||||||
|
state, err := c.GetInstanceState(ctx, instanceUid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.OperationIsPending || state.OperationIsInProgress {
|
||||||
|
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var opId int
|
||||||
|
for _, op := range state.AvailableOperations {
|
||||||
|
if strings.EqualFold(op.Operation, action) {
|
||||||
|
opId = op.SvcOperationId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opId == 0 {
|
||||||
|
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"instanceUid": instanceUid,
|
||||||
|
"svcOperationId": opId,
|
||||||
|
"operation": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||||||
|
}
|
||||||
|
if opUid == "" {
|
||||||
|
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err = c.resolveRefSvcParamValues(ctx, cfsParams, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := make(map[int]bool)
|
||||||
|
for paramId, value := range params {
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: paramId,
|
||||||
|
ParamValue: value,
|
||||||
|
}
|
||||||
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||||||
|
}
|
||||||
|
sent[paramId] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
|
||||||
|
if liveErr != nil {
|
||||||
|
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
|
||||||
|
}
|
||||||
|
for _, param := range cfsParams {
|
||||||
|
if sent[param.SvcOperationCfsParamId] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приоритет: live state.params инстанса → paramValue операции → defaultValue.
|
||||||
|
// paramValue из cfsParams — дефолт формы, не состояние инстанса (см. operation_cfs.go).
|
||||||
|
// Симметрично runInstanceOperationByCode: если ни одного источника нет — пропускаем
|
||||||
|
// (иначе уйдёт синтетический "0"/"false"/"[]" и нарушит constraint).
|
||||||
|
val, hasLive := lookupLiveParam(live, param)
|
||||||
|
if !hasLive {
|
||||||
|
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
|
||||||
|
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if param.ParamValue != nil && strings.TrimSpace(*param.ParamValue) != "" {
|
||||||
|
val = *param.ParamValue
|
||||||
|
} else if param.DefaultValue != nil {
|
||||||
|
val = *param.DefaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = normalizeUniversalValueV6(val, param)
|
||||||
|
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
|
||||||
|
ParamValue: val,
|
||||||
|
}
|
||||||
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("валидация не пройдена: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||||||
|
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRedeployOperation запускает redeploy для сервисов, поддерживающих пересборку из git.
|
||||||
|
// Если params не nil — отправляет CFS-параметры перед запуском.
|
||||||
|
func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride string, params map[int]string) error {
|
||||||
|
state, err := c.GetInstanceState(ctx, instanceUid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.OperationIsPending || state.OperationIsInProgress {
|
||||||
|
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opId := 0
|
||||||
|
for _, op := range state.AvailableOperations {
|
||||||
|
if strings.EqualFold(op.Operation, "redeploy") {
|
||||||
|
opId = op.SvcOperationId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opId == 0 {
|
||||||
|
return fmt.Errorf("операция redeploy недоступна для экземпляра %s", instanceUid)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"instanceUid": instanceUid,
|
||||||
|
"svcOperationId": opId,
|
||||||
|
"operation": "redeploy",
|
||||||
|
}
|
||||||
|
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось создать операцию redeploy: %w", err)
|
||||||
|
}
|
||||||
|
if opUid == "" {
|
||||||
|
return fmt.Errorf("не удалось получить UID операции redeploy")
|
||||||
|
}
|
||||||
|
|
||||||
|
for paramId, value := range params {
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: paramId,
|
||||||
|
ParamValue: value,
|
||||||
|
}
|
||||||
|
if _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload); err != nil {
|
||||||
|
return fmt.Errorf("не удалось установить параметр %d для redeploy: %w", paramId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := c.operationTimeoutForContext(ctx, state.ServiceId, "redeploy")
|
||||||
|
if timeoutOverride != "" {
|
||||||
|
if d, parseErr := time.ParseDuration(timeoutOverride); parseErr == nil {
|
||||||
|
timeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.waitForOperationFinish(ctx, opUid, timeout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunInstanceOperationUniversalByCode runs an operation using params keyed by code.
|
||||||
|
// It resolves param codes to IDs via operation manifest, applies defaults, validates, and runs.
|
||||||
|
func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Context, instanceUid string, action string, params map[string]string) error {
|
||||||
|
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunInstanceOperationUniversalByIdempotent — то же, но с idempotency pre-check:
|
||||||
|
// перед run сверяет desired==current и, если ВСЕ поля совпали, пропускает run.
|
||||||
|
// Применяется для модификаторов с idempotency: check_before_run.
|
||||||
|
func (c *UniversalClient) RunInstanceOperationUniversalByIdempotent(ctx context.Context, instanceUid string, action string, params map[string]string) error {
|
||||||
|
return c.runInstanceOperationByCode(ctx, instanceUid, action, params, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instanceUid string, action string, params map[string]string, idempotent bool) error {
|
||||||
|
state, err := c.GetInstanceState(ctx, instanceUid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.OperationIsPending || state.OperationIsInProgress {
|
||||||
|
if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var opId int
|
||||||
|
for _, op := range state.AvailableOperations {
|
||||||
|
if strings.EqualFold(op.Operation, action) {
|
||||||
|
opId = op.SvcOperationId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opId == 0 {
|
||||||
|
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"instanceUid": instanceUid,
|
||||||
|
"svcOperationId": opId,
|
||||||
|
"operation": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось создать операцию %s: %w", action, err)
|
||||||
|
}
|
||||||
|
if opUid == "" {
|
||||||
|
return fmt.Errorf("не удалось получить UID операции для %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run.
|
||||||
|
// desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
|
||||||
|
if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
codeToParam := make(map[string]universalCfsParam)
|
||||||
|
for _, p := range cfsParams {
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
|
||||||
|
codeToParam[key] = p
|
||||||
|
}
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
|
||||||
|
codeToParam[key] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsByID := map[int]string{}
|
||||||
|
for code, value := range params {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(code))
|
||||||
|
p, ok := codeToParam[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("код параметра %s не найден для операции %s", code, action)
|
||||||
|
}
|
||||||
|
paramsByID[p.SvcOperationCfsParamId] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsByID, err = c.resolveRefSvcParamValues(ctx, cfsParams, paramsByID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := make(map[int]bool)
|
||||||
|
for paramId, value := range paramsByID {
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: paramId,
|
||||||
|
ParamValue: value,
|
||||||
|
}
|
||||||
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||||||
|
}
|
||||||
|
sent[paramId] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
live, liveErr := c.instanceLiveParams(ctx, instanceUid)
|
||||||
|
if liveErr != nil {
|
||||||
|
return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr)
|
||||||
|
}
|
||||||
|
for _, param := range cfsParams {
|
||||||
|
if sent[param.SvcOperationCfsParamId] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Дозаполняем ВСЕ незаданные параметры, чтобы бэкенд modify не трактовал
|
||||||
|
// пропущенный/null как reset-to-default (иначе частичный payload затирает
|
||||||
|
// create-поля, см. prompt_for_opus_modifier_null_bug.md).
|
||||||
|
//
|
||||||
|
// ПРИОРИТЕТ ИСТОЧНИКА: live state.params инстанса → paramValue операции → defaultValue.
|
||||||
|
// КРИТИЧНО: paramValue из ?fields=cfsParams — дефолт ФОРМЫ операции, не состояние
|
||||||
|
// инстанса (HAR/edge_.har: needEnableAVI paramValue="false" при live=true → стирало ALB).
|
||||||
|
// Если ни live, ни paramValue, ни defaultValue НЕТ — пропускаем (не шлём синтетический
|
||||||
|
// "0"/"false"/"[]", который может нарушить constraint "integer > 0").
|
||||||
|
val, hasLive := lookupLiveParam(live, param)
|
||||||
|
if !hasLive {
|
||||||
|
if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") &&
|
||||||
|
(param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if param.ParamValue != nil {
|
||||||
|
val = *param.ParamValue
|
||||||
|
} else if param.DefaultValue != nil {
|
||||||
|
val = *param.DefaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = normalizeUniversalValueV6(val, param)
|
||||||
|
|
||||||
|
pPayload := genericParamReq{
|
||||||
|
InstanceOperationUid: opUid,
|
||||||
|
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
|
||||||
|
ParamValue: val,
|
||||||
|
}
|
||||||
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("валидация не пройдена: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||||||
|
return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action))
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Нормализация значений CFS-параметров =====
|
||||||
|
|
||||||
|
// buildMapFixedDefault строит JSON-объект из дефолтов sub-параметров map-fixed.
|
||||||
|
func buildMapFixedDefault(param universalCfsParam) string {
|
||||||
|
result := make(map[string]string, len(param.DataDescriptor))
|
||||||
|
for key, sub := range param.DataDescriptor {
|
||||||
|
result[key] = sub.DefaultValue
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUniversalValueV6(val string, param universalCfsParam) string {
|
||||||
|
trimmed := strings.TrimSpace(val)
|
||||||
|
if strings.EqualFold(trimmed, "null") {
|
||||||
|
trimmed = ""
|
||||||
|
}
|
||||||
|
if trimmed == "\"\"" {
|
||||||
|
trimmed = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// map-fixed с DataDescriptor: если значение пустое или "{}" — строим JSON из дефолтов sub-params.
|
||||||
|
dataType := strings.ToLower(param.DataType)
|
||||||
|
if (dataType == "map-fixed" || strings.HasPrefix(dataType, "map")) && len(param.DataDescriptor) > 0 {
|
||||||
|
if trimmed == "" || trimmed == "{}" {
|
||||||
|
return buildMapFixedDefault(param)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam)
|
||||||
|
|
||||||
|
if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
if strings.Contains(dataType, "integer") || strings.Contains(dataType, "int") {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
if strings.Contains(dataType, "boolean") || strings.Contains(dataType, "bool") {
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package resources_core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &NsxtSnatResource{}
|
||||||
|
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
|
||||||
|
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
|
||||||
|
|
||||||
|
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
|
||||||
|
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
|
||||||
|
//
|
||||||
|
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
|
||||||
|
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
|
||||||
|
//
|
||||||
|
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
|
||||||
|
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
|
||||||
|
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
|
||||||
|
type NsxtSnatResource struct {
|
||||||
|
client *core.UniversalClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type NsxtSnatModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
NsxtUID types.String `tfsdk:"nsxt_uid"`
|
||||||
|
IpSpaceName types.String `tfsdk:"ip_space_name"`
|
||||||
|
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// noNeededIpSpace — каноническое значение «SNAT не нужен».
|
||||||
|
const noNeededIpSpace = "no-needed"
|
||||||
|
|
||||||
|
func NewNsxtSnatResource() resource.Resource {
|
||||||
|
return &NsxtSnatResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
|
||||||
|
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
|
||||||
|
"Значение `no-needed` выключает SNAT.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.UseStateForUnknown(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nsxt_uid": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ip_space_name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
|
||||||
|
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
|
||||||
|
},
|
||||||
|
"keep_on_destroy": schema.BoolAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: booldefault.StaticBool(false),
|
||||||
|
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
|
||||||
|
"`ipSpaceName = \"no-needed\"`).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan NsxtSnatModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan NsxtSnatModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state NsxtSnatModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||||
|
if nsxtUID == "" || r.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
|
||||||
|
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
|
||||||
|
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
|
||||||
|
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
|
||||||
|
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ID = types.StringValue(nsxtUID)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state NsxtSnatModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||||
|
if nsxtUID == "" || r.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"SNAT не выключался",
|
||||||
|
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||||
|
if err != nil {
|
||||||
|
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
|
||||||
|
// а SNAT останется включённым.
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"SNAT не выключался",
|
||||||
|
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := r.client.LockInstance(nsxtUID)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
|
||||||
|
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
|
||||||
|
"ipSpaceName": noNeededIpSpace,
|
||||||
|
}); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"SNAT выключен",
|
||||||
|
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||||
|
uid := strings.TrimSpace(req.ID)
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
|
||||||
|
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
|
||||||
|
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
|
||||||
|
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
|
||||||
|
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
|
||||||
|
uid := strings.TrimSpace(nsxtUID.ValueString())
|
||||||
|
if uid == "" {
|
||||||
|
return fmt.Errorf("nsxt_uid обязателен")
|
||||||
|
}
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("клиент не инициализирован")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
|
||||||
|
// Выключение SNAT — явное каноническое значение "no-needed".
|
||||||
|
value := strings.TrimSpace(ipSpaceName.ValueString())
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := r.client.LockInstance(uid)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
|
||||||
|
// а не с live-состоянием инстанса — можно ложно пропустить modify.
|
||||||
|
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||||
|
"ipSpaceName": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package resources_core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &OrgIpAllocationResource{}
|
||||||
|
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
||||||
|
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
||||||
|
|
||||||
|
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
||||||
|
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
||||||
|
//
|
||||||
|
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
|
||||||
|
// и адресуется здесь по uid.
|
||||||
|
//
|
||||||
|
// Семантика операции — replace всего массива: переданное значение полностью заменяет
|
||||||
|
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
|
||||||
|
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
|
||||||
|
type OrgIpAllocationResource struct {
|
||||||
|
client *core.UniversalClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgIpAllocationModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Organization types.String `tfsdk:"organization"`
|
||||||
|
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||||
|
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
|
||||||
|
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
|
||||||
|
type vipAllocation struct {
|
||||||
|
Name string
|
||||||
|
Count string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrgIpAllocationResource() resource.Resource {
|
||||||
|
return &OrgIpAllocationResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||||
|
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
|
||||||
|
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.UseStateForUnknown(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"organization": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
|
||||||
|
"или её UUID.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vip_configure": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||||
|
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
||||||
|
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
|
||||||
|
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
||||||
|
},
|
||||||
|
"keep_on_destroy": schema.BoolAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: booldefault.StaticBool(false),
|
||||||
|
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
|
||||||
|
"`count=0` по каждому элементу).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan OrgIpAllocationModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(orgUID)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan OrgIpAllocationModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(orgUID)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state OrgIpAllocationModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
// Организации больше нет — ресурс тоже не нужен.
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
|
||||||
|
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
|
||||||
|
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
|
||||||
|
raw, ok := live["vIPConfigure"]
|
||||||
|
if ok {
|
||||||
|
liveItems, parseErr := parseVipConfigure(raw)
|
||||||
|
if parseErr != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
|
||||||
|
if !vipAllocationsEqual(liveItems, stateItems) {
|
||||||
|
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ID = types.StringValue(orgUID)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state OrgIpAllocationModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"Аллокация IP не снималась",
|
||||||
|
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||||
|
if err != nil {
|
||||||
|
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
|
||||||
|
// а выделенные IP останутся висеть.
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"Аллокация IP не снималась",
|
||||||
|
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := r.client.LockInstance(orgUID)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
|
||||||
|
items := []vipAllocation{}
|
||||||
|
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
|
||||||
|
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
|
||||||
|
items = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
|
||||||
|
items = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"Аллокация IP не снималась",
|
||||||
|
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
|
||||||
|
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
|
||||||
|
zero := make([]vipAllocation, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
|
||||||
|
"vIPConfigure": formatVipConfigure(zero),
|
||||||
|
}); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Diagnostics.AddWarning(
|
||||||
|
"Квота IP обнулена",
|
||||||
|
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||||
|
uid := strings.TrimSpace(req.ID)
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
|
||||||
|
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
|
||||||
|
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
|
||||||
|
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return "", fmt.Errorf("клиент не инициализирован")
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(organization.ValueString())
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("organization обязателен")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
resolved = strings.TrimSpace(resolved)
|
||||||
|
if resolved == "" {
|
||||||
|
return "", fmt.Errorf("организация %q не найдена", raw)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
|
||||||
|
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
|
||||||
|
uid := strings.TrimSpace(orgUID)
|
||||||
|
if uid == "" {
|
||||||
|
return fmt.Errorf("organization обязателен")
|
||||||
|
}
|
||||||
|
if r.client == nil {
|
||||||
|
return fmt.Errorf("клиент не инициализирован")
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseVipConfigure(vipConfigure.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := r.client.LockInstance(uid)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
|
||||||
|
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
|
||||||
|
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
|
||||||
|
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||||
|
"vIPConfigure": formatVipConfigure(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVipConfigure разбирает значение параметра vIPConfigure.
|
||||||
|
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
|
||||||
|
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
|
||||||
|
func parseVipConfigure(raw string) ([]vipAllocation, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]vipAllocation, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
name := ""
|
||||||
|
if v, ok := item["name"]; ok && v != nil {
|
||||||
|
name = strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := "0"
|
||||||
|
if v, ok := item["count"]; ok && v != nil {
|
||||||
|
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
|
||||||
|
count = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, vipAllocation{Name: name, Count: count})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
|
||||||
|
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
|
||||||
|
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
|
||||||
|
func formatVipConfigure(items []vipAllocation) string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, ",") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
|
||||||
|
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
|
||||||
|
func vipAllocationsEqual(a, b []vipAllocation) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
byName := make(map[string]string, len(b))
|
||||||
|
for _, item := range b {
|
||||||
|
byName[item.Name] = item.Count
|
||||||
|
}
|
||||||
|
for _, item := range a {
|
||||||
|
count, ok := byName[item.Name]
|
||||||
|
if !ok || count != item.Count {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package resources_core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StateField struct {
|
||||||
|
Code string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputField struct {
|
||||||
|
Code string
|
||||||
|
Field string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
typeString = reflect.TypeOf(types.String{})
|
||||||
|
typeBool = reflect.TypeOf(types.Bool{})
|
||||||
|
typeInt64 = reflect.TypeOf(types.Int64{})
|
||||||
|
typeMap = reflect.TypeOf(types.Map{})
|
||||||
|
typeList = reflect.TypeOf(types.List{})
|
||||||
|
)
|
||||||
|
|
||||||
|
func RefreshResourceState[T any](ctx context.Context, client *core.UniversalClient, instanceID string, serviceID int, state T, outputs []StateField, inputs []InputField) (T, diag.Diagnostics) {
|
||||||
|
var diags diag.Diagnostics
|
||||||
|
if client == nil || strings.TrimSpace(instanceID) == "" {
|
||||||
|
return state, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
out, outDiags := FetchInstanceOutputs(ctx, client, instanceID)
|
||||||
|
diags.Append(outDiags...)
|
||||||
|
|
||||||
|
v := reflect.ValueOf(&state).Elem()
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return state, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
stateOutMap := map[string]string{}
|
||||||
|
if !out.StateOut.IsNull() && !out.StateOut.IsUnknown() {
|
||||||
|
mapped, mapDiags := StringMapFromTypesMap(ctx, out.StateOut)
|
||||||
|
diags.Append(mapDiags...)
|
||||||
|
if len(mapped) > 0 {
|
||||||
|
stateOutMap = mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range outputs {
|
||||||
|
code := strings.TrimSpace(field.Code)
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fieldName := toCamel(code)
|
||||||
|
fv := v.FieldByName(fieldName)
|
||||||
|
if !fv.IsValid() || !fv.CanSet() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case "state_params":
|
||||||
|
setFieldValue(fv, out.StateParams)
|
||||||
|
case "state_out":
|
||||||
|
setFieldValue(fv, out.StateOut)
|
||||||
|
case "state_params_flat":
|
||||||
|
setFieldValue(fv, out.StateParamsFlat)
|
||||||
|
case "state_out_flat":
|
||||||
|
setFieldValue(fv, out.StateOutFlat)
|
||||||
|
case "vault_secrets":
|
||||||
|
setFieldValue(fv, out.VaultSecrets)
|
||||||
|
case "vault_url":
|
||||||
|
setFieldValue(fv, out.VaultUrl)
|
||||||
|
case "vault_user_path":
|
||||||
|
setFieldValue(fv, out.VaultUserPath)
|
||||||
|
case "vault_fields":
|
||||||
|
setFieldValue(fv, out.VaultFields)
|
||||||
|
default:
|
||||||
|
if val, ok := stateOutMap[code]; ok {
|
||||||
|
setFieldValue(fv, ParseString(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsMap, paramsDiags := StringMapFromTypesMap(ctx, out.StateParams)
|
||||||
|
diags.Append(paramsDiags...)
|
||||||
|
if serviceID > 0 && len(paramsMap) > 0 {
|
||||||
|
paramsMap, paramsDiags = ResolveRefSvcParamDisplayNames(ctx, client, serviceID, paramsMap)
|
||||||
|
diags.Append(paramsDiags...)
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceRealm := strings.TrimSpace(paramsMap["resourceRealm"])
|
||||||
|
if resourceRealm != "" {
|
||||||
|
outFlatMap, flatDiags := StringMapFromTypesMap(ctx, out.StateOutFlat)
|
||||||
|
diags.Append(flatDiags...)
|
||||||
|
if len(outFlatMap) > 0 {
|
||||||
|
fixed := FixInternalConnectMasterSuffix(outFlatMap, resourceRealm)
|
||||||
|
if fixed {
|
||||||
|
out.StateOutFlat, diags = mapValueFrom(ctx, outFlatMap, diags)
|
||||||
|
fv := v.FieldByName(toCamel("state_out_flat"))
|
||||||
|
if fv.IsValid() && fv.CanSet() {
|
||||||
|
setFieldValue(fv, out.StateOutFlat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range inputs {
|
||||||
|
code := strings.TrimSpace(input.Code)
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Поле модели достаём ДО проверки наличия значения в API: оно нужно
|
||||||
|
// и в ветке "API не вернул код" (см. схлопывание unknown → null ниже).
|
||||||
|
fieldName := strings.TrimSpace(input.Field)
|
||||||
|
if fieldName == "" {
|
||||||
|
fieldName = toCamel(code)
|
||||||
|
}
|
||||||
|
fv := v.FieldByName(fieldName)
|
||||||
|
if !fv.IsValid() || !fv.CanSet() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := paramsMap[code]
|
||||||
|
if !ok {
|
||||||
|
// ИНВАРИАНТ: Computed-атрибут обязан быть KNOWN после apply/read.
|
||||||
|
//
|
||||||
|
// Параметры, которые провайдер читает обратно, объявлены в схеме как
|
||||||
|
// Optional+Computed (см. helpers.ShouldBeOptionalComputed). Если
|
||||||
|
// пользователь такой параметр не задал, в плане он = unknown, и именно
|
||||||
|
// провайдер обязан проставить конкретное значение. Когда платформа
|
||||||
|
// не вернула код в state_params, единственное корректное конкретное
|
||||||
|
// значение — null.
|
||||||
|
//
|
||||||
|
// Если оставить unknown, Terraform упадёт с
|
||||||
|
// "Provider produced invalid result object after apply: ... was unknown".
|
||||||
|
if inputFieldUnknown(fv) {
|
||||||
|
setInputFieldNull(fv)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(code, "jsonEnv") && fv.Type() == typeString {
|
||||||
|
// Preserve planned json_env when API returns equivalent JSON with different ordering.
|
||||||
|
if planned, ok := fv.Interface().(types.String); ok && !planned.IsNull() && !planned.IsUnknown() {
|
||||||
|
if JSONStringsEquivalent(planned.ValueString(), value) {
|
||||||
|
fv.Set(reflect.ValueOf(planned))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(input.Type)) {
|
||||||
|
case "bool":
|
||||||
|
if fv.Type() == typeBool {
|
||||||
|
fv.Set(reflect.ValueOf(ParseBool(value)))
|
||||||
|
}
|
||||||
|
case "int", "int64", "number":
|
||||||
|
if fv.Type() == typeInt64 {
|
||||||
|
fv.Set(reflect.ValueOf(ParseInt64(value)))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if fv.Type() == typeString {
|
||||||
|
fv.Set(reflect.ValueOf(ParseString(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputFieldUnknown сообщает, находится ли поле модели в состоянии unknown.
|
||||||
|
//
|
||||||
|
// Зачем: соблюдение инварианта «Computed-атрибут обязан быть known после
|
||||||
|
// apply/read». Если платформа не вернула значение в state_params, unknown
|
||||||
|
// оставлять нельзя — его надо схлопнуть в null (см. setInputFieldNull).
|
||||||
|
func inputFieldUnknown(fv reflect.Value) bool {
|
||||||
|
switch fv.Type() {
|
||||||
|
case typeString:
|
||||||
|
v, ok := fv.Interface().(types.String)
|
||||||
|
return ok && v.IsUnknown()
|
||||||
|
case typeBool:
|
||||||
|
v, ok := fv.Interface().(types.Bool)
|
||||||
|
return ok && v.IsUnknown()
|
||||||
|
case typeInt64:
|
||||||
|
v, ok := fv.Interface().(types.Int64)
|
||||||
|
return ok && v.IsUnknown()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setInputFieldNull записывает в поле модели типизированный null.
|
||||||
|
//
|
||||||
|
// Зачем: null — это конкретное (known) значение, в отличие от unknown. Именно
|
||||||
|
// null приводит состояние Terraform в консистентный вид, когда платформа не
|
||||||
|
// сообщила значение для read-back параметра.
|
||||||
|
func setInputFieldNull(fv reflect.Value) {
|
||||||
|
switch fv.Type() {
|
||||||
|
case typeString:
|
||||||
|
fv.Set(reflect.ValueOf(types.StringNull()))
|
||||||
|
case typeBool:
|
||||||
|
fv.Set(reflect.ValueOf(types.BoolNull()))
|
||||||
|
case typeInt64:
|
||||||
|
fv.Set(reflect.ValueOf(types.Int64Null()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFieldValue(field reflect.Value, value interface{}) {
|
||||||
|
switch field.Type() {
|
||||||
|
case typeString:
|
||||||
|
switch v := value.(type) {
|
||||||
|
case types.String:
|
||||||
|
field.Set(reflect.ValueOf(v))
|
||||||
|
case string:
|
||||||
|
field.Set(reflect.ValueOf(ParseString(v)))
|
||||||
|
}
|
||||||
|
case typeBool:
|
||||||
|
if v, ok := value.(types.Bool); ok {
|
||||||
|
field.Set(reflect.ValueOf(v))
|
||||||
|
}
|
||||||
|
case typeInt64:
|
||||||
|
if v, ok := value.(types.Int64); ok {
|
||||||
|
field.Set(reflect.ValueOf(v))
|
||||||
|
}
|
||||||
|
case typeMap:
|
||||||
|
if v, ok := value.(types.Map); ok {
|
||||||
|
field.Set(reflect.ValueOf(v))
|
||||||
|
}
|
||||||
|
case typeList:
|
||||||
|
if v, ok := value.(types.List); ok {
|
||||||
|
field.Set(reflect.ValueOf(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCamel(s string) string {
|
||||||
|
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' })
|
||||||
|
for i, p := range parts {
|
||||||
|
if len(p) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||||
|
}
|
||||||
|
out := strings.Join(parts, "")
|
||||||
|
if out == "" {
|
||||||
|
return "R"
|
||||||
|
}
|
||||||
|
first := rune(out[0])
|
||||||
|
if (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_' {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return "R" + out
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Список языковых моделей и агентов
|
||||||
|
|
||||||
|
> Данные перенесены из предоставленного списка. Достоверность названий, параметров и стоимости отдельно не проверялась.
|
||||||
|
|
||||||
|
## 1. DeepSeek
|
||||||
|
|
||||||
|
| Имя | Размер контекста | Возможности | Вход | Выход |
|
||||||
|
|---|---:|---|---:|---:|
|
||||||
|
| DeepSeek V4 Flash | 1M | Инструменты, Видение | Не указана | Не указана |
|
||||||
|
| DeepSeek V4 Flash Vision Exp | 1M | Инструменты, Видение | Не указана | Не указана |
|
||||||
|
| DeepSeek V4 Pro | 1M | Инструменты, Видение | Не указана | Не указана |
|
||||||
|
| DeepSeek V4.1 Hash | 1M | Инструменты, Видение | Не указана | Не указана |
|
||||||
|
|
||||||
|
## 2. Copilot
|
||||||
|
|
||||||
|
| Имя | Размер контекста | Возможности | Вход | Выход |
|
||||||
|
|---|---:|---|---:|---:|
|
||||||
|
| Auto | Не указан | Инструменты | Не указана | Не указана |
|
||||||
|
| Claude Fable 5 | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Fable 5.1 | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Haiku 4.5 | 160K | Инструменты, Видение | 100 | 500 |
|
||||||
|
| Claude Opus 4.7 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 4.8 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 4.8 (fast mode) (Preview) | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Opus 5 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 5.5 | 1M | Инструменты, Видение | 400 | 2000 |
|
||||||
|
| Claude Sonnet 5 | 1M | Инструменты, Видение | 200 | 1000 |
|
||||||
|
| Gemini 3.5 Flash | 1M | Инструменты, Видение | 150 | 900 |
|
||||||
|
| Gemini 3.6 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| Gemini 3.7 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| Gemini 3.8 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| GPT-5 mini | 192K | Инструменты, Видение | 25 | 200 |
|
||||||
|
| GPT-5.3-Codex | 400K | Инструменты, Видение | 175 | 1400 |
|
||||||
|
| GPT-5.4 | 1M | Инструменты, Видение | 250 | 1500 |
|
||||||
|
| GPT-5.4 mini | 400K | Инструменты, Видение | 75 | 450 |
|
||||||
|
| GPT-5.5 | 1M | Инструменты, Видение | 500 | 3000 |
|
||||||
|
| GPT-5.6 Luna | 1M | Инструменты, Видение | 20 | 120 |
|
||||||
|
| GPT-5.6 Sol | 1M | Инструменты, Видение | 400 | 2000 |
|
||||||
|
| GPT-5.6 Terra | 1M | Инструменты, Видение | 200 | 1200 |
|
||||||
|
| GPT-6 Astra | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| GPT-6 Luna | 1M | Инструменты, Видение | 10 | 50 |
|
||||||
|
|
||||||
|
## 3. Общий список языковых моделей
|
||||||
|
|
||||||
|
Стоимость указана в кредитах за 1 млн токенов.
|
||||||
|
|
||||||
|
| Имя | Размер контекста | Возможности | Вход | Выход |
|
||||||
|
|---|---:|---|---:|---:|
|
||||||
|
| Claude Fable 5 | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Fable 5.1 | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Haiku 4.5 | 160K | Инструменты, Видение | 100 | 500 |
|
||||||
|
| Claude Opus 4.7 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 4.8 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 4.8 (fast mode) | 1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| Claude Opus 5 | 1M | Инструменты, Видение | 500 | 2500 |
|
||||||
|
| Claude Opus 5.5 | 1.1M | Инструменты, Видение | 400 | 2000 |
|
||||||
|
| Claude Sonnet 5 | 1M | Инструменты, Видение | 200 | 1000 |
|
||||||
|
| Gemini 3.5 Flash | 1M | Инструменты, Видение | 150 | 900 |
|
||||||
|
| Gemini 3.6 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| Gemini 3.7 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| Gemini 3.8 Flash | 1M | Инструменты, Видение | 75 | 375 |
|
||||||
|
| GPT-5 mini | 192K | Инструменты, Видение | 25 | 200 |
|
||||||
|
| GPT-5.3-Codex | 400K | Инструменты, Видение | 175 | 1400 |
|
||||||
|
| GPT-5.4 | 1M | Инструменты, Видение | 250 | 1500 |
|
||||||
|
| GPT-5.4 mini | 400K | Инструменты, Видение | 75 | 450 |
|
||||||
|
| GPT-5.5 | 1M | Инструменты, Видение | 500 | 3000 |
|
||||||
|
| GPT-5.6 Luna | 1M | Инструменты, Видение | 20 | 120 |
|
||||||
|
| GPT-5.6 Sol | 1M | Инструменты, Видение | 400 | 2000 |
|
||||||
|
| GPT-5.6 Terra | 1M | Инструменты, Видение | 200 | 1200 |
|
||||||
|
| GPT-6 Astra | 1.1M | Инструменты, Видение | 1000 | 5000 |
|
||||||
|
| GPT-6 Luna | 1M | Инструменты, Видение | 10 | 50 |
|
||||||
|
| GPT-6 Sol | 1M | Инструменты, Видение | 200 | 1000 |
|
||||||
|
| Grok 4.5 | 500K / 553K | Инструменты, Видение | 200 | 600 |
|
||||||
|
| Grok 4.6 | 500K / 553K | Инструменты, Видение | 200 | 600 |
|
||||||
|
| Grok 4.7 | 500K / 553K | Инструменты, Видение | 200 | 600 |
|
||||||
|
| Kimi K2.7 Code | 256K | Инструменты, Видение | 95 | 400 |
|
||||||
|
| Kimi K3 | 1M | Инструменты, Видение | 300 | 1500 |
|
||||||
|
| MAI Code 1.1-Flash | 256K | Инструменты, Видение | 20 | 120 |
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# adidas Terrex Anylander Unisex Trail Shoes
|
||||||
|
|
||||||
|
## Навигация и доступность
|
||||||
|
|
||||||
|
- Нажмите Alt+1 для режима чтения с экрана.
|
||||||
|
- Нажмите Alt+0 для отмены.
|
||||||
|
- Используйте сайт в режиме чтения с экрана.
|
||||||
|
- Руководство по доступности для чтения с экрана, отзывов и сообщений о проблемах.
|
||||||
|
- Перейти к содержимому.
|
||||||
|
|
||||||
|
## Магазин
|
||||||
|
|
||||||
|
Cosmos Sport
|
||||||
|
|
||||||
|
- 22 594000
|
||||||
|
- Заработайте x10 CASHBACK при первой покупке GoX.
|
||||||
|
- EASY RETURN
|
||||||
|
- FIND YOUR ORDER
|
||||||
|
- «GOX» и найдите предложение.
|
||||||
|
- Новинки
|
||||||
|
- Мужчины
|
||||||
|
- Женщины
|
||||||
|
- Дети
|
||||||
|
- Аксессуары
|
||||||
|
- Спорт
|
||||||
|
- Бренды
|
||||||
|
- Распродажи
|
||||||
|
- Подарочная карта
|
||||||
|
|
||||||
|
## Предложения
|
||||||
|
|
||||||
|
- Новинки. Только что запущено.
|
||||||
|
- Не пропустите: заработайте до 20 € в GoX Wallet.
|
||||||
|
- Не пропустите: On Shoes.
|
||||||
|
- Откройте коллекцию.
|
||||||
|
- Чёрные кроссовки.
|
||||||
|
- Исследуйте коллекцию.
|
||||||
|
|
||||||
|
## Товар
|
||||||
|
|
||||||
|
**Главная → Спорт → Лыжи → adidas Terrex Anylander Unisex Trail Shoes**
|
||||||
|
|
||||||
|
- Посмотреть похожие товары
|
||||||
|
- Бренд: adidas Terrex
|
||||||
|
- Название: adidas Terrex Anylander Unisex Trail Shoes
|
||||||
|
- Цена: €75.00
|
||||||
|
- Артикул: `9000198351_63596`
|
||||||
|
- Размер: выберите размер
|
||||||
|
- Таблица размеров
|
||||||
|
- Добавить в корзину
|
||||||
|
- Оформить заказ
|
||||||
|
- Наличие в магазинах
|
||||||
|
- Самовывоз из магазина за 2 часа
|
||||||
|
- Выберите цвет
|
||||||
|
|
||||||
|
Участник GoX получает 0,75 € кэшбэка. Зарегистрироваться.
|
||||||
|
|
||||||
|
- Бесплатный возврат в течение 30 дней.
|
||||||
|
- BOX NOW Lockers: быстрая доставка 24/7.
|
||||||
|
- Ваш заказ уже в пути!
|
||||||
|
- История цен.
|
||||||
|
|
||||||
|
## Характеристики
|
||||||
|
|
||||||
|
- Вес: 390 г.
|
||||||
|
- Перепад: 10 мм.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
### Уход за обувью
|
||||||
|
|
||||||
|
### Доставка и возврат
|
||||||
|
|
||||||
|
Бесплатная доставка и возврат.
|
||||||
|
|
||||||
|
### Описание товара
|
||||||
|
|
||||||
|
adidas Terrex Anylander Unisex Trail Shoes предназначены для коротких прогулок по лесу и длительных однодневных походов. Эти туристические кроссовки adidas Terrex обеспечивают поддержку и комфорт на разных типах троп. Низкая посадка и мягкая амортизация в межподошве обеспечивают лёгкость и комфорт. Рельефная подошва Traxion даёт сцепление во всех направлениях и помогает уверенно держаться на поверхности.
|
||||||
|
|
||||||
|
Используя переработанные материалы, adidas повторно применяет уже созданные материалы и сокращает отходы. Возобновляемые материалы помогают снизить зависимость от ограниченных ресурсов. В моделях, изготовленных из смеси переработанных и возобновляемых материалов, их общая доля составляет не менее 20%.
|
||||||
|
|
||||||
|
### Особенности
|
||||||
|
|
||||||
|
- Обычная посадка.
|
||||||
|
- Шнуровка.
|
||||||
|
- Текстильный верх с усиленным носком.
|
||||||
|
- Текстильная подкладка.
|
||||||
|
- Межподошва EVA.
|
||||||
|
- Подошва Traxion.
|
||||||
|
- Вес: 390 г, размер UK 8,5.
|
||||||
|
- Перепад межподошвы: 10 мм; пятка 27 мм, носок 17 мм.
|
||||||
|
- Содержит не менее 20% переработанных и возобновляемых материалов.
|
||||||
|
- Цвет: чёрный.
|
||||||
|
- SKU: `ID0895`
|
||||||
|
- Артикул: `9000198351_63596`
|
||||||
|
|
||||||
|
## О бренде adidas Terrex
|
||||||
|
|
||||||
|
adidas Terrex — линейка экипировки adidas для пешего туризма, альпинизма, трейлраннинга и скалолазания. Она является значимым продолжением спортивных традиций adidas и предлагает инновационные решения для активного отдыха.
|
||||||
|
|
||||||
|
Линейка была запущена в конце 2000-х годов, когда adidas заметила растущую потребность в прочной и высокопроизводительной экипировке. Ассортимент Terrex значительно расширился и включает товары, отличающиеся долговечностью, технологичностью и функциональностью.
|
||||||
|
|
||||||
|
Terrex уделяет внимание устойчивому развитию и стремится уменьшить воздействие на окружающую среду. Бренд использует переработанные материалы и внедряет экологичные производственные практики.
|
||||||
|
|
||||||
|
adidas Terrex продолжает развиваться и внедрять инновации, помогая спортсменам расширять свои возможности и безопасно наслаждаться природой.
|
||||||
|
|
||||||
|
## Связанные категории
|
||||||
|
|
||||||
|
- Купить больше: мужская одежда, обувь и аксессуары.
|
||||||
|
- Купить больше: женская коллекция.
|
||||||
|
- Купить больше: мужская одежда, обувь и аксессуары → мужская обувь.
|
||||||
|
- Купить больше: женская коллекция → женская обувь.
|
||||||
|
- Купить больше: модели для трейлраннинга и походов.
|
||||||
|
- Купить больше: мужская одежда, обувь и аксессуары adidas.
|
||||||
|
- Купить больше: мужская одежда, обувь и аксессуары → мужская обувь → мужские трейловые кроссовки.
|
||||||
|
|
||||||
|
## Сервисы Cosmos Sport
|
||||||
|
|
||||||
|
- Электронная подарочная карта: подарки на выбор среди более чем 20 000 моделей; номинал от 20 €.
|
||||||
|
- Бесплатный Click & Collect в 40 пунктах.
|
||||||
|
- BOX NOW: бесплатная доставка в постаматы.
|
||||||
|
- GoX Cashback: 1% кэшбэка в GoX Wallet для следующих покупок.
|
||||||
|
- Магазин для спортсменов: более 60 брендов.
|
||||||
|
- Бесплатный Click & Collect в более чем 40 пунктах.
|
||||||
|
- Бесплатный возврат в течение 30 дней.
|
||||||
|
- Бесплатная доставка при заказе от 49 €.
|
||||||
|
|
||||||
|
## Новости и контакты
|
||||||
|
|
||||||
|
CosmosNews: получайте первые предложения со скидками до 30% и эксклюзивные предложения.
|
||||||
|
|
||||||
|
Интересует:
|
||||||
|
|
||||||
|
- Мужчина.
|
||||||
|
- Женщина.
|
||||||
|
- Ребёнок.
|
||||||
|
|
||||||
|
Популярные ссылки:
|
||||||
|
|
||||||
|
- Найти заказ.
|
||||||
|
- Бесплатная доставка при заказе от 49 €.
|
||||||
|
- Бесплатный возврат в течение 30 дней.
|
||||||
|
- Контакты.
|
||||||
|
- 22 594000.
|
||||||
|
- Часы работы колл-центра: понедельник–пятница 09:00–21:00, суббота 09:00–20:00.
|
||||||
|
|
||||||
|
## Компания
|
||||||
|
|
||||||
|
- Магазины и пункты выдачи.
|
||||||
|
- Наша история.
|
||||||
|
- GoX.
|
||||||
|
- Условия и положения.
|
||||||
|
- Вакансии.
|
||||||
|
- Условия использования.
|
||||||
|
- Корпоративная социальная ответственность.
|
||||||
|
- Cosmos People.
|
||||||
|
- Льготы.
|
||||||
|
- TEAM WEAR.
|
||||||
|
|
||||||
|
## Обслуживание
|
||||||
|
|
||||||
|
- Служба поддержки.
|
||||||
|
- Контакты.
|
||||||
|
- Бесплатный возврат любым способом.
|
||||||
|
- Доставка и получение товаров.
|
||||||
|
- Способы оплаты.
|
||||||
|
- Бесплатный Click & Collect.
|
||||||
|
- Часто задаваемые вопросы.
|
||||||
|
- Подарочные карты и электронные подарочные карты.
|
||||||
|
- Политика конфиденциальности.
|
||||||
|
- Файлы cookie.
|
||||||
|
- Декларация о файлах cookie.
|
||||||
|
|
||||||
|
Присоединяйтесь: Facebook, Instagram.
|
||||||
|
|
||||||
|
© 2026 CosmosSport. Разработано Sleed.
|
||||||
+65
-14
@@ -9,18 +9,21 @@ reflected here FIRST, then implemented in `gen_v2` and other tools.
|
|||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
1) YAML per service is generated ONLY from API data.
|
1) YAML per service is generated ONLY from API data.
|
||||||
2) The provider core is universal and must not contain service-specific logic.
|
2) The provider core (`provider/internal/core`) is universal and must not contain
|
||||||
3) Service-specific Go code is fully generated from YAML. No manual edits.
|
service-specific logic.
|
||||||
|
3) Service-specific Go code (`provider/internal/resources_gen`) is fully generated
|
||||||
|
from YAML. No manual edits to generated code.
|
||||||
4) Documentation is generated from the same YAML.
|
4) Documentation is generated from the same YAML.
|
||||||
5) Build artifacts for 3 OS targets are published to the registry, and docs are
|
5) Build artifacts for 3 OS targets are published to the registry, and docs are
|
||||||
published to the website.
|
published to the website.
|
||||||
6) `devops/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
|
6) `TOOLS/ARCHITECTURE.md` (this file) is the primary spec. Code follows.
|
||||||
|
|
||||||
## Service Selection
|
## Service Selection
|
||||||
|
|
||||||
- The inclusion list is defined by: `devops/config/services_list.txt` (repo-relative path)
|
- The inclusion list is defined by: `TOOLS/config/<dev|test|prod>/services_list.txt`
|
||||||
|
(по одному списку на стенд; repo-relative path)
|
||||||
- Each line starts with service_id, followed by service name/alias.
|
- Each line starts with service_id, followed by service name/alias.
|
||||||
- Operation timeouts source is defined by: `devops/config/operation_timeouts.json`.
|
- Operation timeouts source is defined by: `TOOLS/config/<dev|test|prod>/operation_timeouts.json`.
|
||||||
|
|
||||||
## API Endpoint
|
## API Endpoint
|
||||||
|
|
||||||
@@ -106,18 +109,38 @@ Each operation has a kind:
|
|||||||
|
|
||||||
## Provider Model
|
## Provider Model
|
||||||
|
|
||||||
- Core is universal: no service-specific logic inside the core.
|
- Core (`provider/internal/core`) is universal: no service-specific logic inside it.
|
||||||
- Generated service resources contain only schema/params and references.
|
- Generated service resources (`provider/internal/resources_gen`) contain only
|
||||||
|
schema/params and references.
|
||||||
|
- Hand-written service resources live in `provider/internal/resources_core` and are
|
||||||
|
registered in `provider/internal/provider/provider.go` `Resources()`. They are
|
||||||
|
NOT generated; they must not contain arbitrary service logic, only:
|
||||||
|
- a schema, and
|
||||||
|
- wiring between schema fields and the universal core API
|
||||||
|
(`RunInstanceOperationUniversalByCode`, `ResolveRefSvcParamValue`, etc.).
|
||||||
|
|
||||||
### API Resilience
|
### API Resilience
|
||||||
|
|
||||||
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
|
- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff).
|
||||||
Gateway may temporarily reject valid JWT tokens.
|
Gateway may temporarily reject valid JWT tokens.
|
||||||
- GET operations (GetInstanceState, GetInstanceStateRaw) retry 401 with 2s/4s/8s backoff.
|
- Implemented: `isRetryable` (`core/http.go`) includes 401 alongside {429, 502, 503, 504};
|
||||||
- `doRequest` treats 401 as retryable for GET requests (alongside 429, 502, 503, 504).
|
retry applies to GET requests only.
|
||||||
|
- Network errors and HTTP retryable statuses are retried for **GET only** (decided
|
||||||
|
2026-09-30). POST is NEVER retried: `POST /instanceOperations` is not idempotent, and a
|
||||||
|
blind retry would duplicate the operation. The API has no `Idempotency-Key` support.
|
||||||
|
- Draft operation on failure (decided 2026-09-30): if an error occurs after
|
||||||
|
`POST /instanceOperations` but before `.../run`, the operation remains created but was
|
||||||
|
never executed (the instance is unaffected). No cancellation API is known — neither the
|
||||||
|
provider code nor the captured HAR contain `DELETE /instanceOperations/{uid}`.
|
||||||
|
- Transient 401 is retried for GET (see `isRetryable`).
|
||||||
|
|
||||||
### Generated Code Resilience
|
### Generated Code Resilience
|
||||||
|
|
||||||
|
- **Partial state при ошибке create** (шаблон `instance.go`): если инстанс успел создаться
|
||||||
|
(`CreateResourceWithTimeout` вернул непустой uid ВМЕСТЕ с ошибкой), uid фиксируется в state
|
||||||
|
до вывода ошибки. Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный
|
||||||
|
`apply` упрётся в страж дубликатов. Ядро (`CreateGenericInstanceUniversalV6`) возвращает uid
|
||||||
|
при любой ошибке ПОСЛЕ создания инстанса; пустой uid — только если создание не состоялось.
|
||||||
- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует
|
- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует
|
||||||
в пользовательском `.tf`, подставлять zero-value по `dataType`:
|
в пользовательском `.tf`, подставлять zero-value по `dataType`:
|
||||||
- `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""`
|
- `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""`
|
||||||
@@ -207,28 +230,56 @@ From the unified YAML, generate:
|
|||||||
5) Upload provider artifacts to registry.
|
5) Upload provider artifacts to registry.
|
||||||
6) Build and publish docs to site.
|
6) Build and publish docs to site.
|
||||||
|
|
||||||
|
## Lifecycle Vocabulary (single contract)
|
||||||
|
|
||||||
|
**Target contract (decided 2026-09-30):** two runtime flags — `keep_on_destroy` and
|
||||||
|
`suspend_on_destroy` — express destroy behaviour for ALL resource kinds. The compile-time
|
||||||
|
`delete_strategy` (YAML) is a generator input that MAPS onto them:
|
||||||
|
|
||||||
|
| `delete_strategy` | Runtime meaning |
|
||||||
|
|---|---|
|
||||||
|
| `noop_warn` | `keep_on_destroy = true` (platform effect left untouched) |
|
||||||
|
| `inverse` | normal destroy (inverse modify is performed) |
|
||||||
|
| `error` | destroy refused with a validation error |
|
||||||
|
|
||||||
|
Current state (2026-09-30) — to be migrated:
|
||||||
|
|
||||||
|
1. generated instance resources: already `suspend_on_destroy` / `keep_on_destroy`;
|
||||||
|
2. generated modifiers: `delete_strategy` only (no runtime flag yet);
|
||||||
|
3. hand-written modifiers: already `keep_on_destroy`.
|
||||||
|
|
||||||
## Non-Negotiable Rules
|
## Non-Negotiable Rules
|
||||||
|
|
||||||
- No manual edits to generated YAML or generated Go code.
|
- No manual edits to generated YAML or generated Go code.
|
||||||
- Any change must come from API or generator logic updates.
|
- Any change to generated code must come from API or generator logic updates.
|
||||||
- The generator must enforce these rules and fail fast on drift.
|
- The generator must enforce these rules and fail fast on drift.
|
||||||
|
|
||||||
## Exception Registry (service-specific DATA, never logic)
|
## Exception Registry (service-specific DATA, never logic)
|
||||||
|
|
||||||
Principle: provider core and generator logic are universal for all stands and
|
Principle: provider core and generator logic are universal for all stands and
|
||||||
services. The ONLY allowed deviations are DATA entries, and they MUST live in
|
services. Service-specific deviations are of two kinds:
|
||||||
exactly two named registries:
|
|
||||||
|
1. **Generated modifiers** — a `modify` op that should become a dedicated modifier
|
||||||
|
resource. The generator (`TOOLS/resource-generator/internal/loader/loader.go`)
|
||||||
|
supports `kind: modifier` with `delete_strategy` (`noop_warn`/`inverse`/`error`)
|
||||||
|
and `idempotency` (`none`/`check_before_run`). The overlay data file
|
||||||
|
`modifiers.yaml` that would drive this is **documented but NOT yet created**;
|
||||||
|
until then, modifiers are hand-written in `provider/internal/resources_core/`.
|
||||||
|
(The legacy registry `serviceSpecificModifiers` in `TOOLS/yaml-generator/main.go`
|
||||||
|
was REMOVED during the 2026-09-23 refactoring.)
|
||||||
|
2. **Doc examples** — named registry:
|
||||||
|
|
||||||
| Registry | File | Declares |
|
| Registry | File | Declares |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `serviceSpecificModifiers` | `TOOLS/yaml-generator/main.go` | which service `modify` op becomes a modifier resource and its name (key = normalized service name) |
|
|
||||||
| `serviceSpecificDocExamples` | `TOOLS/docs-generator/internal/writers/writers.go` | per-service doc examples, gated on service name + required state/vault keys |
|
| `serviceSpecificDocExamples` | `TOOLS/docs-generator/internal/writers/writers.go` | per-service doc examples, gated on service name + required state/vault keys |
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Key by stable service NAME (slug), never by raw numeric ID.
|
- Key by stable service NAME (slug), never by raw numeric ID.
|
||||||
- Each entry answers WHAT / WHAT IT DOES / WHY / WHERE (see code comments).
|
- Each entry answers WHAT / WHAT IT DOES / WHY / WHERE (see code comments).
|
||||||
- Adding an exception = editing one of these two registries → visible in diff.
|
- Doc-example exception = editing the named registry above → visible in diff.
|
||||||
|
- Modifier exception (until `modifiers.yaml` exists) = hand-written resource in
|
||||||
|
`provider/internal/resources_core/` + registration in `provider.go`.
|
||||||
- Never annotate API-YAML: it is machine-regenerated and edits would be lost.
|
- Never annotate API-YAML: it is machine-regenerated and edits would be lost.
|
||||||
|
|
||||||
Enforced by scripts (run before build/commit):
|
Enforced by scripts (run before build/commit):
|
||||||
|
|||||||
@@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
Каждый инструмент — независимый Go-модуль.
|
Каждый инструмент — независимый Go-модуль.
|
||||||
|
|
||||||
|
## Канонический пайплайн (порядок шагов)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) YAML-спеки сервисов из API (per-stand!)
|
||||||
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/dev
|
||||||
|
|
||||||
|
# 2) Go-ресурсы + документация из этих YAML
|
||||||
|
./TOOLS/scripts/02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev
|
||||||
|
|
||||||
|
# 3) (релиз) сборка 3 платформ + публикация в реестр
|
||||||
|
./TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`--profile` обязателен: без него скрипты выходят с кодом 2 (никаких дефолтов).
|
||||||
|
|
||||||
|
### Шаг 1 — безопасная генерация YAML
|
||||||
|
|
||||||
|
`01_generate_yamls.sh` работает по принципу «сначала во временное, потом атомарная замена»:
|
||||||
|
|
||||||
|
- генерация идёт в staging-каталог `generated/<stand>/resources_yaml.staging.<pid>/`;
|
||||||
|
- рабочий `generated/<stand>/resources_yaml/` **не** удаляется и **не** модифицируется до полного успеха;
|
||||||
|
- при полном успехе старый каталог уезжает в бэкап `resources_yaml.bak-<UTC>`,
|
||||||
|
а staging встаёт на его место (атомарный `mv` в пределах одного FS),
|
||||||
|
хранятся последние `KEEP_BACKUPS` (по умолчанию 5);
|
||||||
|
- при любой ошибке замена **отменяется**: старый каталог цел, частичный результат лежит в staging для разбора, скрипт выходит с кодом 1;
|
||||||
|
- в каталоге лежит маркер `.stand`, защищающий от генерации не в тот стенд.
|
||||||
|
|
||||||
|
Перегенерировать все стенды подряд:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in dev test prod; do
|
||||||
|
./TOOLS/scripts/01_generate_yamls.sh --profile TOOLS/config/$s || break
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
> Примечание: часть параметров API отдаёт со случайным `default`-суффиксом
|
||||||
|
> (`db-ievgpdvu` → `db-ujama5rb` и т.п.), поэтому побайтовое сравнение двух
|
||||||
|
> прогонов даёт различия в этих строках — это не регрессия.
|
||||||
|
|
||||||
## yaml-generator
|
## yaml-generator
|
||||||
|
|
||||||
API Nubes → `resources_yaml/*.yaml`
|
API Nubes → `resources_yaml/*.yaml`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ TOKEN_FILE="secrets/dev.token"
|
|||||||
|
|
||||||
# Release versions
|
# Release versions
|
||||||
# Version
|
# Version
|
||||||
VERSION="2.0.17"
|
VERSION="2.0.0"
|
||||||
|
|
||||||
NAMESPACE="nubes-dev"
|
NAMESPACE="nubes-dev"
|
||||||
PROVIDER_NAME="nubes"
|
PROVIDER_NAME="nubes"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
148 vcMgmtSthutrvalCluster # Менеджмент Kubernetes кластер Штурвал
|
148 vcMgmtSthutrvalCluster # Менеджмент Kubernetes кластер Штурвал
|
||||||
149 valoTenant # VALO Cloud
|
149 valoTenant # VALO Cloud
|
||||||
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
|
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
|
||||||
# 151 k8sOpenbao # Vault — нет в PROD UI
|
151 k8sOpenbao # Vault
|
||||||
# 153 nifi # Nifi (DEV)
|
# 153 nifi # Nifi (DEV)
|
||||||
163 llmAi # LLM
|
163 llmAi # LLM
|
||||||
# 175 k8sGo # Go — нет в UI
|
# 175 k8sGo # Go — нет в UI
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Общий список сервисов — объединение DEV/TEST/PROD
|
|
||||||
# service_id service_name # RU description
|
|
||||||
# do not delete lines; to exclude a service, comment it with #
|
|
||||||
1 dummy # Болванка
|
|
||||||
2 template # Темплейт k8s
|
|
||||||
12 s3 # S3 Object Storage
|
|
||||||
13 s3bucket # S3 бакет
|
|
||||||
19 vcOrg # Организация в Cloud Director
|
|
||||||
# 20 vcOrgSaas # Организация [DEPRECATED] — нет в UI
|
|
||||||
21 vc_vdc # Виртуальный датацентр (vDC)
|
|
||||||
22 vc_nsxt # Сетевой шлюз периметра (Edge)
|
|
||||||
# 23 vc_vm # VM в Cloud Director (старый формат) (vc_vm) — нет в UI
|
|
||||||
# 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat)
|
|
||||||
25 vcexternalip # Публичные IP адреса
|
|
||||||
26 vapp # Виртуальный каталог ВМ (vApp)
|
|
||||||
27 vc_vm_v2 # VM в Cloud Director (vc_vmV2)
|
|
||||||
28 vc_vm_v3 # Виртуальная машина
|
|
||||||
29 vcVdcGroup # Группа датацентров
|
|
||||||
# 32 vmpostgre # vc_vm_postgresql_std
|
|
||||||
50 nextcloud # Nextcloud
|
|
||||||
81 superset # Apache Superset
|
|
||||||
82 harbor # Container Registry
|
|
||||||
86 k8sVelero # Velero
|
|
||||||
# 87 k8svalkey # Valkey — нет в UI
|
|
||||||
# 88 k8sZitiController # k8sZitiController — нет в UI
|
|
||||||
89 flask # Web-сервер с фреймворком Flask
|
|
||||||
90 postgres # PostgreSQL
|
|
||||||
91 redis # Redis
|
|
||||||
92 mongodb # MongoDB
|
|
||||||
93 rabbitmq # RabbitMQ
|
|
||||||
94 lucee # Lucee
|
|
||||||
95 nodejs # NodeJS
|
|
||||||
96 pgadmin # pgAdmin
|
|
||||||
# 97 nodered # NodeRed
|
|
||||||
98 http # Простой HTTP контейнер
|
|
||||||
99 gitea # Gitea
|
|
||||||
# 100 openwhisk # Serverless Openwhisk — нет в UI
|
|
||||||
109 zonesV2 # Управление DNS
|
|
||||||
# 110 dnszone # DNS зона — нет в UI
|
|
||||||
111 dnsrecord # DNS запись
|
|
||||||
# 112 tenant # Тенант в Grafana — нет в UI
|
|
||||||
# 113 vcComplex # Быстрый старт — нет в UI
|
|
||||||
# 114 GiteaComplex # Комплексная услуга по созданию gitea — нет в UI
|
|
||||||
115 mariadb # Mariadb
|
|
||||||
116 kafka # ApacheKafka
|
|
||||||
# 117 nifi # Nifi
|
|
||||||
119 Akhq # Akhq
|
|
||||||
120 clickhouse # ClickHouse
|
|
||||||
148 vcMgmtSthutrvalCluster # Менеджмент Kubernetes кластер Штурвал
|
|
||||||
149 valoTenant # VALO Cloud
|
|
||||||
150 k8sSthutrvalCluster # Kubernetes кластер Штурвал
|
|
||||||
151 k8sOpenbao # Vault
|
|
||||||
# 153 nifi # Nifi (DEV)
|
|
||||||
163 llmAi # LLM
|
|
||||||
# 175 k8sGo # Go — нет в UI
|
|
||||||
@@ -313,6 +313,12 @@ func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.Cre
|
|||||||
}
|
}
|
||||||
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
|
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Partial state (Q5): фиксируем ТОЛЬКО id. Остальные атрибуты плана могут быть
|
||||||
|
// unknown/computed — запись их в state даёт "invalid new value ... unknown" и
|
||||||
|
// маскирует исходную ошибку. Read затем синхронизирует реальное состояние.
|
||||||
|
if id != "" {
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(id))...)
|
||||||
|
}
|
||||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ resolve_root_path() {
|
|||||||
echo "${ROOT_DIR}/${path_value}"
|
echo "${ROOT_DIR}/${path_value}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Список сервисов — только из профиля стенда (TOOLS/config/<stand>/services_list.txt).
|
||||||
|
# Общий TOOLS/config/services_list.txt удалён 2026-09-30 (не использовался ни одним профилем).
|
||||||
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
|
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
|
||||||
if [[ -f "${PROFILE_DIR}/services_list.txt" ]]; then
|
|
||||||
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
|
|
||||||
fi
|
|
||||||
SERVICES_FILE="${SERVICES_FILE:-$SERVICES_FILE_DEFAULT}"
|
SERVICES_FILE="${SERVICES_FILE:-$SERVICES_FILE_DEFAULT}"
|
||||||
SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
|
SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
|
|||||||
REQUEST_DELAY="${REQUEST_DELAY:-0.5}"
|
REQUEST_DELAY="${REQUEST_DELAY:-0.5}"
|
||||||
ATTEMPTS="${ATTEMPTS:-3}"
|
ATTEMPTS="${ATTEMPTS:-3}"
|
||||||
|
|
||||||
# Auth: either an explicit token or a token file (latest *.token if not set).
|
# Auth: токен из NUBES_API_TOKEN или из файла TOKEN_FILE (profile.env).
|
||||||
TOKEN_FILE="${TOKEN_FILE:-}"
|
TOKEN_FILE="${TOKEN_FILE:-}"
|
||||||
NUBES_API_TOKEN="${NUBES_API_TOKEN:-}"
|
NUBES_API_TOKEN="${NUBES_API_TOKEN:-}"
|
||||||
if [[ -n "$TOKEN_FILE" ]]; then
|
if [[ -n "$TOKEN_FILE" ]]; then
|
||||||
@@ -62,37 +61,39 @@ if [[ -n "$TOKEN_FILE" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Назначение:
|
# Назначение:
|
||||||
# - Берет список сервисов из services_list.txt.
|
# - Берет список сервисов из services_list.txt профиля стенда.
|
||||||
# - Для каждого сервиса запрашивает единый spec через API и пишет YAML в
|
# - Для каждого сервиса запрашивает единый spec через API и пишет YAML в
|
||||||
# ${ROOT_DIR}/provider/resources_yaml.
|
# ${ROOT_DIR}/generated/<stand>/resources_yaml (per-stand, см. ниже).
|
||||||
# - Формат имени файла: ID_имя.yaml (например, 13_s3bucket.yaml).
|
# - Формат имени файла: ID_имя.yaml (например, 13_s3bucket.yaml).
|
||||||
#
|
#
|
||||||
|
# Безопасность записи (best practice, с 2026-09-30):
|
||||||
|
# - Генерация идёт во временный каталог (staging), старый каталог не удаляется заранее.
|
||||||
|
# - Замена каталога атомарна и делается ТОЛЬКО при полном успехе; предыдущий
|
||||||
|
# каталог уезжает в бэкап resources_yaml.bak-<UTC>, хранятся последние KEEP_BACKUPS.
|
||||||
|
# - При любом сбое рабочий каталог остаётся нетронутым, частичный результат — в staging.
|
||||||
|
#
|
||||||
# Источники данных:
|
# Источники данных:
|
||||||
# - Список сервисов: TOOLS/config/{stand}/services_list.txt
|
# - Список сервисов: TOOLS/config/{stand}/services_list.txt
|
||||||
# - API endpoint: ${API_ENDPOINT}
|
# - API endpoint: ${API_ENDPOINT} (обязателен, только из profile.env)
|
||||||
# - Токен: переменная NUBES_API_TOKEN или файл .token.
|
# - Токен: переменная NUBES_API_TOKEN или файл TOKEN_FILE (из profile.env).
|
||||||
#
|
#
|
||||||
# Формат services_list.txt:
|
# Формат services_list.txt:
|
||||||
# - В начале строки: service_id.
|
# - Поле 1: service_id (обязательно).
|
||||||
# - Остальное в строке — произвольная заметка для девопса (не используется).
|
# - Поле 2: имя сервиса (используется для лога; можно опустить).
|
||||||
|
# - Всё после # — заметка для девопса (не используется).
|
||||||
#
|
#
|
||||||
# Задержки и попытки:
|
# Задержки и попытки:
|
||||||
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.2).
|
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.5).
|
||||||
# - ATTEMPTS — число попыток на сервис (по умолчанию 3).
|
# - ATTEMPTS — число попыток на сервис (по умолчанию 3).
|
||||||
#
|
#
|
||||||
# Логи ошибок:
|
# Логи ошибок:
|
||||||
# - Список неуспешных сервисов пишется в /tmp/yaml_gen_failures.txt.
|
# - Список неуспешных сервисов пишется в generated/<stand>/tmp/yaml_gen_failures.txt.
|
||||||
#
|
#
|
||||||
# Токен берется из переменной NUBES_API_TOKEN.
|
# Токен: NUBES_API_TOKEN, иначе файл TOKEN_FILE (из profile.env).
|
||||||
# Если переменная пуста, берется файл .token из ${ROOT_DIR}:
|
# Легаси-поиск «последнего» *.token в корне репо удален (2026-09-30): токенов там
|
||||||
# - "последний" = файл с самым новым временем изменения (ls -t | head -n 1).
|
# нет, а такой поиск мог молча подхватить чужой токен.
|
||||||
if [[ -z "$NUBES_API_TOKEN" ]]; then
|
if [[ -z "$NUBES_API_TOKEN" && -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
|
||||||
if [[ -z "$TOKEN_FILE" ]]; then
|
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
|
||||||
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
|
|
||||||
fi
|
|
||||||
if [[ -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
|
|
||||||
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$NUBES_API_TOKEN" ]]; then
|
if [[ -z "$NUBES_API_TOKEN" ]]; then
|
||||||
@@ -105,12 +106,18 @@ if [[ ! -f "$SERVICES_FILE" ]]; then
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ⛔ LEGACY: deck-api.ngcloud.ru ЗАКРЫВАЕТСЯ. Default = Gateway. Override via NUBES_API_ENDPOINT.
|
# Endpoint ОБЯЗАТЕЛЕН: берется ТОЛЬКО из NUBES_API_ENDPOINT (profile.env).
|
||||||
API_ENDPOINT="${NUBES_API_ENDPOINT:-https://lk-api-gateway.ngcloud.ru/api/v1/svc}"
|
# Легаси-фолбэк на PROD gateway удален (2026-09-30): молчаливый уход в прод недопустим.
|
||||||
|
if [[ -z "${NUBES_API_ENDPOINT:-}" ]]; then
|
||||||
|
echo "Error: NUBES_API_ENDPOINT is required (set it in ${PROFILE_DIR}/profile.env)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
API_ENDPOINT="$NUBES_API_ENDPOINT"
|
||||||
# Auto-detect API style: if endpoint contains "index.cfm" → legacy proxy (?endpoint=),
|
# Auto-detect API style: if endpoint contains "index.cfm" → legacy proxy (?endpoint=),
|
||||||
# otherwise → new REST gateway (direct paths). No forced /index.cfm normalization.
|
# otherwise → new REST gateway (direct paths). No forced /index.cfm normalization.
|
||||||
|
|
||||||
GENERATED_DIR="${ROOT_DIR}/generated/$(basename "$PROFILE_DIR")"
|
STAND="$(basename "$PROFILE_DIR")"
|
||||||
|
GENERATED_DIR="${ROOT_DIR}/generated/${STAND}"
|
||||||
YAML_OUTPUT_DIR_DEFAULT="${GENERATED_DIR}/resources_yaml"
|
YAML_OUTPUT_DIR_DEFAULT="${GENERATED_DIR}/resources_yaml"
|
||||||
FAILURES_FILE_DEFAULT="${GENERATED_DIR}/tmp/yaml_gen_failures.txt"
|
FAILURES_FILE_DEFAULT="${GENERATED_DIR}/tmp/yaml_gen_failures.txt"
|
||||||
|
|
||||||
@@ -119,12 +126,27 @@ FAILURES_FILE="${FAILURES_FILE:-$FAILURES_FILE_DEFAULT}"
|
|||||||
YAML_OUTPUT_DIR="$(resolve_root_path "$YAML_OUTPUT_DIR")"
|
YAML_OUTPUT_DIR="$(resolve_root_path "$YAML_OUTPUT_DIR")"
|
||||||
FAILURES_FILE="$(resolve_root_path "$FAILURES_FILE")"
|
FAILURES_FILE="$(resolve_root_path "$FAILURES_FILE")"
|
||||||
|
|
||||||
# Убедимся, что папка есть. Полной очистки нет: обрабатываем только сервисы из списка.
|
YAML_PARENT_DIR="$(dirname "$YAML_OUTPUT_DIR")"
|
||||||
mkdir -p "$YAML_OUTPUT_DIR"
|
YAML_BASE_NAME="$(basename "$YAML_OUTPUT_DIR")"
|
||||||
|
mkdir -p "$YAML_PARENT_DIR"
|
||||||
mkdir -p "$(dirname "$FAILURES_FILE")"
|
mkdir -p "$(dirname "$FAILURES_FILE")"
|
||||||
|
|
||||||
if [[ ! -f "${YAML_OUTPUT_DIR}/embed.go" ]]; then
|
# Защита от генерации не в тот стенд: каталог помечается маркером .stand.
|
||||||
cat > "${YAML_OUTPUT_DIR}/embed.go" <<'EOF'
|
if [[ -d "$YAML_OUTPUT_DIR" && -f "${YAML_OUTPUT_DIR}/.stand" ]]; then
|
||||||
|
existing_stand="$(cat "${YAML_OUTPUT_DIR}/.stand" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$existing_stand" && "$existing_stand" != "$STAND" ]]; then
|
||||||
|
echo "Error: ${YAML_OUTPUT_DIR} belongs to stand '${existing_stand}', not '${STAND}'. Refusing." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Staging: старый каталог НЕ удаляется заранее, замена — атомарная (mv в конце).
|
||||||
|
STAGING_DIR="${YAML_OUTPUT_DIR}.staging.$$"
|
||||||
|
rm -rf "$STAGING_DIR"
|
||||||
|
mkdir -p "$STAGING_DIR"
|
||||||
|
|
||||||
|
# embed.go обязателен: пакет resources_yaml используется через go:embed *.yaml.
|
||||||
|
cat > "${STAGING_DIR}/embed.go" <<'EOF'
|
||||||
package resources_yaml
|
package resources_yaml
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
@@ -134,7 +156,7 @@ import "embed"
|
|||||||
//go:embed *.yaml
|
//go:embed *.yaml
|
||||||
var Files embed.FS
|
var Files embed.FS
|
||||||
EOF
|
EOF
|
||||||
fi
|
printf '%s\n' "$STAND" > "${STAGING_DIR}/.stand"
|
||||||
|
|
||||||
rm -f "$FAILURES_FILE"
|
rm -f "$FAILURES_FILE"
|
||||||
|
|
||||||
@@ -164,7 +186,12 @@ while IFS= read -r line; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
sid=$(echo "$line" | awk '{print $1}')
|
sid=$(echo "$line" | awk '{print $1}')
|
||||||
svc_name=""
|
# Имя сервиса берётся из 2-го поля services_list.txt. API ради имени не дёргаем:
|
||||||
|
# Go-генератор сам запрашивает spec и нормализует имя (это убирает лишний запрос).
|
||||||
|
svc_name="$(echo "$line" | awk '{print $2}')"
|
||||||
|
if [[ "$svc_name" == \#* ]]; then
|
||||||
|
svc_name=""
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$sid" ]]; then
|
if [[ -z "$sid" ]]; then
|
||||||
continue
|
continue
|
||||||
@@ -176,59 +203,13 @@ while IFS= read -r line; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Если имя не указано в списке — подтягиваем по API.
|
# Один YAML на сервис формируется Go-генератором — пишем во временный каталог.
|
||||||
if [[ -z "$svc_name" ]]; then
|
|
||||||
svc_name=$(python3 - <<PY
|
|
||||||
import json, sys, time, urllib.parse, urllib.request
|
|
||||||
|
|
||||||
sid = "${sid}"
|
|
||||||
endpoint = "${API_ENDPOINT}"
|
|
||||||
token = "${NUBES_API_TOKEN}"
|
|
||||||
max_retries = 3
|
|
||||||
|
|
||||||
# Auto-detect API style: "index.cfm" → legacy proxy, otherwise → REST gateway.
|
|
||||||
if "index.cfm" in endpoint:
|
|
||||||
params = urllib.parse.urlencode({"endpoint": f"/services/{sid}"})
|
|
||||||
url = f"{endpoint}?{params}"
|
|
||||||
else:
|
|
||||||
url = f"{endpoint}/services/{sid}"
|
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
if token:
|
|
||||||
req.add_header("Authorization", f"Bearer {token}")
|
|
||||||
req.add_header("User-Agent", "Mozilla/5.0 (compatible; Terraform-Provider-Nubes/BashGenerator)")
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
svc = data.get("svc", {})
|
|
||||||
name = svc.get("svcShort") or svc.get("name") or svc.get("title") or f"service_{sid}"
|
|
||||||
print(name)
|
|
||||||
sys.exit(0)
|
|
||||||
except Exception as e:
|
|
||||||
if attempt < max_retries:
|
|
||||||
time.sleep(2 * attempt)
|
|
||||||
else:
|
|
||||||
print(f"ERROR: failed to fetch service {sid} after {max_retries} attempts: {e}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
if [[ "$svc_name" == ERROR:* ]]; then
|
|
||||||
echo "$svc_name" >&2
|
|
||||||
echo "${sid} api_error" >> "$FAILURES_FILE"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Один YAML на сервис формируется Go-генератором.
|
|
||||||
echo "Generating unified spec for ${sid} (${svc_name})"
|
echo "Generating unified spec for ${sid} (${svc_name})"
|
||||||
success=0
|
success=0
|
||||||
# Generator normalizes names, so verify output by ID prefix only.
|
# Generator normalizes names, so verify output by ID prefix only.
|
||||||
output_glob="${YAML_OUTPUT_DIR}/${sid}_*.yaml"
|
output_glob="${STAGING_DIR}/${sid}_*.yaml"
|
||||||
|
|
||||||
# Для выбранного сервиса удаляем старый YAML и генерируем заново с ретраями.
|
|
||||||
rm -f "$output_glob"
|
|
||||||
|
|
||||||
|
# Старый YAML НЕ удаляем заранее: полная замена каталога — атомарная, в конце.
|
||||||
# Генерация YAML для выбранного сервиса с ретраями.
|
# Генерация YAML для выбранного сервиса с ретраями.
|
||||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||||
(
|
(
|
||||||
@@ -237,7 +218,7 @@ PY
|
|||||||
NUBES_SERVICE_ID="$sid" \
|
NUBES_SERVICE_ID="$sid" \
|
||||||
NUBES_SERVICE_NAME="$svc_name" \
|
NUBES_SERVICE_NAME="$svc_name" \
|
||||||
NUBES_API_ENDPOINT="$API_ENDPOINT" \
|
NUBES_API_ENDPOINT="$API_ENDPOINT" \
|
||||||
NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \
|
NUBES_OUTPUT_DIR="$STAGING_DIR" \
|
||||||
"${ROOT_DIR}/TOOLS/bin/yaml-generator"
|
"${ROOT_DIR}/TOOLS/bin/yaml-generator"
|
||||||
) && success=1 || success=0
|
) && success=1 || success=0
|
||||||
|
|
||||||
@@ -265,9 +246,28 @@ PY
|
|||||||
|
|
||||||
done < "$SERVICES_FILE"
|
done < "$SERVICES_FILE"
|
||||||
|
|
||||||
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
|
if [[ -s "$FAILURES_FILE" ]]; then
|
||||||
if [[ -f "$FAILURES_FILE" ]]; then
|
|
||||||
echo "Failed services (see $FAILURES_FILE):" >&2
|
echo "Failed services (see $FAILURES_FILE):" >&2
|
||||||
cat "$FAILURES_FILE" >&2
|
cat "$FAILURES_FILE" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Замена каталога ОТМЕНЕНА: рабочий ${YAML_OUTPUT_DIR} не тронут." >&2
|
||||||
|
echo "Частичный результат оставлен для разбора: $STAGING_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Атомарная замена: старый каталог уезжает в бэкап, новый встаёт на его место.
|
||||||
|
BACKUP_DIR="${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
if [[ -d "$YAML_OUTPUT_DIR" ]]; then
|
||||||
|
mv "$YAML_OUTPUT_DIR" "$BACKUP_DIR"
|
||||||
|
echo "Backup of previous specs: $BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
mv "$STAGING_DIR" "$YAML_OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Ротация бэкапов: держим последние KEEP_BACKUPS (по умолчанию 5).
|
||||||
|
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
|
||||||
|
mapfile -t stale_backups < <(ls -1dt "${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-"* 2>/dev/null | tail -n +"$((KEEP_BACKUPS + 1))")
|
||||||
|
if [[ "${#stale_backups[@]}" -gt 0 ]]; then
|
||||||
|
rm -rf -- "${stale_backups[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Generate docs using ClickHouse template (v2, no color markup).
|
|
||||||
# Canonical docs generator for unified resources_yaml.
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
|
||||||
PROVIDER_DIR="${ROOT_DIR}/provider"
|
|
||||||
RESOURCES_YAML_DIR="${PROVIDER_DIR}/resources_yaml"
|
|
||||||
DOCS_DIR="${ROOT_DIR}/docs/30_registry/resources"
|
|
||||||
SERVICES_LIST_PATH="${PROFILE_DIR}/services_list.txt"
|
|
||||||
|
|
||||||
cd "$PROVIDER_DIR"
|
|
||||||
|
|
||||||
${ROOT_DIR}/TOOLS/bin/docs-generator \
|
|
||||||
-resources "$RESOURCES_YAML_DIR" \
|
|
||||||
-docs "$DOCS_DIR" \
|
|
||||||
-services "$SERVICES_LIST_PATH" \
|
|
||||||
-exclude "clickhouse"
|
|
||||||
|
|
||||||
echo "Docs generated in ${DOCS_DIR}"
|
|
||||||
@@ -164,6 +164,13 @@ if [[ -n "${MKDOCS_DOCS_DIR:-}" ]]; then
|
|||||||
mkdir -p "${MKDOCS_DOCS_DIR}/curated"
|
mkdir -p "${MKDOCS_DOCS_DIR}/curated"
|
||||||
cp -r "${ROOT_DIR}/docs/curated/"* "${MKDOCS_DOCS_DIR}/curated/" 2>/dev/null || true
|
cp -r "${ROOT_DIR}/docs/curated/"* "${MKDOCS_DOCS_DIR}/curated/" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
# Картинки схемы зависимостей (docs/diagrams/*.svg|png) — чтобы их можно было
|
||||||
|
# показывать на опубликованных страницах. Исходники (.mmd, .py) не копируются.
|
||||||
|
if [[ -d "${ROOT_DIR}/docs/diagrams" ]]; then
|
||||||
|
mkdir -p "${MKDOCS_DOCS_DIR}/diagrams"
|
||||||
|
cp "${ROOT_DIR}/docs/diagrams/"*.svg "${MKDOCS_DOCS_DIR}/diagrams/" 2>/dev/null || true
|
||||||
|
cp "${ROOT_DIR}/docs/diagrams/"*.png "${MKDOCS_DOCS_DIR}/diagrams/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Per-стенд подстановка во все скопированные Markdown-файлы.
|
# Per-стенд подстановка во все скопированные Markdown-файлы.
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
|
||||||
SCRIPT="${ROOT_DIR}/TOOLS/scripts/01_generate_yamls.sh"
|
|
||||||
TOKEN_FILE="${TOKEN_FILE:-}"
|
|
||||||
REQUEST_DELAY="${REQUEST_DELAY:-0.2}"
|
|
||||||
ATTEMPTS="${ATTEMPTS:-3}"
|
|
||||||
RUNS="${RUNS:-10}"
|
|
||||||
LOG="${LOG:-/tmp/yaml_gen_runs.log}"
|
|
||||||
|
|
||||||
export TOKEN_FILE
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" ]]; then
|
|
||||||
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
|
|
||||||
echo "Error: token file not found in ${ROOT_DIR}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
: > "$LOG"
|
|
||||||
|
|
||||||
echo "Runs: $RUNS" | tee -a "$LOG"
|
|
||||||
echo "Request delay: $REQUEST_DELAY" | tee -a "$LOG"
|
|
||||||
echo "Attempts per service: $ATTEMPTS" | tee -a "$LOG"
|
|
||||||
echo "Token file: $TOKEN_FILE" | tee -a "$LOG"
|
|
||||||
|
|
||||||
echo "" | tee -a "$LOG"
|
|
||||||
|
|
||||||
for i in $(seq 1 "$RUNS"); do
|
|
||||||
start=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
echo "RUN $i start $start" | tee -a "$LOG"
|
|
||||||
REQUEST_DELAY="$REQUEST_DELAY" ATTEMPTS="$ATTEMPTS" "$SCRIPT" >> "$LOG" 2>&1
|
|
||||||
rc=$?
|
|
||||||
end=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
echo "RUN $i end $end rc=$rc" | tee -a "$LOG"
|
|
||||||
echo "" | tee -a "$LOG"
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Log: $LOG" | tee -a "$LOG"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
|
||||||
TOKEN_FILE="${TOKEN_FILE:-}"
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" ]]; then
|
|
||||||
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
|
|
||||||
echo "Error: token file not found in ${ROOT_DIR}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
export TOKEN_FILE
|
|
||||||
exec "${ROOT_DIR}/TOOLS/scripts/10_yaml_stability_run.sh"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
|
||||||
TOKEN_FILE="${TOKEN_FILE:-}"
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" ]]; then
|
|
||||||
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TOKEN_FILE" || ! -f "$TOKEN_FILE" ]]; then
|
|
||||||
echo "Error: token file not found in ${ROOT_DIR}" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
export TOKEN_FILE
|
|
||||||
exec "${ROOT_DIR}/TOOLS/scripts/01_generate_yamls.sh"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
|
||||||
PROVIDER_DIR="${ROOT_DIR}/provider"
|
|
||||||
|
|
||||||
if [[ ! -d "${PROVIDER_DIR}/resources_yaml" ]]; then
|
|
||||||
echo "Error: resources_yaml not found: ${PROVIDER_DIR}/resources_yaml" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start from a clean slate to avoid stale YAMLs.
|
|
||||||
rm -f "${PROVIDER_DIR}/resources_yaml"/*.yaml
|
|
||||||
|
|
||||||
exec "${ROOT_DIR}/TOOLS/scripts/12_generate_yamls_latest.sh"
|
|
||||||
@@ -3,27 +3,45 @@ set -euo pipefail
|
|||||||
|
|
||||||
# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID.
|
# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID.
|
||||||
#
|
#
|
||||||
# Ищет сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
|
# Ищет:
|
||||||
# в Go-коде TOOLS/. Исключения должны жить ТОЛЬКО в именованных реестрах (данные):
|
# 1) сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0)
|
||||||
# - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)
|
# 2) литеральные ref-service id в вызовах резолва:
|
||||||
|
# ResolveRefSvcParamValue(ctx, 19, ...) / ResolveRefSvcParamDisplayName(ctx, 22, ...)
|
||||||
|
#
|
||||||
|
# Область: TOOLS/** и provider/internal/** (кроме сгенерированного resources_gen/**).
|
||||||
|
# Исключения должны жить в именованных реестрах/константах (данные), а не как магические числа.
|
||||||
# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)
|
# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)
|
||||||
|
# - provider/internal/resources_core/*.go (именованные svcID* константы)
|
||||||
#
|
#
|
||||||
# Выход: 0 — хардкодов нет; 1 — найдены.
|
# Выход: 0 — хардкодов нет; 1 — найдены.
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
|
||||||
|
|
||||||
matches="$(grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS" --include='*.go' || true)"
|
SCAN_DIRS=("$ROOT_DIR/TOOLS" "$ROOT_DIR/provider/internal")
|
||||||
|
|
||||||
|
matches=""
|
||||||
|
for dir in "${SCAN_DIRS[@]}"; do
|
||||||
|
[[ -d "$dir" ]] || continue
|
||||||
|
# Сгенерированный код не сканируем — он не редактируется руками (см. ARCHITECTURE.md).
|
||||||
|
found="$(grep -rnE \
|
||||||
|
-e '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' \
|
||||||
|
-e '(ResolveRefSvcParamValue|ResolveRefSvcParamDisplayName)\([^,]+, *[1-9][0-9]*' \
|
||||||
|
"$dir" --include='*.go' --exclude-dir=resources_gen || true)"
|
||||||
|
if [[ -n "$found" ]]; then
|
||||||
|
matches+="$found"$'\n'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -n "$matches" ]]; then
|
if [[ -n "$matches" ]]; then
|
||||||
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a registry):" >&2
|
echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a named registry):" >&2
|
||||||
echo "$matches" >&2
|
printf '%s' "$matches" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Вынеси исключение в один из реестров:" >&2
|
echo "Вынеси исключение в именованный реестр/константу:" >&2
|
||||||
echo " - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)" >&2
|
|
||||||
echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2
|
echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2
|
||||||
echo "См. TOOLS/ARCHITECTURE.md, раздел «Реестр исключений»." >&2
|
echo " - provider/internal/resources_core/*.go (именованные svcID* константы)" >&2
|
||||||
|
echo "См. TOOLS/ARCHITECTURE.md, раздел «Exception Registry»." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "OK: no hardcoded service IDs in TOOLS/."
|
echo "OK: no hardcoded service IDs in TOOLS/ and provider/internal/."
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ type ServiceRef struct {
|
|||||||
|
|
||||||
// Load читает конфигурацию из переменных окружения и файлов.
|
// Load читает конфигурацию из переменных окружения и файлов.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://lk-api-gateway.ngcloud.ru/api/v1/svc")
|
// Endpoint ОБЯЗАТЕЛЕН. Легаси-дефолт на PROD gateway удален (2026-09-30):
|
||||||
|
// провайдер не должен молча ходить в прод при незаданном endpoint.
|
||||||
|
apiEndpoint := strings.TrimSpace(os.Getenv("NUBES_API_ENDPOINT"))
|
||||||
|
if apiEndpoint == "" {
|
||||||
|
return Config{}, errors.New("NUBES_API_ENDPOINT is required (no default: refusing to guess a stand endpoint)")
|
||||||
|
}
|
||||||
apiEndpoint = normalize.APIEndpoint(apiEndpoint)
|
apiEndpoint = normalize.APIEndpoint(apiEndpoint)
|
||||||
apiToken, err := loadToken()
|
apiToken, err := loadToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,13 +56,11 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
services := []ServiceRef{}
|
services := []ServiceRef{}
|
||||||
if singleID == 0 {
|
if singleID == 0 {
|
||||||
|
// Без per-service ID нужен явный список. Угадывание пути удалено (2026-09-30):
|
||||||
|
// старый дефолт указывал на несуществующий provider/devops/config/services_list.txt.
|
||||||
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
|
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
|
||||||
if listPath == "" {
|
if listPath == "" {
|
||||||
repoRoot, err := FindRepoRoot()
|
return Config{}, errors.New("NUBES_SERVICES_FILE is required when NUBES_SERVICE_ID is not set")
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt")
|
|
||||||
}
|
}
|
||||||
list, err := ReadServicesList(listPath)
|
list, err := ReadServicesList(listPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,14 +79,6 @@ func Load() (Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getenvDefault(key string, def string) string {
|
|
||||||
val := strings.TrimSpace(os.Getenv(key))
|
|
||||||
if val == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadToken() (string, error) {
|
func loadToken() (string, error) {
|
||||||
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
|
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
|
||||||
return tok, nil
|
return tok, nil
|
||||||
@@ -95,46 +90,9 @@ func loadToken() (string, error) {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(b)), nil
|
return strings.TrimSpace(string(b)), nil
|
||||||
}
|
}
|
||||||
repoRoot, err := FindRepoRoot()
|
// Легаси-фолбэк «найти последний *.token в корне репо» удален (2026-09-30):
|
||||||
if err != nil {
|
// токенов там нет, а такой поиск мог молча подхватить чужой токен.
|
||||||
return "", err
|
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
|
||||||
}
|
|
||||||
latest, err := findLatestToken(repoRoot)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if latest == "" {
|
|
||||||
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
|
|
||||||
}
|
|
||||||
b, err := os.ReadFile(latest)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(b)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findLatestToken(dir string) (string, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var latest string
|
|
||||||
var latestTime int64
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mt := info.ModTime().Unix()
|
|
||||||
if mt > latestTime {
|
|
||||||
latestTime = mt
|
|
||||||
latest = filepath.Join(dir, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return latest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadServicesList читает список сервисов из файла.
|
// ReadServicesList читает список сервисов из файла.
|
||||||
|
|||||||
+9
-5
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
> Обновлять после **каждой** заливки. Это единственный источник правды.
|
> Обновлять после **каждой** заливки. Это единственный источник правды.
|
||||||
|
|
||||||
| Стенд | Namespace | Версия | Дата заливки |
|
| Стенд | Namespace | Версия | Дата заливки | Примечание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
|
| PROD | `nubes` | `1.0.0` | 2026-09-30 | **перезалит свежей сборкой** (перегенерация YAML + сборка из текущего кода) |
|
||||||
| DEV | `nubes-dev` | `2.0.23` | 2026-09-24 | (fix: UUID внутри JSON нормализуется к lowercase при сравнении (adopt suspended-инстанса падал на регистре `nsxtUid`) + `JsonNormalize` приводит UUID-подстроки; тесты) |
|
| DEV | `nubes-dev` | `2.0.0` | 2026-09-30 | **перезалит свежей сборкой**; в реестре также остаются `2.0.21`–`2.0.24` |
|
||||||
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
|
| TEST | `nubes-test` | `3.0.0` | 2026-09-30 | **перезалит свежей сборкой** |
|
||||||
|
|
||||||
|
> ⚠️ **Хранение в реестре (2026-09-30):** в dev-реестре (`nubes-dev/nubes`) оставлены только
|
||||||
|
> `2.0.21`–`2.0.24`; версии `2.0.0`–`2.0.20` удалены физически (бакет un-versioned).
|
||||||
|
> Подробности и проверки: `HISTORY/2026-09-30_dev_registry_prune_versions.md`.
|
||||||
|
|
||||||
## Как проверить
|
## Как проверить
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
| Виртуальный датацентр (21) | `nubes_vc_vdc` | создаёт vDC с ресурсами (CPU/RAM/Storage) |
|
| Виртуальный датацентр (21) | `nubes_vc_vdc` | создаёт vDC с ресурсами (CPU/RAM/Storage) |
|
||||||
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt` | создаёт Edge (routed-сеть, ALB/AVI) |
|
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt` | создаёт Edge (routed-сеть, ALB/AVI) |
|
||||||
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt_snat` | **модификатор**: включает/переключает SNAT (`ipSpaceName`) на существующем Edge |
|
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt_snat` | **модификатор**: включает/переключает SNAT (`ipSpaceName`) на существующем Edge |
|
||||||
|
| Виртуальный каталог ВМ (26) | `nubes_vapp` | создаёт vApp (каталог для ВМ) на существующих vDC и Edge |
|
||||||
|
| Виртуальная машина (28) | `nubes_vc_vm_v3` | создаёт ВМ внутри vApp (CPU/RAM/диск, образ, SSH-ключ, внешний IP) |
|
||||||
| Kubernetes кластер Штурвал (150) | `nubes_k8s_sthutrval_cluster` | создаёт кластер (control plane + группы воркеров) |
|
| Kubernetes кластер Штурвал (150) | `nubes_k8s_sthutrval_cluster` | создаёт кластер (control plane + группы воркеров) |
|
||||||
|
|
||||||
Общее правило. Ресурс, у которого есть свой «объект в облаке», **создаёт** этот объект. А то, что платформа
|
Общее правило. Ресурс, у которого есть свой «объект в облаке», **создаёт** этот объект. А то, что платформа
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
|
|
||||||
| Флаг | Где применим | Поведение при `destroy` | Предупреждение в выводе |
|
| Флаг | Где применим | Поведение при `destroy` | Предупреждение в выводе |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `suspend_on_destroy = true` | кластер Штурвала, vDC (по умолчанию `true`) | объект **приостанавливается**, не удаляется | «Ресурс заморожен, а не удалён» |
|
| `suspend_on_destroy = true` | кластер Штурвала, vDC, vApp, ВМ (по умолчанию `true`) | объект **приостанавливается**, не удаляется | «Ресурс заморожен, а не удалён» |
|
||||||
| `keep_on_destroy = true` | Edge, SNAT, квота IP (по умолчанию `false`) | объект **не трогается в облаке**, только убирается из state | «Оставлен как есть» (для SNAT — «SNAT не выключался», для квоты IP — «Аллокация IP не снималась») |
|
| `keep_on_destroy = true` | Edge, SNAT, квота IP (по умолчанию `false`) | объект **не трогается в облаке**, только убирается из state | «Оставлен как есть» (для SNAT — «SNAT не выключался», для квоты IP — «Аллокация IP не снималась») |
|
||||||
| оба `false` | любой | обычное удаление | — |
|
| оба `false` | любой | обычное удаление | — |
|
||||||
|
|
||||||
@@ -96,7 +98,8 @@ Edge **физически не умеет `suspend`**: в списке дост
|
|||||||
то есть SNAT для виртуальных машин остаётся в прежнем состоянии.
|
то есть SNAT для виртуальных машин остаётся в прежнем состоянии.
|
||||||
- Квота внешних IP: уменьшать `count` **ниже фактически занятых адресов платформа не разрешает**
|
- Квота внешних IP: уменьшать `count` **ниже фактически занятых адресов платформа не разрешает**
|
||||||
(ошибка вида «Кол-во занятых Ip … Невозможно выставить параметр count ниже этого параметра»).
|
(ошибка вида «Кол-во занятых Ip … Невозможно выставить параметр count ниже этого параметра»).
|
||||||
Адреса держит кластер Штурвала (API + ingress), и `suspend` кластера их **не освобождает**.
|
Адреса держит кластер Штурвала (API + ingress), и `suspend` кластера их **не освобождает**;
|
||||||
|
если у ВМ есть внешний адрес, он тоже держится инстансом ВМ — даже остановленной.
|
||||||
Поэтому при «заморозке» квота не изменяется вовсе, а реальное освобождение адресов возможно только после
|
Поэтому при «заморозке» квота не изменяется вовсе, а реальное освобождение адресов возможно только после
|
||||||
удаления кластера — отдельным шагом.
|
удаления кластера — отдельным шагом.
|
||||||
|
|
||||||
@@ -109,6 +112,8 @@ Edge **физически не умеет `suspend`**: в списке дост
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Кластер Штурвала | `suspend` | adopt по имени + `resume` |
|
| Кластер Штурвала | `suspend` | adopt по имени + `resume` |
|
||||||
| vDC | `suspend` | adopt по имени + `resume` |
|
| vDC | `suspend` | adopt по имени + `resume` |
|
||||||
|
| vApp | `suspend` | adopt по имени + `resume` |
|
||||||
|
| ВМ | `suspend` | adopt по имени + `resume` |
|
||||||
| Edge | не трогается (`running`) | adopt (инстанс уже работает) |
|
| Edge | не трогается (`running`) | adopt (инстанс уже работает) |
|
||||||
| SNAT | не трогается (включён) | повторный `modify` теми же значениями (фактически no-op) |
|
| SNAT | не трогается (включён) | повторный `modify` теми же значениями (фактически no-op) |
|
||||||
| Квота IP | не трогается | `modify` с тем же `count` (no-op) |
|
| Квота IP | не трогается | `modify` с тем же `count` (no-op) |
|
||||||
@@ -117,7 +122,7 @@ Edge **физически не умеет `suspend`**: в списке дост
|
|||||||
Полностью удалить такой стенд можно только явным отказом от заморозки (`keep_on_destroy = false` /
|
Полностью удалить такой стенд можно только явным отказом от заморозки (`keep_on_destroy = false` /
|
||||||
`suspend_on_destroy = false`) и в правильном порядке (см. §6).
|
`suspend_on_destroy = false`) и в правильном порядке (см. §6).
|
||||||
|
|
||||||
## 6. Конкретный пайплайн стенда: vDC → Edge → внешние IP → SNAT → Штурвал
|
## 6. Конкретный пайплайн стенда: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал
|
||||||
|
|
||||||
Порядок создания и зависимости:
|
Порядок создания и зависимости:
|
||||||
|
|
||||||
@@ -125,14 +130,24 @@ Edge **физически не умеет `suspend`**: в списке дост
|
|||||||
2. `nubes_vc_vdc` — виртуальный датацентр.
|
2. `nubes_vc_vdc` — виртуальный датацентр.
|
||||||
3. `nubes_vc_nsxt` — Edge. Обязательно включать балансировщик (параметр `need_enable_avi = true`)
|
3. `nubes_vc_nsxt` — Edge. Обязательно включать балансировщик (параметр `need_enable_avi = true`)
|
||||||
и задать не меньше 3 виртуальных сервисов (`virtual_services_count >= 3`) — это нужно кластеру Штурвала.
|
и задать не меньше 3 виртуальных сервисов (`virtual_services_count >= 3`) — это нужно кластеру Штурвала.
|
||||||
4. `nubes_vc_org_ip_allocation` — внешние адреса в организации (минимум 3 по инструкции услуги Штурвала).
|
4. `nubes_vc_org_ip_allocation` — внешние адреса в организации (минимум 3 по инструкции услуги Штурвала,
|
||||||
|
плюс ещё один, если у ВМ будет внешний адрес).
|
||||||
5. `nubes_vc_nsxt_snat` — SNAT на Edge (по `depends_on` после аллокации адресов).
|
5. `nubes_vc_nsxt_snat` — SNAT на Edge (по `depends_on` после аллокации адресов).
|
||||||
6. `nubes_k8s_sthutrval_cluster` — кластер Штурвала (по `depends_on` после SNAT: нодам нужен выход в интернет).
|
6. `nubes_vapp` — vApp: привязка к vDC (`vdc_uid`) и Edge (`nsxt_uid`); создаётся после SNAT,
|
||||||
|
чтобы сеть была уже готова.
|
||||||
|
7. `nubes_vc_vm_v3` — ВМ внутри vApp (`vapp_uid`): образ, CPU/RAM/диск, учётка и SSH-ключ, порты FW.
|
||||||
|
8. `nubes_k8s_sthutrval_cluster` — кластер Штурвала (по `depends_on` после SNAT: нодам нужен выход в интернет).
|
||||||
|
|
||||||
При удалении Terraform идёт в обратном порядке. Ограничения платформы, которые встречаются на этом пути:
|
При удалении Terraform идёт в обратном порядке. Ограничения платформы, которые встречаются на этом пути:
|
||||||
|
|
||||||
- **1 кластер Штурвала = 1 vDC** (действующее ограничение услуги).
|
- **1 кластер Штурвала = 1 vDC** (действующее ограничение услуги).
|
||||||
- vDC удаляется только **через 14 дней после `suspend`**; при живых Edge/vApp/VM/кластере — через поддержку.
|
- vDC удаляется только **через 14 дней после `suspend`**; при живых Edge/vApp/VM/кластере — через поддержку.
|
||||||
|
- У vApp удаление требует **предварительного `suspend`**, полное удаление — тоже через 14 дней.
|
||||||
|
- У vApp и ВМ есть `suspend` и `resume`; у Edge операции `suspend` нет вообще.
|
||||||
|
- **ВМ не включится, если в vDC не осталось vCPU**: Штурвал при настройках по умолчанию занимает всё
|
||||||
|
(2 ноды × 4 vCPU при `vdc_cpu_allocated = 8`). Платформа отвечает общей ошибкой
|
||||||
|
`[400:VALIDATION] Unable to perform this action` на этапе power-on, инстанс остаётся в `not created`.
|
||||||
|
- Создание ВМ идёт дольше остальных ресурсов (customization + power-on) — задавайте `operation_timeout = "15m"`.
|
||||||
- Edge не удаляется при живых зависимых объектах (по инструкции услуги).
|
- Edge не удаляется при живых зависимых объектах (по инструкции услуги).
|
||||||
- Квота IP не опускается ниже занятых адресов (см. §5).
|
- Квота IP не опускается ниже занятых адресов (см. §5).
|
||||||
|
|
||||||
@@ -198,7 +213,7 @@ UUID в облаке не имеет «правильного» регистра
|
|||||||
|
|
||||||
## 10. Куда смотреть дальше
|
## 10. Куда смотреть дальше
|
||||||
|
|
||||||
- [Как развернуть vDC, Edge, внешние IP, SNAT и кластер Штурвал](https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/vdc_edge_ip_snat/) —
|
- [Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал](https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-dev/curated/pipeline/vdc_edge_ip_snat/) —
|
||||||
пошаговая инструкция по всей цепочке вашего стенда.
|
пошаговая инструкция по всей цепочке вашего стенда.
|
||||||
- `curated/modifiers/org_ip_and_snat.md` — ресурсы-модификаторы.
|
- `curated/modifiers/org_ip_and_snat.md` — ресурсы-модификаторы.
|
||||||
- `30_registry/guides/terraform-structure.md` — структура манифестов.
|
- `30_registry/guides/terraform-structure.md` — структура манифестов.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Зависимости облачных сервисов каталога DEV
|
||||||
|
|
||||||
|
Промежуточная инвентаризация для будущего общего графа. Источник — YAML-контракты
|
||||||
|
в `generated/dev/resources_yaml`.
|
||||||
|
|
||||||
|
Вершина — сервис каталога. Направление будущей стрелки: `потребитель -> зависимость`.
|
||||||
|
В таблицу зависимостей включены только связи, явно подтверждённые полем
|
||||||
|
`ref_svc_id` или тем же контрактом сервиса.
|
||||||
|
|
||||||
|
## Все сервисы каталога
|
||||||
|
|
||||||
|
| ID | Сервис | YAML |
|
||||||
|
|---:|---|---|
|
||||||
|
| 1 | Болванка | `1_dummy.yaml` |
|
||||||
|
| 2 | Темплейт k8s | `2_template.yaml` |
|
||||||
|
| 12 | S3 Object Storage | `12_s3.yaml` |
|
||||||
|
| 13 | S3 бакет | `13_s3bucket.yaml` |
|
||||||
|
| 19 | Организация в Cloud Director | `19_vc_org.yaml` |
|
||||||
|
| 21 | Виртуальный датацентр (vDC) | `21_vc_vdc.yaml` |
|
||||||
|
| 22 | Сетевой шлюз периметра (Edge) | `22_vc_nsxt.yaml` |
|
||||||
|
| 25 | Публичные IP адреса | `25_vcexternalip.yaml` |
|
||||||
|
| 26 | Виртуальный каталог ВМ (vApp) | `26_vapp.yaml` |
|
||||||
|
| 28 | Виртуальная машина | `28_vc_vm_v3.yaml` |
|
||||||
|
| 29 | Группа датацентров | `29_vc_vdc_group.yaml` |
|
||||||
|
| 50 | Nextcloud | `50_nextcloud.yaml` |
|
||||||
|
| 81 | Apache Superset | `81_superset.yaml` |
|
||||||
|
| 82 | Container Registry | `82_harbor.yaml` |
|
||||||
|
| 86 | Velero | `86_k8s_velero.yaml` |
|
||||||
|
| 87 | Valkey | `87_k8svalkey.yaml` |
|
||||||
|
| 88 | k8sZitiController | `88_k8s_ziti_controller.yaml` |
|
||||||
|
| 89 | Web-сервер с фреймворком Flask | `89_flask.yaml` |
|
||||||
|
| 90 | PostgreSQL | `90_postgres.yaml` |
|
||||||
|
| 91 | Redis | `91_redis.yaml` |
|
||||||
|
| 92 | MongoDB | `92_mongodb.yaml` |
|
||||||
|
| 93 | RabbitMQ | `93_rabbitmq.yaml` |
|
||||||
|
| 94 | Lucee | `94_lucee.yaml` |
|
||||||
|
| 95 | NodeJS | `95_nodejs.yaml` |
|
||||||
|
| 96 | pgAdmin | `96_pgadmin.yaml` |
|
||||||
|
| 97 | NodeRed | `97_nodered.yaml` |
|
||||||
|
| 98 | Простой HTTP контейнер | `98_http.yaml` |
|
||||||
|
| 99 | Gitea | `99_gitea.yaml` |
|
||||||
|
| 109 | Управление DNS | `109_zones_v2.yaml` |
|
||||||
|
| 111 | DNS запись | `111_dnsrecord.yaml` |
|
||||||
|
| 115 | Mariadb | `115_mariadb.yaml` |
|
||||||
|
| 116 | ApacheKafka | `116_kafka.yaml` |
|
||||||
|
| 119 | Akhq | `119_akhq.yaml` |
|
||||||
|
| 120 | ClickHouse | `120_clickhouse.yaml` |
|
||||||
|
| 148 | Менеджмент Kubernetes кластер Штурвал | `148_vc_mgmt_sthutrval_cluster.yaml` |
|
||||||
|
| 149 | VALO Cloud | `149_valo_tenant.yaml` |
|
||||||
|
| 150 | Kubernetes кластер Штурвал | `150_k8s_sthutrval_cluster.yaml` |
|
||||||
|
| 151 | Vault | `151_k8s_openbao.yaml` |
|
||||||
|
| 153 | Nifi | `153_nifi.yaml` |
|
||||||
|
| 163 | ML Улей | `163_llm_ai.yaml` |
|
||||||
|
|
||||||
|
## Подтверждённые зависимости
|
||||||
|
|
||||||
|
| ID | Потребитель | Зависимость | Поле связи | Смысл связи |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D-01 | DNS запись (111) | Управление DNS (109) | `zoneUid` | запись создаётся в DNS-зоне |
|
||||||
|
| D-02 | S3 бакет (13) | S3 Object Storage (12) | `s3UserUid` | бакет создаётся для корневой S3-услуги |
|
||||||
|
| D-03 | Виртуальный датацентр (21) | Организация в Cloud Director (19) | `organizationUid` | vDC принадлежит организации |
|
||||||
|
| D-04 | Сетевой шлюз периметра (22) | Виртуальный датацентр (21) | `vdcUid` | Edge привязывается к vDC |
|
||||||
|
| D-05 | Сетевой шлюз периметра (22) | Группа датацентров (29) | `vdcGroupUid` | альтернативная привязка Edge к группе vDC (vdcType=vdcGroup) |
|
||||||
|
| D-06 | Виртуальный каталог ВМ (26) | Сетевой шлюз периметра (22) | `nsxtUid` | vApp использует Edge для сети |
|
||||||
|
| D-07 | Виртуальный каталог ВМ (26) | Виртуальный датацентр (21) | `vdcUid` | vApp размещается в vDC |
|
||||||
|
| D-08 | Виртуальная машина (28) | Виртуальный каталог ВМ (26) | `vappUid` | VM размещается в vApp |
|
||||||
|
| D-09 | Группа датацентров (29) | Виртуальный датацентр (21) | `vdcUid` | группа объединяет vDC (create и add_vdc) |
|
||||||
|
| D-10 | Akhq (119) | ApacheKafka (116) | `kafkaUid` | UI подключается к Kafka-кластеру |
|
||||||
|
| D-11 | Container Registry (82) | S3 Object Storage (12) | `s3Uid` | registry использует S3 для хранения |
|
||||||
|
| D-12 | Gitea (99) | PostgreSQL (90) | `psqlUid` | Gitea использует PostgreSQL |
|
||||||
|
|
||||||
|
## Переиспользование существующих инстансов
|
||||||
|
|
||||||
|
Зависимости (кроме Организации) являются ссылками на **существующие инстансы** через UUID:
|
||||||
|
|
||||||
|
- Новую ВМ (28) можно создать в уже существующем vApp, vDC, Edge — ссылкой `vappUid`/`vdcUid`/`nsxtUid`.
|
||||||
|
- Edge (22) можно переиспользовать, если он привязан к нужному vDC или groupvDC.
|
||||||
|
- Для Штурвала (150) переиспользуемый Edge должен иметь включённый ALB и `virtualServicesCount >= 3`.
|
||||||
|
- Внешние IP резервируются только после: Организация + vDC + Каталог (vApp) + Edge (по `25_vcexternalip.yaml`, `service_man`).
|
||||||
|
|
||||||
|
## Где выделяются ресурсы (CPU / RAM / Storage / IP)
|
||||||
|
|
||||||
|
| Элемент | Где задаётся | Параметр(ы) |
|
||||||
|
|---|---|---|
|
||||||
|
| Пул CPU/RAM/Storage организации | vDC (21) create/modify | `cpuAllocated`, `memAllocated`, `storageConfig` (+ разовый `cpuGuaranteed` 0/50/80) |
|
||||||
|
| Запрос ресурсов нод Штурвала | Кластер Штурвал (150) | `controlPlaneConfiguration.sizingPolicy/sizingDisk/count`, `workerConfiguration…` (политики из vDC) |
|
||||||
|
| Запрос ресурсов ВМ | Виртуальная машина (28) | `vmCpu`, `vmRam`, `vmDisk` (доп. диск) |
|
||||||
|
| Внешние IP | vcOrg (19) `modify` (`vIPConfigure`) или `vcexternalip` (25) | `ipSpaceName`, квота адресов |
|
||||||
|
| SNAT-правило | Edge (22) `modify` | `ipSpaceName` |
|
||||||
|
| DNAT/SNAT-правила адреса | Публичные IP (25) | `dnatCreate`/`snatCreate`, `internalAddrAccess`, `internalPortAccess` |
|
||||||
|
|
||||||
|
## Чек-лист создания Штурвала (150) — по `service_man`
|
||||||
|
|
||||||
|
1. Организация (19) — create.
|
||||||
|
2. vDC (21) — create, выделить ресурсы под все ноды.
|
||||||
|
3. Edge (22) — create, включить ALB и `virtualServicesCount >= 3`.
|
||||||
|
4. Внешние адреса в организации — vcOrg `modify`, суммарно 3 адреса.
|
||||||
|
5. SNAT — Edge `modify`, выбрать `ipSpaceName`.
|
||||||
|
6. Кластер Штурвал (150) — create (требует `vdcUid` и `nsxtUid`).
|
||||||
|
|
||||||
|
## Чек-лист создания ВМ (28) — по create-параметрам
|
||||||
|
|
||||||
|
1. vDC (21) — существующий (`vdcUid`).
|
||||||
|
2. Edge (22) — существующий (`nsxtUid`).
|
||||||
|
3. vApp (26) — существующий (`vappUid`), привязан к vDC и Edge.
|
||||||
|
4. ВМ (28) — create; обязательные: `vappUid`, `vmName`, `vmCpu`, `vmRam`, `accessPortList`, `imageVm`, `userLogin`, `userPublicKey`, `ipSpaceName`.
|
||||||
|
|
||||||
|
## Правила для будущего графа
|
||||||
|
|
||||||
|
1. Рисовать стрелку только по подтверждённой связи сервиса каталога.
|
||||||
|
2. `ref_svc_id` считать прямой зависимостью; `descr` использовать для подписи
|
||||||
|
стрелки и проверки смысла.
|
||||||
|
3. Не считать общей платформой, DNS-полем или внешним IP автоматическую связь
|
||||||
|
между сервисами без ссылки на конкретный сервисный ID.
|
||||||
|
4. Поля без `ref_svc_id` вынести в отдельный список кандидатов на проверку, но не
|
||||||
|
смешивать с подтверждёнными рёбрами.
|
||||||
@@ -90,6 +90,16 @@ schema.StringAttribute{
|
|||||||
|
|
||||||
## 4. Правильное решение
|
## 4. Правильное решение
|
||||||
|
|
||||||
|
> ⚠️ **Историческая справка (пометка 2026-09-30).** Описанный ниже подход «Restore user-provided
|
||||||
|
> casing» (`originalVappUid` в шаблоне `instance.go`) в текущем коде **отсутствует**.
|
||||||
|
> Фактическая реализация регистра UUID:
|
||||||
|
> 1. ref_svc-атрибуты **исключены из read-back**: шаблон `instance.go` добавляет `InputField` только
|
||||||
|
> при `eq .RefSvcId 0`, поэтому в state остаётся регистр из config;
|
||||||
|
> 2. UUID-подстроки **внутри JSON** нормализуются при отправке
|
||||||
|
> (`resources_core.BuildJSON` → `jsonutil.LowercaseUUIDsInText`).
|
||||||
|
>
|
||||||
|
> Разделы §10–§11 ниже — актуальные. Раздел 4 оставлен как история разбора.
|
||||||
|
|
||||||
**Источник истины**: plan = config = "6214BA32-..." (регистр пользователя, неизменен)
|
**Источник истины**: plan = config = "6214BA32-..." (регистр пользователя, неизменен)
|
||||||
**Задача**: state после apply тоже должен быть "6214BA32-..."
|
**Задача**: state после apply тоже должен быть "6214BA32-..."
|
||||||
|
|
||||||
@@ -280,6 +290,8 @@ Error: required params mismatch for resource_name shturval-dev: startupConfigura
|
|||||||
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, **не проверяется вообще** (открыто) |
|
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, **не проверяется вообще** (открыто) |
|
||||||
| 8 | `core/operation_run*.go` (`lookupLiveParam`) | подстановка live-значений по ключам: при другом регистре ключа может молча не сработать (открыто, требует живой проверки) |
|
| 8 | `core/operation_run*.go` (`lookupLiveParam`) | подстановка live-значений по ключам: при другом регистре ключа может молча не сработать (открыто, требует живой проверки) |
|
||||||
|
|
||||||
|
> **Дополнение 2026-09-30**: пункты 9–10 (регистр UUID на **отправке** в API) — см. §11.
|
||||||
|
|
||||||
**Фикс (провайдер `2.0.23`, dev).**
|
**Фикс (провайдер `2.0.23`, dev).**
|
||||||
|
|
||||||
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (UUID-подстрока → lowercase), и строковые
|
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (UUID-подстрока → lowercase), и строковые
|
||||||
@@ -295,3 +307,59 @@ Error: required params mismatch for resource_name shturval-dev: startupConfigura
|
|||||||
значения); смотрите `jsonutil.LowercaseUUIDsInText`.
|
значения); смотрите `jsonutil.LowercaseUUIDsInText`.
|
||||||
8. Не «лечите» это нормализацией плана целиком (скаляры → строки, сортировка ключей): для атрибутов из config
|
8. Не «лечите» это нормализацией плана целиком (скаляры → строки, сортировка ключей): для атрибутов из config
|
||||||
допустимо менять только регистр UUID-подстрок, иначе Terraform ругнётся на несоответствие плана конфигу.
|
допустимо менять только регистр UUID-подстрок, иначе Terraform ругнётся на несоответствие плана конфигу.
|
||||||
|
9. Разделяйте **сравнение** и **отправку**: фиксы §10 закрывали только сравнение (план/state/adopt). Если ресурс
|
||||||
|
падает на create без видимых расхождений плана — смотрите путь отправки, §11.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Обновление 2026-09-30: UUID внутри JSON на ОТПРАВКЕ в API (create/modify/redeploy)
|
||||||
|
|
||||||
|
**Что уточнилось.** Все фиксы §10 закрывали **сравнение** (план vs state, adopt-suspended,
|
||||||
|
modifier-compare, диагностика) и **не** трогали то, что реально уходит в API при создании.
|
||||||
|
На провайдере `2.0.23` (то есть уже после §10) `create` кластера Штурвала **без** `lower(...)`
|
||||||
|
в HCL падал с «Edge не развёрнут в указанном vDC». Обнаружено при запуске Terraform
|
||||||
|
**из-под Windows**.
|
||||||
|
|
||||||
|
**Где именно.**
|
||||||
|
|
||||||
|
- Все map-fixed JSON-параметры (`startupConfiguration`, `clusterConfiguration`, …) собираются
|
||||||
|
одной функцией `resources_core.BuildJSON` (`provider/internal/resources_core/helpers.go`) —
|
||||||
|
она берёт значения подполей (`ValueString()`) **как есть**.
|
||||||
|
- Вызывает её сгенерированный код через `NestedJSONExpr` (`TOOLS/resource-generator/internal/templates/instance.go`,
|
||||||
|
ветки **Create** ~строка 302, **Modify** ~488, **Redeploy** ~505).
|
||||||
|
- `nubes_vc_nsxt` отдаёт `id` в UPPERCASE (`2C37FED1-…`), платформа хранит UUID в lowercase
|
||||||
|
и **сравнивает регистр при create** → UPPERCASE `nsxtUid`/`vdcUid` внутри JSON отвергается.
|
||||||
|
- `resolveRefSvcParamValues` (`core/refsvc.go`, `core/refsvc_resolve.go`) тут не спасает: он
|
||||||
|
нормализует только **top-level** refSvc-параметры и `s3.*uid` **внутри** map-fixed;
|
||||||
|
`vdcUid`/`nsxtUid` — обычные строковые подполя JSON, refSvcId у них нет.
|
||||||
|
|
||||||
|
**Аудит (продолжение таблицы §10, проверено 30.09).**
|
||||||
|
|
||||||
|
| # | Место | Что ломает |
|
||||||
|
|---|---|---|
|
||||||
|
| 9 | `resources_core/helpers.go` (`BuildJSON`) — **отправка** map-fixed JSON в API (create/modify/redeploy) | create падает: «Edge не развёрнут в указанном vDC» — регистр `vdcUid`/`nsxtUid` внутри JSON |
|
||||||
|
| 10 | `core/params.go` (`normalizeUniversalValueV6`) — дефолты create (`instance_create.go`) и досылка modify (`operation_run.go`) | скалярный UUID к lowercase не приводится (открыто, вторично) |
|
||||||
|
|
||||||
|
**Фикс (выпущен в dev `2.0.24`, 30.09.2026).**
|
||||||
|
|
||||||
|
- `provider/internal/resources_core/helpers.go`: `BuildJSON` оборачивает результат в
|
||||||
|
`jsonutil.LowercaseUUIDsInText(...)` (+ импорт `core/jsonutil`). Одна точка → покрыты **все**
|
||||||
|
map-fixed-параметры всех ресурсов на create / modify / redeploy. Регенерация не нужна.
|
||||||
|
|
||||||
|
**Почему это безопасно.**
|
||||||
|
|
||||||
|
- `BuildJSON` используется **только для отправки**, не для построения state (проверено: вызовы
|
||||||
|
только в `resources_gen/*.go`).
|
||||||
|
- Regex `uuidAnywhereRegex` (`[0-9a-f]{8}-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) совпадает только с UUID;
|
||||||
|
пароли/имена/иные строки не задевает. Внутри map-fixed строковых секретных полей
|
||||||
|
(password/secret/token) в спеках нет — только `*Uid`-ссылки на ресурсы.
|
||||||
|
- Это **выравнивание** с правилом «регистр UUID незначим» (то же приведение уже делается на
|
||||||
|
сравнении), а не новое поведение.
|
||||||
|
|
||||||
|
**Костыль в HCL.** `tf_examples/fullpipe_chain/shturval.tf:139-140`
|
||||||
|
(`vdc_uid = lower(...)`, `nsxt_uid = lower(...)`) после релиза `2.0.24` стал не нужен —
|
||||||
|
убирать отдельной командой (в HCL пока оставлен, чтобы не ломать провайдер `2.0.23`
|
||||||
|
на других стендах).
|
||||||
|
В локальных стендах `lower()` не используется: `DEV_STAND/FPipeGmail/shturval.tf:125-126` и
|
||||||
|
`DEV_STAND/FullPipe/shturval.tf1:123` передают `nubes_vc_vdc.vdc.id` / `nubes_vc_nsxt.edge.id`
|
||||||
|
напрямую → на провайдере `2.0.23` и ниже у них тот же риск, на `2.0.24` — уже нет.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
Оба ресурса проверены живым прогоном: создание, проверка состояния в ЛК и удаление.
|
Оба ресурса проверены живым прогоном: создание, проверка состояния в ЛК и удаление.
|
||||||
|
|
||||||
Полный рабочий пример (vDC + Edge + IP + SNAT) — на странице
|
Полный рабочий пример (vDC + Edge + IP + SNAT + vApp + ВМ + Штурвал) — на странице
|
||||||
[«Как развернуть vDC, Edge, внешние IP и SNAT»](../pipeline/vdc_edge_ip_snat.md),
|
[«Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал»](../pipeline/vdc_edge_ip_snat.md),
|
||||||
файлы — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
файлы — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
||||||
|
|
||||||
## `nubes_vc_org_ip_allocation` — внешние IP на организации
|
## `nubes_vc_org_ip_allocation` — внешние IP на организации
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
| Атрибут | Тип | Описание |
|
| Атрибут | Тип | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `organization` | string, обязательный | Организация: название услуги из ЛК или её UUID |
|
| `organization` | string, обязательный | Организация: название услуги из ЛК или её UUID |
|
||||||
| `vip_configure` | string (JSON), обязательный | Сколько IP выделить: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строкой |
|
| `vip_configure` | string (JSON), обязательный | Сколько IP выделить: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строкой. Для Штурвала нужно не меньше 3 адресов, ещё один — если у ВМ будет внешний адрес |
|
||||||
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении квота обнуляется; `true` — остаётся как есть |
|
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении квота обнуляется; `true` — остаётся как есть |
|
||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
@@ -39,7 +39,10 @@ resource "nubes_vc_org_ip_allocation" "this" {
|
|||||||
- массив `vip_configure` записывается **целиком**, поэтому ресурс владеет всей аллокацией организации:
|
- массив `vip_configure` записывается **целиком**, поэтому ресурс владеет всей аллокацией организации:
|
||||||
если нужно поменять количество — меняйте значение и делайте `apply`;
|
если нужно поменять количество — меняйте значение и делайте `apply`;
|
||||||
- снять аллокацию через пустой массив нельзя — для этого удалите ресурс
|
- снять аллокацию через пустой массив нельзя — для этого удалите ресурс
|
||||||
(`terraform destroy -target=nubes_vc_org_ip_allocation.this`), тогда отправится запрос с `count = "0"`.
|
(`terraform destroy -target=nubes_vc_org_ip_allocation.this`), тогда отправится запрос с `count = "0"`;
|
||||||
|
- **считайте потребителей адресов**: кластер Штурвал держит 3 адреса (API и Ingress), а ВМ с внешним
|
||||||
|
адресом (`ip_space_name = "internet-ipv4-v1"`) занимает ещё один — итого `count = "4"`.
|
||||||
|
Адреса освобождаются только после удаления потребителя: `suspend` их не снимает.
|
||||||
|
|
||||||
## `nubes_vc_nsxt_snat` — SNAT на шлюзе
|
## `nubes_vc_nsxt_snat` — SNAT на шлюзе
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Как развернуть vDC, Edge, внешние IP, SNAT и кластер Штурвал
|
# Как развернуть цепочку: vDC → Edge → внешние IP → SNAT → vApp → ВМ → Штурвал
|
||||||
|
|
||||||
Пошаговая инструкция. Готовые файлы примера — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
Пошаговая инструкция. Готовые файлы примера — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
||||||
|
|
||||||
@@ -8,11 +8,15 @@
|
|||||||
- сетевой шлюз периметра (Edge) с балансировщиком AVI;
|
- сетевой шлюз периметра (Edge) с балансировщиком AVI;
|
||||||
- внешние IP на организации;
|
- внешние IP на организации;
|
||||||
- SNAT на шлюзе;
|
- SNAT на шлюзе;
|
||||||
|
- виртуальный каталог (vApp) и виртуальная машина (ВМ) внутри него — с доступом по SSH;
|
||||||
- Kubernetes-кластер Штурвал (сервис 150) на этой сети.
|
- Kubernetes-кластер Штурвал (сервис 150) на этой сети.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**Организацию создайте заранее в ЛК** — Terraform её не создаёт и не удаляет.
|
**Организацию создайте заранее в ЛК** — Terraform её не создаёт и не удаляет.
|
||||||
Кластер Штурвал создаётся десятки минут, поэтому провайдер ждёт его до часу
|
Кластер Штурвал создаётся десятки минут, поэтому провайдер ждёт его до часу
|
||||||
(`operation_timeout = "60m"`); остальные ресурсы — минуты.
|
(`operation_timeout = "60m"`); ВМ тоже создаётся небыстро (у неё отдельный таймаут `15m`),
|
||||||
|
остальные ресурсы — минуты.
|
||||||
|
|
||||||
## Что нужно перед началом
|
## Что нужно перед началом
|
||||||
|
|
||||||
@@ -22,12 +26,15 @@
|
|||||||
| Токен API | доступ к ЛК | ЛК → Профиль → Токены → «Технический» |
|
| Токен API | доступ к ЛК | ЛК → Профиль → Токены → «Технический» |
|
||||||
| Организация в Cloud Director | всё создаётся внутри неё | услуга «Организация в Cloud Director» |
|
| Организация в Cloud Director | всё создаётся внутри неё | услуга «Организация в Cloud Director» |
|
||||||
| Edge с балансировщиком AVI | без ALB кластер Штурвал не поднимется | `nsxt_need_enable_avi = true`, `nsxt_virtual_services_count >= 3` |
|
| Edge с балансировщиком AVI | без ALB кластер Штурвал не поднимется | `nsxt_need_enable_avi = true`, `nsxt_virtual_services_count >= 3` |
|
||||||
| Не меньше 3 внешних IP | адрес Kubernetes API, адрес Ingress и запас | `ip_count = "3"` |
|
| Внешние IP: не меньше 3 + 1 под ВМ | адрес Kubernetes API, адрес Ingress, адрес ВМ и запас | `ip_count = "4"` |
|
||||||
| SNAT на Edge | выход в интернет для машин кластера | ресурс `nubes_vc_nsxt_snat` |
|
| SNAT на Edge | выход в интернет для машин кластера и для ВМ | ресурс `nubes_vc_nsxt_snat` |
|
||||||
|
| Публичный SSH-ключ | вход на ВМ по SSH | `~/.ssh/id_ed25519.pub` → `vm_user_public_key` |
|
||||||
|
| Запас vCPU в vDC | Штурвал занимает 8 vCPU из 8 при настройках по умолчанию, и тогда ВМ не включится | `vdc_cpu_allocated` |
|
||||||
|
|
||||||
Порядок из чек-листа услуги 150 (именно так связаны ресурсы в примере):
|
Порядок из чек-листа услуги 150 (именно так связаны ресурсы в примере):
|
||||||
организация → vDC → Edge (ALB, AVI ≥ 3) → внешние IP (≥ 3) → SNAT → кластер Штурвал.
|
организация → vDC → Edge (ALB, AVI ≥ 3) → внешние IP (≥ 3) → SNAT → **vApp → ВМ** → кластер Штурвал.
|
||||||
Минимум для кластера: 1 мастер-нода и 1 воркер-нода по 4 vCPU / 8 ГБ RAM / 50 ГБ диска.
|
Минимум для кластера: 1 мастер-нода и 1 воркер-нода по 4 vCPU / 8 ГБ RAM / 50 ГБ диска;
|
||||||
|
минимум для ВМ: 1 vCPU / 1 ГБ RAM (в примере — 2 vCPU / 2 ГБ).
|
||||||
|
|
||||||
## 1. Скачайте пример
|
## 1. Скачайте пример
|
||||||
|
|
||||||
@@ -67,13 +74,13 @@ organization = "organ"
|
|||||||
|
|
||||||
# Внешние IP
|
# Внешние IP
|
||||||
ip_space_name = "internet-ipv4-v1"
|
ip_space_name = "internet-ipv4-v1"
|
||||||
ip_count = "3"
|
ip_count = "4" # 3 под Штурвал + 1 под ВМ
|
||||||
|
|
||||||
# vDC
|
# vDC
|
||||||
vdc_resource_name = "fullpipe-vdc" # имя услуги в ЛК, любое
|
vdc_resource_name = "fullpipe-vdc" # имя услуги в ЛК, любое
|
||||||
vdc_network_provider = "snb1" # ЛК → создание vDC → «Сетевой провайдер»
|
vdc_network_provider = "snb1" # ЛК → создание vDC → «Сетевой провайдер»
|
||||||
vdc_provider_vdc = "Intel Broadwell 2.4" # ЛК → создание vDC → «Provider VDC»
|
vdc_provider_vdc = "Intel Broadwell 2.4" # ЛК → создание vDC → «Provider VDC»
|
||||||
vdc_cpu_allocated = 8 # vCPU, шт.
|
vdc_cpu_allocated = 12 # 8 под Штурвал + 2 под ВМ + 2 запаса
|
||||||
vdc_cpu_guaranteed = 0 # резервирование vCPU, %: 0, 50 или 80
|
vdc_cpu_guaranteed = 0 # резервирование vCPU, %: 0, 50 или 80
|
||||||
vdc_mem_allocated = 32 # RAM, ГБ
|
vdc_mem_allocated = 32 # RAM, ГБ
|
||||||
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]" # политика и размер, ГБ
|
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]" # политика и размер, ГБ
|
||||||
@@ -86,6 +93,17 @@ nsxt_virtual_services_count = 3 # виртуальных серв
|
|||||||
nsxt_ip_addr_pool = "10.10.102.0/24" # пул адресов routed-сети, маска /24
|
nsxt_ip_addr_pool = "10.10.102.0/24" # пул адресов routed-сети, маска /24
|
||||||
nsxt_main_dns = "81.22.46.22" # основной DNS
|
nsxt_main_dns = "81.22.46.22" # основной DNS
|
||||||
nsxt_second_dns = "185.247.187.77" # второй DNS
|
nsxt_second_dns = "185.247.187.77" # второй DNS
|
||||||
|
|
||||||
|
# vApp и ВМ
|
||||||
|
vapp_name = "fullpipe-vapp-01" # имя vApp: маска ^[a-z0-9][a-z0-9.-]{3,61}[a-z0-9]$, уникально в организации
|
||||||
|
vm_name = "web01" # имя ВМ: задаёт имя IP Set {vapp_name}-{vm_name}
|
||||||
|
vm_image = "Ubuntu_22-20G" # RockyLinux_9-16G-cloudinit | Ubuntu_22-20G | Debian_13-20G
|
||||||
|
vm_cpu = 2 # vCPU, 1..64
|
||||||
|
vm_ram = 2 # RAM, ГБ, 1..256
|
||||||
|
vm_disk = 20 # дополнительный диск, ГБ
|
||||||
|
vm_user_login = "ubuntu" # учётка SSH
|
||||||
|
vm_user_public_key = "ssh-ed25519 AAAA..." # содержимое ~/.ssh/id_ed25519.pub
|
||||||
|
vm_same_snat = false # false — за общим SNAT шлюза
|
||||||
```
|
```
|
||||||
|
|
||||||
Описание всех параметров — в `variables.tf`. `terraform.tfvars` с токеном никому не передавайте
|
Описание всех параметров — в `variables.tf`. `terraform.tfvars` с токеном никому не передавайте
|
||||||
@@ -95,7 +113,7 @@ nsxt_second_dns = "185.247.187.77" # второй DNS
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
terraform init # один раз — скачает провайдер
|
terraform init # один раз — скачает провайдер
|
||||||
terraform plan # покажет, что будет создано: 5 ресурсов
|
terraform plan # покажет, что будет создано: 7 ресурсов (vDC, Edge, IP, SNAT, vApp, ВМ, Штурвал)
|
||||||
terraform apply # создаст (подтвердить: yes)
|
terraform apply # создаст (подтвердить: yes)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,14 +123,73 @@ terraform apply # создаст (подтвердить: yes)
|
|||||||
terraform output # UUID и имена созданных услуг
|
terraform output # UUID и имена созданных услуг
|
||||||
```
|
```
|
||||||
|
|
||||||
И в ЛК: появились vDC, Edge и кластер Штурвал, на организации выделены внешние IP,
|
И в ЛК: появились vDC, Edge, vApp с ВМ и кластер Штурвал, на организации выделены внешние IP,
|
||||||
на шлюзе включён SNAT. Повторный `terraform plan` должен показать `No changes`.
|
на шлюзе включён SNAT. Повторный `terraform plan` должен показать `No changes`.
|
||||||
|
|
||||||
|
Адрес ВМ виден в `terraform output vm_state_flat` — поля `externalConnect` (внешний IP),
|
||||||
|
`internalConnect` (адрес в routed-сети) и `fqdn`: `ssh <vm_user_login>@<externalConnect>`.
|
||||||
|
|
||||||
Кластер создаётся десятки минут — провайдер ждёт его (`operation_timeout = "60m"`),
|
Кластер создаётся десятки минут — провайдер ждёт его (`operation_timeout = "60m"`),
|
||||||
в ЛК он появится со статусом `running`. Адреса Kubernetes API и Ingress — в
|
в ЛК он появится со статусом `running`. Адреса Kubernetes API и Ingress — в
|
||||||
`terraform output shturval_state_params` (поля `kubernetesApiAddress` и `ingressAddress`).
|
`terraform output shturval_state_params` (поля `kubernetesApiAddress` и `ingressAddress`).
|
||||||
|
|
||||||
## 6. Удаление: «заморозка» вместо удаления
|
## 6. vApp и ВМ
|
||||||
|
|
||||||
|
Виртуальная машина живёт **внутри vApp**: vApp привязывается к vDC (`vdc_uid`) и к Edge (`nsxt_uid`),
|
||||||
|
а ВМ — к vApp (`vapp_uid`) и получает адрес в routed-сети шлюза. Своей сети у vApp нет.
|
||||||
|
|
||||||
|
| Ресурс | Услуга | Ключевые параметры |
|
||||||
|
|---|---|---|
|
||||||
|
| `nubes_vapp` | 26 | `vapp_name` (уникальное в организации, маска DNS-имени), `vdc_uid`, `nsxt_uid` |
|
||||||
|
| `nubes_vc_vm_v3` | 28 | `vapp_uid`, `vm_name`, `vm_cpu`, `vm_ram`, `vm_disk`, `image_vm`, `user_login`, `user_public_key`, `ip_space_name`, `access_port_list` |
|
||||||
|
|
||||||
|
### Параметры ВМ
|
||||||
|
|
||||||
|
| Параметр | Значения |
|
||||||
|
|---|---|
|
||||||
|
| `image_vm` | `RockyLinux_9-16G-cloudinit`, `Ubuntu_22-20G`, `Debian_13-20G` |
|
||||||
|
| `vm_cpu` / `vm_ram` | 1..64 шт. / 1..256 ГБ |
|
||||||
|
| `vm_disk` | дополнительный диск, ГБ (основной диск зависит от образа) |
|
||||||
|
| `user_login` | учётка SSH (по умолчанию `myuser`) |
|
||||||
|
| `access_port_list` | `[{ "port": "22", "type": "tcp" }]`; `type` — `tcp`, `udp` или `all` |
|
||||||
|
| `access_ip_list` | белый список адресов; по умолчанию `["0.0.0.0/0"]` |
|
||||||
|
|
||||||
|
`image_vm`, `vm_name`, `user_login`, `user_public_key` и `vapp_uid` — **create-only** (менять нельзя).
|
||||||
|
`vm_cpu`, `vm_ram`, `vm_disk`, `access_port_list`, `ip_space_name` меняются операцией `modify`.
|
||||||
|
|
||||||
|
### Внешний доступ
|
||||||
|
|
||||||
|
| `ip_space_name` | Что это значит |
|
||||||
|
|---|---|
|
||||||
|
| `internet-ipv4-v1` | у ВМ есть внешний адрес: в `vm_state_flat` появятся `externalConnect` и `fqdn` |
|
||||||
|
| `no-needed` | внешнего адреса нет, доступ только изнутри |
|
||||||
|
|
||||||
|
Адрес берётся из квоты организации, поэтому при внешнем доступе нужен **4-й адрес** (`ip_count = "4"`).
|
||||||
|
При `same_snat = false` (по умолчанию) ВМ публикуется за общим SNAT шлюза.
|
||||||
|
|
||||||
|
Пример результата на живом стенде:
|
||||||
|
|
||||||
|
```
|
||||||
|
vm_state_flat = {
|
||||||
|
"externalConnect" = "185.247.187.235"
|
||||||
|
"internalConnect" = "10.10.102.4"
|
||||||
|
"fqdn" = "web01.85ea682f-...dev.nubes.ru"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что проверять при создании ВМ
|
||||||
|
|
||||||
|
1. **Запас vCPU в vDC.** Штурвал при настройках по умолчанию занимает 8 vCPU из 8 (2 ноды × «TKG 4CPU 8RAM»).
|
||||||
|
Тогда ВМ не включится: платформа отдаёт `[400:VALIDATION] Unable to perform this action` на этапе
|
||||||
|
power-on, а инстанс остаётся в статусе `not created`. Лечится увеличением `vdc_cpu_allocated`.
|
||||||
|
2. **Квота IP.** `ip_count ≥ 4`, иначе внешний адрес для ВМ не выделится.
|
||||||
|
3. **Ключ.** Публичный ключ кладите в `terraform.tfvars`, не в репозиторий.
|
||||||
|
4. **Время.** ВМ создаётся и включается с customization (запись учётки и ключа) — на живом стенде
|
||||||
|
операция шла больше 15 минут. В примере стоит `operation_timeout = "15m"`; при медленной платформе
|
||||||
|
увеличьте его. Симптом таймаута — «операция … не завершилась за установленный таймаут».
|
||||||
|
5. **Проверка.** `ssh <vm_user_login>@<externalConnect>` и `terraform plan` → `No changes`.
|
||||||
|
|
||||||
|
## 7. Удаление: «заморозка» вместо удаления
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
terraform destroy
|
terraform destroy
|
||||||
@@ -127,15 +204,18 @@ terraform destroy
|
|||||||
| Edge | не трогается: у эджа нет операции `suspend` | `keep_on_destroy = true` |
|
| Edge | не трогается: у эджа нет операции `suspend` | `keep_on_destroy = true` |
|
||||||
| SNAT | не выключается | `keep_on_destroy = true` |
|
| SNAT | не выключается | `keep_on_destroy = true` |
|
||||||
| Квота внешних IP | не меняется: адреса держит кластер Штурвала | `keep_on_destroy = true` |
|
| Квота внешних IP | не меняется: адреса держит кластер Штурвала | `keep_on_destroy = true` |
|
||||||
|
| vApp | `suspend`: выключаются все ВМ внутри каталога | `suspend_on_destroy = true` |
|
||||||
|
| ВМ | `suspend` | `suspend_on_destroy = true` |
|
||||||
|
|
||||||
Приоритет флагов: `keep_on_destroy` важнее `suspend_on_destroy`. Следующий `apply`
|
Приоритет флагов: `keep_on_destroy` важнее `suspend_on_destroy`. Следующий `apply`
|
||||||
усыновит объекты по имени и разморозит кластер и vDC
|
усыновит объекты по имени и разморозит кластер и vDC
|
||||||
(`adopt_existing_on_create = true` → `resume`). **Организация не удаляется никогда.**
|
(`adopt_existing_on_create = true` → `resume`). **Организация не удаляется никогда.**
|
||||||
|
|
||||||
Полное удаление — осознанно: поставьте `keep_on_destroy = false` и
|
Полное удаление — осознанно: поставьте `keep_on_destroy = false` и
|
||||||
`suspend_on_destroy = false` и удаляйте по порядку: кластер → квота IP (`count = 0`) →
|
`suspend_on_destroy = false` и удаляйте по порядку: кластер → ВМ → vApp → квота IP (`count = 0`) →
|
||||||
SNAT → Edge → vDC. Квоту нельзя опустить ниже занятых адресов, поэтому — только
|
SNAT → Edge → vDC. Квоту нельзя опустить ниже занятых адресов, поэтому — только
|
||||||
после удаления кластера; vDC удаляется лишь через 14 дней после `suspend`.
|
после удаления кластера и ВМ; vApp удаляется лишь после `suspend` (полное удаление — через 14 дней),
|
||||||
|
а vDC — через 14 дней после `suspend`.
|
||||||
|
|
||||||
## Файлы примера
|
## Файлы примера
|
||||||
|
|
||||||
@@ -147,8 +227,9 @@ SNAT → Edge → vDC. Квоту нельзя опустить ниже зан
|
|||||||
| `vdc.tf` | виртуальный датацентр |
|
| `vdc.tf` | виртуальный датацентр |
|
||||||
| `edge.tf` | сетевой шлюз периметра (Edge) |
|
| `edge.tf` | сетевой шлюз периметра (Edge) |
|
||||||
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
|
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
|
||||||
|
| `vm.tf` | vApp и ВМ: переменные vApp/ВМ, оба ресурса и выводы (отключается удалением файла) |
|
||||||
| `shturval.tf` | Kubernetes-кластер Штурвал: переменные Штурвала, группы воркеров и сам ресурс |
|
| `shturval.tf` | Kubernetes-кластер Штурвал: переменные Штурвала, группы воркеров и сам ресурс |
|
||||||
| `outputs.tf` | UUID и имена созданных услуг (vDC, Edge, Штурвал) |
|
| `outputs.tf` | UUID и имена созданных услуг (vDC, Edge, vApp, ВМ, Штурвал) |
|
||||||
| `terraform.tfvars.example` | шаблон значений (копируется в `terraform.tfvars`) |
|
| `terraform.tfvars.example` | шаблон значений (копируется в `terraform.tfvars`) |
|
||||||
|
|
||||||
Подробнее про два последних ресурса — на странице
|
Подробнее про два последних ресурса — на странице
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Схема зависимостей облачных сервисов (docs/diagrams)
|
||||||
|
|
||||||
|
Папка содержит все артефакты схемы: исходники, отрендеренные изображения и
|
||||||
|
генератор. Здесь же описан процесс создания и результаты сверки с контрактами.
|
||||||
|
|
||||||
|
## 1. Состав папки
|
||||||
|
|
||||||
|
| Файл | Что это | Чем создаётся |
|
||||||
|
|---|---|---|
|
||||||
|
| `infra_services_flow.mmd` | исходник Mermaid-версии схемы | правится вручную |
|
||||||
|
| `infra_services_flow.svg` | вектор Mermaid-версии | рендер через `mermaid.ink` |
|
||||||
|
| `infra_services_flow.png` | растр Mermaid-версии | рендер через `mermaid.ink` |
|
||||||
|
| `render_infra_diagram.py` | генератор стилизованной схемы (PNG + SVG) | чистый Python + Pillow |
|
||||||
|
| `infra_services_diagram.png` | стилизованная схема, растр 1600×720 (рендер 2x + LANCZOS) | `render_infra_diagram.py` |
|
||||||
|
| `infra_services_diagram.svg` | стилизованная схема, вектор | `render_infra_diagram.py` |
|
||||||
|
|
||||||
|
Две версии существуют одновременно: Mermaid — для правок текстом и встраивания
|
||||||
|
в Markdown; стилизованная — как готовая картинка для показа.
|
||||||
|
|
||||||
|
## 2. Источник данных
|
||||||
|
|
||||||
|
Единственный источник фактов — YAML-контракты сервисов:
|
||||||
|
|
||||||
|
```
|
||||||
|
generated/dev/resources_yaml/*.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Из них берутся:
|
||||||
|
|
||||||
|
- `service_id`, `service_display_name` — идентичность сервиса;
|
||||||
|
- `operations[].params[].ref_svc_id` — **прямая зависимость** от другого сервиса;
|
||||||
|
- `operations[].params[].depends_on` — зависимость порядка внутри операции;
|
||||||
|
- `value_list`, `default`, `man`, `descr` — значения на блоках (CPU, сети и т.п.);
|
||||||
|
- `service_man` — чек-листы и порядок действий, заданные самой платформой.
|
||||||
|
|
||||||
|
Terraform-файлы `DEV_STAND/*` **не используются как источник** для этой схемы —
|
||||||
|
они лишь один из примеров потребления.
|
||||||
|
|
||||||
|
## 3. Как перегенерировать
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/naeel/TF/tf_provider
|
||||||
|
python3 docs/diagrams/render_infra_diagram.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт пишет `infra_services_diagram.png` и `.svg` **в свою же папку**
|
||||||
|
(`docs/diagrams/`), путь вычисляется от расположения файла.
|
||||||
|
|
||||||
|
Требования: `python3` с `Pillow` и шрифты DejaVu
|
||||||
|
(`/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf`, `...-Bold.ttf`).
|
||||||
|
Сторонние библиотеки для SVG не нужны — SVG формируется текстом.
|
||||||
|
|
||||||
|
### Публикация картинок на сайте
|
||||||
|
|
||||||
|
Скрипт `TOOLS/scripts/04_build_and_publish_docs.sh` копирует **только картинки**
|
||||||
|
(`docs/diagrams/*.svg`, `*.png`) в публикуемый `docs_dir` → `diagrams/`: исходники (`.mmd`, `.py`)
|
||||||
|
на сайт не попадают. Поэтому картинку можно вставлять в опубликованные страницы относительной
|
||||||
|
ссылкой, например со страницы `docs/curated/pipeline/vdc_edge_ip_snat.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
Изменения в `docs/diagrams/` попадают на сайт только при следующей публикации документации.
|
||||||
|
|
||||||
|
Для Mermaid-версии нужен рендерер (`mermaid.ink`, `mmdc` или предпросмотр
|
||||||
|
Markdown в VS Code); в этом репозитории использовался `mermaid.ink` через
|
||||||
|
`python3` + `urllib`.
|
||||||
|
|
||||||
|
## 4. Структура картинки
|
||||||
|
|
||||||
|
Пять колонок = этапы создания, слева направо:
|
||||||
|
|
||||||
|
1. **Тенант** — Организация в Cloud Director (19)
|
||||||
|
2. **Пул ресурсов** — Виртуальный датацентр (21)
|
||||||
|
3. **Сетевой периметр** — Сетевой шлюз периметра / Edge (22)
|
||||||
|
4. **Среда запуска** — Каталог ВМ / vApp (26) + Внешние IP (vcOrg modify / 25)
|
||||||
|
5. **Потребители** — Виртуальная машина (28) и Kubernetes кластер Штурвал (150)
|
||||||
|
|
||||||
|
### Обозначения
|
||||||
|
|
||||||
|
- **Сплошная стрелка** — обязательная зависимость, ссылка на UUID существующего
|
||||||
|
инстанса (`ref_svc_id`).
|
||||||
|
- **Пунктирная стрелка** — выделение ресурса, операция `modify` или предусловие.
|
||||||
|
- Направление стрелки всегда **от зависимости к потребителю**: `Org → vDC`,
|
||||||
|
`vApp → VM`. То есть стрелка отвечает на вопрос «что должно существовать до».
|
||||||
|
- Цвет колонки кодирует этап, цвет рамки блока — группу (тенант / ресурсы / сеть /
|
||||||
|
потребители / адреса). Легенда — в правом нижнем углу картинки.
|
||||||
|
|
||||||
|
### Бейджи точек выделения ресурсов
|
||||||
|
|
||||||
|
| Бейдж | Где на схеме | Что означает |
|
||||||
|
|---|---|---|
|
||||||
|
| «здесь выделяются CPU / RAM / Диск» | блок vDC (21) | `cpuAllocated`, `memAllocated`, `storageConfig` |
|
||||||
|
| «SNAT настраивается операцией modify» | блок Edge (22) | SNAT — не сервис, а `modify` у Edge |
|
||||||
|
| «здесь выделяются IP» | блок «Внешние IP» | выделение адресов на организации |
|
||||||
|
| «требует ALB + VS ≥ 3» | блок Штурвала (150) | обязательное условие из чек-листа |
|
||||||
|
|
||||||
|
## 5. Результат сверки с YAML
|
||||||
|
|
||||||
|
Проверено построчно (коммит `6791e8f`, дополнение — связь `vApp → Внешние IP`).
|
||||||
|
|
||||||
|
| Утверждение на схеме | Источник |
|
||||||
|
|---|---|
|
||||||
|
| Организация: типы `iaas / saas` | `19_vc_org.yaml:47-53` |
|
||||||
|
| Выделение IP через `vcOrg modify (vIPConfigure)` | `19_vc_org.yaml:115` |
|
||||||
|
| Для внешних IP нужны vDC и Edge | `19_vc_org.yaml:112` (`man`) |
|
||||||
|
| vDC: `cpuGuaranteed: 0% / 50% / 80%` | `21_vc_vdc.yaml:71-78` |
|
||||||
|
| vDC: `cpuAllocated`, `memAllocated`, `storageConfig` | `21_vc_vdc.yaml:49,83,93` |
|
||||||
|
| Edge: AVI Load Balancer | `22_vc_nsxt.yaml` (`needEnableAVI`) |
|
||||||
|
| Edge: routed-сеть `10.10.102.0/24` | `22_vc_nsxt.yaml:5` |
|
||||||
|
| Edge: SNAT настраивается операцией `modify` | `22_vc_nsxt.yaml` (`modify → ipSpaceName`) |
|
||||||
|
| vApp → vDC (`vdcUid`), Edge (`nsxtUid`) | `26_vapp.yaml:38,56` |
|
||||||
|
| ВМ → vApp (`vappUid`), `vmCpu · vmRam · vmDisk` | `28_vc_vm_v3.yaml:39,49,59,69` |
|
||||||
|
| Штурвал: `vdcUid`, `nsxtUid`; `sizingPolicy · sizingDisk · count` | `150_k8s_sthutrval_cluster.yaml:41,47,132,141,148` |
|
||||||
|
| Публичные IP: NAT/DNAT через сервис 25 | `25_vcexternalip.yaml:41,57,100` |
|
||||||
|
| **Публичные IP требуют «Каталог» (vApp)** | `25_vcexternalip.yaml:5` (`service_man`) |
|
||||||
|
| Штурвал: обязательны ALB и VS ≥ 3; суммарно 3 внешних адреса | `150_k8s_sthutrval_cluster.yaml` (`service_man`, чек-лист) |
|
||||||
|
|
||||||
|
## 6. Известные ограничения
|
||||||
|
|
||||||
|
1. **Переиспользование инстансов** («vDC и Edge могут быть переиспользованы»)
|
||||||
|
не написано в YAML буквально — это следствие семантики ссылок на UUID
|
||||||
|
существующего инстанса. Уверенность средняя, не высокая.
|
||||||
|
2. Блок «Внешние IP» объединяет два механизма: выделение адресов на организации
|
||||||
|
(`vcOrg modify`) и правила NAT у сервиса 25 (`vcexternalip`). Они подписаны
|
||||||
|
раздельно внутри блока, но нарисованы одной вершиной.
|
||||||
|
3. Остальные 33 сервиса каталога на схеме не показаны — отрисованы только
|
||||||
|
инфраструктурные цепочки и их прямые потребители.
|
||||||
|
4. Схема — компоновка по контрактам, а не результат наблюдения за развёртыванием.
|
||||||
|
Фактический порядок и доступность нужно подтверждать отдельно.
|
||||||
|
|
||||||
|
## 7. История
|
||||||
|
|
||||||
|
| Дата | Изменение |
|
||||||
|
|---|---|
|
||||||
|
| 2026-09-26 | Создана Mermaid-версия (`infra_services_flow.*`) |
|
||||||
|
| 2026-09-26 | Исправлена связь «вычислительный пул»: от vDC, а не от внешних IP |
|
||||||
|
| 2026-09-26 | Добавлен генератор стилизованной схемы (`render_infra_diagram.py`) |
|
||||||
|
| 2026-09-26 | Убрана дублирующая подпись `modify: SNAT` у блока Edge |
|
||||||
|
| 2026-09-26 | Сверка с YAML; добавлена связь `vApp → Внешние IP` — предусловие сервиса 25 |
|
||||||
|
| 2026-09-26 | Все артефакты перенесены в `docs/diagrams/` |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
@@ -0,0 +1,145 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="720" viewBox="0 0 1600 720" font-family="DejaVu Sans, Verdana, sans-serif">
|
||||||
|
<rect width="1600" height="720" fill="#FFFFFF"/>
|
||||||
|
<rect x="16" y="8" width="270" height="552" rx="12" fill="#E8F3FB"/>
|
||||||
|
<rect x="16" y="14" width="270" height="44" rx="8" fill="#1565C0"/>
|
||||||
|
<text x="151.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 1. Тенант</text>
|
||||||
|
<text x="151.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">базовый уровень изоляции</text>
|
||||||
|
<rect x="340" y="8" width="270" height="552" rx="12" fill="#FDF1E3"/>
|
||||||
|
<rect x="340" y="14" width="270" height="44" rx="8" fill="#E65100"/>
|
||||||
|
<text x="475.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 2. Пул ресурсов</text>
|
||||||
|
<text x="475.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">CPU / RAM / Storage</text>
|
||||||
|
<rect x="664" y="8" width="270" height="552" rx="12" fill="#EEEAF8"/>
|
||||||
|
<rect x="664" y="14" width="270" height="44" rx="8" fill="#4527A0"/>
|
||||||
|
<text x="799.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 3. Сетевой периметр</text>
|
||||||
|
<text x="799.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">маршрутизация и балансировка</text>
|
||||||
|
<rect x="988" y="8" width="270" height="552" rx="12" fill="#FDF4E3"/>
|
||||||
|
<rect x="988" y="14" width="270" height="44" rx="8" fill="#EF6C00"/>
|
||||||
|
<text x="1123.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 4. Среда запуска</text>
|
||||||
|
<text x="1123.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">контейнер и внешние адреса</text>
|
||||||
|
<rect x="1312" y="8" width="270" height="552" rx="12" fill="#E9F5EA"/>
|
||||||
|
<rect x="1312" y="14" width="270" height="44" rx="8" fill="#2E7D32"/>
|
||||||
|
<text x="1447.0" y="25" font-size="15" fill="#FFFFFF" font-weight="bold" text-anchor="middle" dominant-baseline="hanging">Шаг 5. Потребители</text>
|
||||||
|
<text x="1447.0" y="68" font-size="12" fill="#7A8794" text-anchor="middle" dominant-baseline="hanging">что разворачивается в итоге</text>
|
||||||
|
<rect x="31" y="150" width="240" height="150" rx="10" fill="#FFFFFF" stroke="#0288D1" stroke-width="2"/>
|
||||||
|
<rect x="43" y="162" width="22" height="22" rx="5" fill="#0288D1"/>
|
||||||
|
<text x="73" y="162" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Организация</text>
|
||||||
|
<text x="73" y="182" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">в Cloud Director</text>
|
||||||
|
<text x="73" y="202" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 19 · vcOrg</text>
|
||||||
|
<circle cx="51" cy="231" r="2.4" fill="#0288D1"/>
|
||||||
|
<text x="61" y="224" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Типы: iaas / saas</text>
|
||||||
|
<rect x="355" y="130" width="240" height="230" rx="10" fill="#FFF3E0" stroke="#F57C00" stroke-width="2"/>
|
||||||
|
<rect x="367" y="142" width="22" height="22" rx="5" fill="#F57C00"/>
|
||||||
|
<text x="397" y="142" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Виртуальный</text>
|
||||||
|
<text x="397" y="162" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">датацентр (vDC)</text>
|
||||||
|
<text x="397" y="182" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 21 · vcVdc</text>
|
||||||
|
<circle cx="375" cy="211" r="2.4" fill="#F57C00"/>
|
||||||
|
<text x="385" y="204" font-size="10" fill="#2E3A45" dominant-baseline="hanging">cpuGuaranteed: 0% / 50% / 80%</text>
|
||||||
|
<circle cx="375" cy="231" r="2.4" fill="#F57C00"/>
|
||||||
|
<text x="385" y="224" font-size="10" fill="#2E3A45" dominant-baseline="hanging">cpuAllocated, memAllocated</text>
|
||||||
|
<circle cx="375" cy="251" r="2.4" fill="#F57C00"/>
|
||||||
|
<text x="385" y="244" font-size="10" fill="#2E3A45" dominant-baseline="hanging">storageConfig</text>
|
||||||
|
<rect x="367" y="318" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#F57C00" stroke-width="1"/>
|
||||||
|
<text x="475.0" y="328" font-size="10" fill="#F57C00" text-anchor="middle" dominant-baseline="hanging">здесь выделяются CPU / RAM / Диск</text>
|
||||||
|
<rect x="679" y="120" width="240" height="200" rx="10" fill="#EDE7F6" stroke="#5E35B1" stroke-width="2"/>
|
||||||
|
<rect x="691" y="132" width="22" height="22" rx="5" fill="#5E35B1"/>
|
||||||
|
<text x="721" y="132" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Сетевой шлюз</text>
|
||||||
|
<text x="721" y="152" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">периметра (Edge)</text>
|
||||||
|
<text x="721" y="172" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 22 · vcNsxt</text>
|
||||||
|
<circle cx="699" cy="201" r="2.4" fill="#5E35B1"/>
|
||||||
|
<text x="709" y="194" font-size="10" fill="#2E3A45" dominant-baseline="hanging">AVI Load Balancer</text>
|
||||||
|
<circle cx="699" cy="221" r="2.4" fill="#5E35B1"/>
|
||||||
|
<text x="709" y="214" font-size="10" fill="#2E3A45" dominant-baseline="hanging">routed-сеть 10.10.102.0/24</text>
|
||||||
|
<rect x="691" y="278" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#5E35B1" stroke-width="1"/>
|
||||||
|
<text x="799.0" y="288" font-size="10" fill="#5E35B1" text-anchor="middle" dominant-baseline="hanging">SNAT настраивается операцией modify</text>
|
||||||
|
<rect x="1003" y="100" width="240" height="110" rx="10" fill="#FFF3E0" stroke="#F57C00" stroke-width="2"/>
|
||||||
|
<rect x="1015" y="112" width="22" height="22" rx="5" fill="#F57C00"/>
|
||||||
|
<text x="1045" y="112" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Каталог ВМ (vApp)</text>
|
||||||
|
<text x="1045" y="132" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 26 · vapp</text>
|
||||||
|
<circle cx="1023" cy="161" r="2.4" fill="#F57C00"/>
|
||||||
|
<text x="1033" y="154" font-size="10" fill="#2E3A45" dominant-baseline="hanging">логический контейнер для ВМ</text>
|
||||||
|
<rect x="1003" y="245" width="180" height="150" rx="10" fill="#FAFAFA" stroke="#9E9E9E" stroke-width="2" stroke-dasharray="9 6"/>
|
||||||
|
<rect x="1015" y="257" width="22" height="22" rx="5" fill="#9E9E9E"/>
|
||||||
|
<text x="1045" y="257" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Внешние IP</text>
|
||||||
|
<text x="1045" y="277" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 25</text>
|
||||||
|
<circle cx="1023" cy="306" r="2.4" fill="#9E9E9E"/>
|
||||||
|
<text x="1033" y="299" font-size="10" fill="#2E3A45" dominant-baseline="hanging">выделение: vcOrg</text>
|
||||||
|
<circle cx="1023" cy="326" r="2.4" fill="#9E9E9E"/>
|
||||||
|
<text x="1033" y="319" font-size="10" fill="#2E3A45" dominant-baseline="hanging">NAT: сервис 25</text>
|
||||||
|
<circle cx="1023" cy="346" r="2.4" fill="#9E9E9E"/>
|
||||||
|
<text x="1033" y="339" font-size="10" fill="#2E3A45" dominant-baseline="hanging">квота IP (ipSpace)</text>
|
||||||
|
<rect x="1015" y="353" width="156" height="30" rx="6" fill="#FFFFFF" stroke="#9E9E9E" stroke-width="1"/>
|
||||||
|
<text x="1093.0" y="363" font-size="10" fill="#9E9E9E" text-anchor="middle" dominant-baseline="hanging">здесь выделяются IP</text>
|
||||||
|
<rect x="1327" y="100" width="240" height="120" rx="10" fill="#E8F5E9" stroke="#2E7D32" stroke-width="2"/>
|
||||||
|
<rect x="1339" y="112" width="22" height="22" rx="5" fill="#2E7D32"/>
|
||||||
|
<text x="1369" y="112" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Виртуальная машина</text>
|
||||||
|
<text x="1369" y="132" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 28 · vc_vm_v3</text>
|
||||||
|
<circle cx="1347" cy="161" r="2.4" fill="#2E7D32"/>
|
||||||
|
<text x="1357" y="154" font-size="10" fill="#2E3A45" dominant-baseline="hanging">vmCpu · vmRam · vmDisk</text>
|
||||||
|
<rect x="1327" y="255" width="240" height="170" rx="10" fill="#E8F5E9" stroke="#2E7D32" stroke-width="2"/>
|
||||||
|
<rect x="1339" y="267" width="22" height="22" rx="5" fill="#2E7D32"/>
|
||||||
|
<text x="1369" y="267" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Kubernetes кластер</text>
|
||||||
|
<text x="1369" y="287" font-size="15" fill="#1A1A1A" font-weight="bold" dominant-baseline="hanging">Штурвал</text>
|
||||||
|
<text x="1369" y="307" font-size="11" fill="#5F6B76" dominant-baseline="hanging">Сервис 150</text>
|
||||||
|
<circle cx="1347" cy="336" r="2.4" fill="#2E7D32"/>
|
||||||
|
<text x="1357" y="329" font-size="10" fill="#2E3A45" dominant-baseline="hanging">sizingPolicy · sizingDisk</text>
|
||||||
|
<circle cx="1347" cy="356" r="2.4" fill="#2E7D32"/>
|
||||||
|
<text x="1357" y="349" font-size="10" fill="#2E3A45" dominant-baseline="hanging">· count</text>
|
||||||
|
<rect x="1339" y="383" width="216" height="30" rx="6" fill="#FFFFFF" stroke="#2E7D32" stroke-width="1"/>
|
||||||
|
<text x="1447.0" y="393" font-size="10" fill="#2E7D32" text-anchor="middle" dominant-baseline="hanging">требует ALB + VS ≥ 3</text>
|
||||||
|
<path d="M271 225 L355 225" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="355,225 346.0,228.78 346.0,221.22" fill="#455A64"/>
|
||||||
|
<text x="313" y="262" font-size="10" fill="#455A64" text-anchor="middle" dominant-baseline="middle" transform="rotate(-90 313 262)">organizationUid</text>
|
||||||
|
<path d="M595 200 L679 200" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="679,200 670.0,203.78 670.0,196.22" fill="#455A64"/>
|
||||||
|
<text x="637" y="186" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
|
||||||
|
<path d="M500 130 L500 92 L961 92 L961 140 L1003 140" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1003,140 994.0,143.78 994.0,136.22" fill="#455A64"/>
|
||||||
|
<text x="706" y="96" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
|
||||||
|
<path d="M919 180 L1003 180" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1003,180 994.0,183.78 994.0,176.22" fill="#455A64"/>
|
||||||
|
<text x="961" y="166" font-size="10" fill="#455A64" dominant-baseline="hanging">nsxtUid</text>
|
||||||
|
<path d="M1243 140 L1327 140" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1327,140 1318.0,143.78 1318.0,136.22" fill="#455A64"/>
|
||||||
|
<text x="1285" y="126" font-size="10" fill="#455A64" dominant-baseline="hanging">vappUid</text>
|
||||||
|
<path d="M500 360 L500 470 L1447 470 L1447 425" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1447,425 1450.78,434.0 1443.22,434.0" fill="#455A64"/>
|
||||||
|
<text x="1180" y="456" font-size="10" fill="#455A64" dominant-baseline="hanging">vdcUid</text>
|
||||||
|
<path d="M799 320 L799 505 L1467 505 L1467 425" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1467,425 1470.78,434.0 1463.22,434.0" fill="#455A64"/>
|
||||||
|
<text x="1180" y="491" font-size="10" fill="#455A64" dominant-baseline="hanging">nsxtUid</text>
|
||||||
|
<path d="M151 300 L151 540 L1050 540 L1050 395" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1050,395 1053.78,404.0 1046.22,404.0" fill="#7A8794"/>
|
||||||
|
<text x="161" y="526" font-size="10" fill="#7A8794" dominant-baseline="hanging">modify: vIPConfigure</text>
|
||||||
|
<path d="M870 320 L870 400 L1140 400 L1140 395" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1140,395 1143.78,404.0 1136.22,404.0" fill="#7A8794"/>
|
||||||
|
<text x="880" y="386" font-size="10" fill="#7A8794" dominant-baseline="hanging">modify: SNAT</text>
|
||||||
|
<path d="M1050 210 L1050 245" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1050,245 1046.22,236.0 1053.78,236.0" fill="#7A8794"/>
|
||||||
|
<text x="1060" y="218" font-size="10" fill="#7A8794" dominant-baseline="hanging">требует Каталог</text>
|
||||||
|
<path d="M1183 270 L1255 270 L1255 160 L1327 160" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1327,160 1318.0,163.78 1318.0,156.22" fill="#7A8794"/>
|
||||||
|
<text x="1187" y="222" font-size="10" fill="#7A8794" dominant-baseline="hanging">внешний IP</text>
|
||||||
|
<path d="M1183 350 L1327 350" fill="none" stroke="#C62828" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1327,350 1318.0,353.78 1318.0,346.22" fill="#C62828"/>
|
||||||
|
<text x="1187" y="330" font-size="10" fill="#C62828" dominant-baseline="hanging">≥ 3 адреса</text>
|
||||||
|
<text x="24" y="590" font-size="14" fill="#2E3A45" dominant-baseline="hanging">> Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал.</text>
|
||||||
|
<text x="24" y="616" font-size="14" fill="#2E3A45" dominant-baseline="hanging">> SNAT — не отдельный сервис, а операция modify у Edge.</text>
|
||||||
|
<text x="24" y="642" font-size="14" fill="#2E3A45" dominant-baseline="hanging">> vDC и Edge могут быть переиспользованы существующие.</text>
|
||||||
|
<path d="M980 606 L1040 606" fill="none" stroke="#455A64" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1040,606 1031.0,609.78 1031.0,602.22" fill="#455A64"/>
|
||||||
|
<text x="1052" y="597" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Сплошная линия — обязательная зависимость</text>
|
||||||
|
<text x="1052" y="613" font-size="10" fill="#7A8794" dominant-baseline="hanging">(ссылка на UUID существующего инстанса)</text>
|
||||||
|
<path d="M980 650 L1040 650" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
|
||||||
|
<polygon points="1040,650 1031.0,653.78 1031.0,646.22" fill="#7A8794"/>
|
||||||
|
<text x="1052" y="641" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Пунктир — выделение ресурса / modify / предусловие</text>
|
||||||
|
<rect x="980" y="684" width="12" height="12" rx="3" fill="#1565C0"/>
|
||||||
|
<text x="996" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Тенант</text>
|
||||||
|
<rect x="1038" y="684" width="12" height="12" rx="3" fill="#F57C00"/>
|
||||||
|
<text x="1054" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Ресурсы</text>
|
||||||
|
<rect x="1102" y="684" width="12" height="12" rx="3" fill="#5E35B1"/>
|
||||||
|
<text x="1118" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Сеть</text>
|
||||||
|
<rect x="1147" y="684" width="12" height="12" rx="3" fill="#2E7D32"/>
|
||||||
|
<text x="1163" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Потребители</text>
|
||||||
|
<rect x="1237" y="684" width="12" height="12" rx="3" fill="#9E9E9E"/>
|
||||||
|
<text x="1253" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Адреса</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
flowchart LR
|
||||||
|
classDef base fill:#e1f5fe,stroke:#0288d1,stroke-width:2px;
|
||||||
|
classDef net fill:#ede7f6,stroke:#5e35b1,stroke-width:2px;
|
||||||
|
classDef compute fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
|
||||||
|
classDef target fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
|
||||||
|
classDef note fill:#fafafa,stroke:#9e9e9e,stroke-width:1px,stroke-dasharray: 4 4;
|
||||||
|
|
||||||
|
subgraph S1 ["Шаг 1. Тенант"]
|
||||||
|
ORG["<b>Организация (vcOrg: 19)</b><br/>• Тип: iaas / saas"]:::base
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph S2 ["Шаг 2. Пул ресурсов"]
|
||||||
|
VDC["<b>Виртуальный ДЦ (vcVdc: 21)</b><br/>• cpuGuaranteed: 0% / 50% / 80%<br/>• Пул: cpuAllocated, memAllocated, storage"]:::compute
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph S3 ["Шаг 3. Сетевой периметр"]
|
||||||
|
EDGE["<b>Шлюз Edge (vcNsxt: 22)</b><br/>• AVI Load Balancer<br/>• routed-сеть 10.10.102.0/24"]:::net
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph S4 ["Шаг 4. Среда запуска и адреса"]
|
||||||
|
VAPP["<b>Каталог vApp (26)</b><br/>(контейнер для ВМ)"]:::compute
|
||||||
|
IP["<b>Внешние IP</b><br/>• vcOrg modify / 25<br/>• квота адресов (ipSpace)"]:::note
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph S5 ["Шаг 5. Потребители"]
|
||||||
|
VM["<b>Виртуальная машина (28)</b><br/>• vmCpu, vmRam, vmDisk"]:::target
|
||||||
|
SHTURVAL["<b>Кластер Штурвал (150)</b><br/>• sizingPolicy, sizingDisk, count"]:::target
|
||||||
|
end
|
||||||
|
|
||||||
|
ORG -->|"organizationUid"| VDC
|
||||||
|
VDC -->|"vdcUid"| EDGE
|
||||||
|
ORG -.->|"modify: vIPConfigure"| IP
|
||||||
|
EDGE -.->|"modify: SNAT (ipSpaceName)"| IP
|
||||||
|
|
||||||
|
VDC -->|"vdcUid"| VAPP
|
||||||
|
EDGE -->|"nsxtUid"| VAPP
|
||||||
|
|
||||||
|
VAPP -->|"vappUid"| VM
|
||||||
|
IP -.->|"внешний IP"| VM
|
||||||
|
|
||||||
|
VDC -->|"vdcUid"| SHTURVAL
|
||||||
|
EDGE -->|"nsxtUid"| SHTURVAL
|
||||||
|
IP -.->|"внешний IP (>= 3)"| SHTURVAL
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="mermaid-svg" width="100%" xmlns="http://www.w3.org/2000/svg" class="flowchart" style="max-width: 2338.671875px;" viewBox="0 0 2338.671875 552" role="graphics-document document" aria-roledescription="flowchart-v2" xmlns:xlink="http://www.w3.org/1999/xlink"><style xmlns="http://www.w3.org/1999/xhtml">@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css");</style><style>#mermaid-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg .error-icon{fill:#552222;}#mermaid-svg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg .marker{fill:#333333;stroke:#333333;}#mermaid-svg .marker.cross{stroke:#333333;}#mermaid-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg p{margin:0;}#mermaid-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg .cluster-label text{fill:#333;}#mermaid-svg .cluster-label span{color:#333;}#mermaid-svg .cluster-label span p{background-color:transparent;}#mermaid-svg .label text,#mermaid-svg span{fill:#333;color:#333;}#mermaid-svg .node rect,#mermaid-svg .node circle,#mermaid-svg .node ellipse,#mermaid-svg .node polygon,#mermaid-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg .rough-node .label text,#mermaid-svg .node .label text,#mermaid-svg .image-shape .label,#mermaid-svg .icon-shape .label{text-anchor:middle;}#mermaid-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg .rough-node .label,#mermaid-svg .node .label,#mermaid-svg .image-shape .label,#mermaid-svg .icon-shape .label{text-align:center;}#mermaid-svg .node.clickable{cursor:pointer;}#mermaid-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg .arrowheadPath{fill:#333333;}#mermaid-svg .edgePath .path{stroke:#333333;stroke-width:1px;}#mermaid-svg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg .cluster text{fill:#333;}#mermaid-svg .cluster span{color:#333;}#mermaid-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg rect.text{fill:none;stroke-width:0;}#mermaid-svg .icon-shape,#mermaid-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg .icon-shape p,#mermaid-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg .icon-shape .label rect,#mermaid-svg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg .node .neo-node{stroke:#9370DB;}#mermaid-svg [data-look="neo"].node rect,#mermaid-svg [data-look="neo"].cluster rect,#mermaid-svg [data-look="neo"].node polygon{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].swimlane.cluster rect{filter:none;}#mermaid-svg [data-look="neo"].node path{stroke:#9370DB;stroke-width:1px;}#mermaid-svg [data-look="neo"].node .outer-path{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].node .neo-line path{stroke:#9370DB;filter:none;}#mermaid-svg [data-look="neo"].node circle{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].node circle .state-start{fill:#000000;}#mermaid-svg [data-look="neo"].icon-shape .icon{fill:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg [data-look="neo"].icon-shape .icon-neo path{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#mermaid-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg .base>*{fill:rgb(225, 245, 254)!important;stroke:rgb(2, 136, 209)!important;stroke-width:2px!important;}#mermaid-svg .base span{fill:rgb(225, 245, 254)!important;stroke:rgb(2, 136, 209)!important;stroke-width:2px!important;}#mermaid-svg .net>*{fill:rgb(237, 231, 246)!important;stroke:rgb(94, 53, 177)!important;stroke-width:2px!important;}#mermaid-svg .net span{fill:rgb(237, 231, 246)!important;stroke:rgb(94, 53, 177)!important;stroke-width:2px!important;}#mermaid-svg .compute>*{fill:rgb(255, 243, 224)!important;stroke:rgb(245, 124, 0)!important;stroke-width:2px!important;}#mermaid-svg .compute span{fill:rgb(255, 243, 224)!important;stroke:rgb(245, 124, 0)!important;stroke-width:2px!important;}#mermaid-svg .target>*{fill:rgb(232, 245, 233)!important;stroke:rgb(46, 125, 50)!important;stroke-width:2px!important;}#mermaid-svg .target span{fill:rgb(232, 245, 233)!important;stroke:rgb(46, 125, 50)!important;stroke-width:2px!important;}#mermaid-svg .note>*{fill:rgb(250, 250, 250)!important;stroke:rgb(158, 158, 158)!important;stroke-width:1px!important;stroke-dasharray:4,4!important;}#mermaid-svg .note span{fill:rgb(250, 250, 250)!important;stroke:rgb(158, 158, 158)!important;stroke-width:1px!important;stroke-dasharray:4,4!important;}</style><g><marker id="mermaid-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="mermaid-svg_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="mermaid-svg_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster " id="mermaid-svg-S5" data-look="classic"><rect style="" x="2020.671875" y="60" width="310" height="449"/><g class="cluster-label " transform="translate(2101.4140625, 60)"><foreignObject width="148.515625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 5. Потребители</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S4" data-look="classic"><rect style="" x="1539.546875" y="37" width="298.9375" height="322"/><g class="cluster-label " transform="translate(1573.4921875, 37)"><foreignObject width="231.046875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 4. Среда запуска и адреса</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S3" data-look="classic"><rect style="" x="979.546875" y="98" width="310" height="276"/><g class="cluster-label " transform="translate(1039.9453125, 98)"><foreignObject width="189.203125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 3. Сетевой периметр</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S2" data-look="classic"><rect style="" x="471.03125" y="178" width="310" height="366"/><g class="cluster-label " transform="translate(549.703125, 178)"><foreignObject width="152.65625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 2. Пул ресурсов</p></span></div></foreignObject></g></g><g class="cluster " id="mermaid-svg-S1" data-look="classic"><rect style="" x="8" y="8" width="301.84375" height="403"/><g class="cluster-label " transform="translate(107.265625, 8)"><foreignObject width="103.3125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel "><p>Шаг 1. Тенант</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M201.574,265L219.619,281.5C237.664,298,273.754,331,305.231,347.5C336.708,364,363.573,364,390.438,364C417.302,364,444.167,364,461.099,364C478.031,364,485.031,364,488.531,364L492.031,364" id="mermaid-svg-L_ORG_VDC_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_ORG_VDC_0" data-points="W3sieCI6MjAxLjU3MzcwOTIzOTEzMDQ0LCJ5IjoyNjV9LHsieCI6MzA5Ljg0Mzc1LCJ5IjozNjR9LHsieCI6MzkwLjQzNzUsInkiOjM2NH0seyJ4Ijo0NzEuMDMxMjUsInkiOjM2NH0seyJ4Ijo0OTYuMDMxMjUsInkiOjM2NH1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M740.311,277L747.098,271.833C753.884,266.667,767.458,256.333,790.787,251.167C814.117,246,847.203,246,880.289,246C913.375,246,946.461,246,966.504,246C986.547,246,993.547,246,997.047,246L1000.547,246" id="mermaid-svg-L_VDC_EDGE_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_EDGE_0" data-points="W3sieCI6NzQwLjMxMDkxMTAxNjk0OTEsInkiOjI3N30seyJ4Ijo3ODEuMDMxMjUsInkiOjI0Nn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6MjQ2fSx7IngiOjk3OS41NDY4NzUsInkiOjI0Nn0seyJ4IjoxMDA0LjU0Njg3NSwieSI6MjQ2fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M195.481,187L214.541,166.667C233.602,146.333,271.723,105.667,304.216,85.333C336.708,65,363.573,65,390.438,65C417.302,65,444.167,65,483.432,65C522.698,65,574.365,65,626.031,65C677.698,65,729.365,65,771.741,65C814.117,65,847.203,65,880.289,65C913.375,65,946.461,65,988.837,65C1031.214,65,1082.88,65,1134.547,65C1186.214,65,1237.88,65,1284.547,78.333C1331.214,91.667,1372.88,118.333,1414.547,131.667C1456.214,145,1497.88,145,1522.221,144.484C1546.561,143.968,1553.575,142.935,1557.082,142.419L1560.59,141.903" id="mermaid-svg-L_ORG_IP_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_ORG_IP_0" data-points="W3sieCI6MTk1LjQ4MDU5MDA2MjExMTgsInkiOjE4N30seyJ4IjozMDkuODQzNzUsInkiOjY1fSx7IngiOjM5MC40Mzc1LCJ5Ijo2NX0seyJ4Ijo0NzEuMDMxMjUsInkiOjY1fSx7IngiOjYyNi4wMzEyNSwieSI6NjV9LHsieCI6NzgxLjAzMTI1LCJ5Ijo2NX0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NjV9LHsieCI6OTc5LjU0Njg3NSwieSI6NjV9LHsieCI6MTEzNC41NDY4NzUsInkiOjY1fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo2NX0seyJ4IjoxNDE0LjU0Njg3NSwieSI6MTQ1fSx7IngiOjE1MzkuNTQ2ODc1LCJ5IjoxNDV9LHsieCI6MTU2NC41NDY4NzUsInkiOjE0MS4zMjAzMDEwNjYyNzY0fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1258.154,183L1263.387,180.333C1268.619,177.667,1279.083,172.333,1305.148,158.667C1331.214,145,1372.88,123,1414.547,112C1456.214,101,1497.88,101,1522.221,101.516C1546.561,102.032,1553.575,103.065,1557.082,103.581L1560.59,104.097" id="mermaid-svg-L_EDGE_IP_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_IP_0" data-points="W3sieCI6MTI1OC4xNTQ0Njk5MzY3MDksInkiOjE4M30seyJ4IjoxMjg5LjU0Njg3NSwieSI6MTY3fSx7IngiOjE0MTQuNTQ2ODc1LCJ5IjoxMDF9LHsieCI6MTUzOS41NDY4NzUsInkiOjEwMX0seyJ4IjoxNTY0LjU0Njg3NSwieSI6MTA0LjY3OTY5ODkzMzcyMzZ9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M756.031,399.226L760.198,400.355C764.365,401.484,772.698,403.742,793.408,404.871C814.117,406,847.203,406,880.289,406C913.375,406,946.461,406,988.837,406C1031.214,406,1082.88,406,1134.547,406C1186.214,406,1237.88,406,1284.547,390.333C1331.214,374.667,1372.88,343.333,1414.547,327.667C1456.214,312,1497.88,312,1525.684,310.741C1553.487,309.482,1567.428,306.964,1574.398,305.704L1581.368,304.445" id="mermaid-svg-L_VDC_VAPP_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_VAPP_0" data-points="W3sieCI6NzU2LjAzMTI1LCJ5IjozOTkuMjI1ODA2NDUxNjEyOX0seyJ4Ijo3ODEuMDMxMjUsInkiOjQwNn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NDA2fSx7IngiOjk3OS41NDY4NzUsInkiOjQwNn0seyJ4IjoxMTM0LjU0Njg3NSwieSI6NDA2fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo0MDZ9LHsieCI6MTQxNC41NDY4NzUsInkiOjMxMn0seyJ4IjoxNTM5LjU0Njg3NSwieSI6MzEyfSx7IngiOjE1ODUuMzA0Njg3NSwieSI6MzAzLjczNDMxOTQ2NDc3MTF9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1264.547,224.194L1268.714,223.495C1272.88,222.796,1281.214,221.398,1306.214,220.699C1331.214,220,1372.88,220,1414.547,220C1456.214,220,1497.88,220,1528.067,224.067C1558.253,228.135,1576.96,236.27,1586.313,240.337L1595.666,244.405" id="mermaid-svg-L_EDGE_VAPP_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_VAPP_0" data-points="W3sieCI6MTI2NC41NDY4NzUsInkiOjIyNC4xOTM1NDgzODcwOTY3N30seyJ4IjoxMjg5LjU0Njg3NSwieSI6MjIwfSx7IngiOjE0MTQuNTQ2ODc1LCJ5IjoyMjB9LHsieCI6MTUzOS41NDY4NzUsInkiOjIyMH0seyJ4IjoxNTk5LjMzNDM3NSwieSI6MjQ2fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1792.727,285L1800.353,285C1807.979,285,1823.232,285,1846.04,285C1868.849,285,1899.214,285,1929.578,285C1959.943,285,1990.307,285,2019.627,274.237C2048.947,263.474,2077.222,241.949,2091.36,231.186L2105.498,220.423" id="mermaid-svg-L_VAPP_VM_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VAPP_VM_0" data-points="W3sieCI6MTc5Mi43MjY1NjI1LCJ5IjoyODV9LHsieCI6MTgzOC40ODQzNzUsInkiOjI4NX0seyJ4IjoxOTI5LjU3ODEyNSwieSI6Mjg1fSx7IngiOjIwMjAuNjcxODc1LCJ5IjoyODV9LHsieCI6MjEwOC42ODAzNDk1NzYyNzEsInkiOjIxOH1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1813.484,123L1817.651,123C1821.818,123,1830.151,123,1849.5,123C1868.849,123,1899.214,123,1929.578,123C1959.943,123,1990.307,123,2009.015,124.001C2027.723,125.001,2034.773,127.003,2038.299,128.004L2041.824,129.004" id="mermaid-svg-L_IP_VM_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_IP_VM_0" data-points="W3sieCI6MTgxMy40ODQzNzUsInkiOjEyM30seyJ4IjoxODM4LjQ4NDM3NSwieSI6MTIzfSx7IngiOjE5MjkuNTc4MTI1LCJ5IjoxMjN9LHsieCI6MjAyMC42NzE4NzUsInkiOjEyM30seyJ4IjoyMDQ1LjY3MTg3NSwieSI6MTMwLjA5Njc3NDE5MzU0ODM4fV0=" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M756.031,441.161L760.198,443.634C764.365,446.108,772.698,451.054,793.408,453.527C814.117,456,847.203,456,880.289,456C913.375,456,946.461,456,988.837,456C1031.214,456,1082.88,456,1134.547,456C1186.214,456,1237.88,456,1284.547,456C1331.214,456,1372.88,456,1414.547,456C1456.214,456,1497.88,456,1543.625,456C1589.37,456,1639.193,456,1689.016,456C1738.839,456,1788.661,456,1828.755,456C1868.849,456,1899.214,456,1929.578,456C1959.943,456,1990.307,456,2011.337,453.434C2032.368,450.869,2044.063,445.738,2049.911,443.172L2055.759,440.607" id="mermaid-svg-L_VDC_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_VDC_SHTURVAL_0" data-points="W3sieCI6NzU2LjAzMTI1LCJ5Ijo0NDEuMTYxMjkwMzIyNTgwNn0seyJ4Ijo3ODEuMDMxMjUsInkiOjQ1Nn0seyJ4Ijo4ODAuMjg5MDYyNSwieSI6NDU2fSx7IngiOjk3OS41NDY4NzUsInkiOjQ1Nn0seyJ4IjoxMTM0LjU0Njg3NSwieSI6NDU2fSx7IngiOjEyODkuNTQ2ODc1LCJ5Ijo0NTZ9LHsieCI6MTQxNC41NDY4NzUsInkiOjQ1Nn0seyJ4IjoxNTM5LjU0Njg3NSwieSI6NDU2fSx7IngiOjE2ODkuMDE1NjI1LCJ5Ijo0NTZ9LHsieCI6MTgzOC40ODQzNzUsInkiOjQ1Nn0seyJ4IjoxOTI5LjU3ODEyNSwieSI6NDU2fSx7IngiOjIwMjAuNjcxODc1LCJ5Ijo0NTZ9LHsieCI6MjA1OS40MjE4NzUsInkiOjQzOX1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1264.547,303.871L1268.714,305.726C1272.88,307.581,1281.214,311.29,1306.214,325.812C1331.214,340.333,1372.88,365.667,1414.547,378.333C1456.214,391,1497.88,391,1543.625,391C1589.37,391,1639.193,391,1689.016,391C1738.839,391,1788.661,391,1828.755,391C1868.849,391,1899.214,391,1929.578,391C1959.943,391,1990.307,391,2008.99,390.932C2027.672,390.865,2034.672,390.729,2038.172,390.661L2041.673,390.594" id="mermaid-svg-L_EDGE_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_EDGE_SHTURVAL_0" data-points="W3sieCI6MTI2NC41NDY4NzUsInkiOjMwMy44NzA5Njc3NDE5MzU1fSx7IngiOjEyODkuNTQ2ODc1LCJ5IjozMTV9LHsieCI6MTQxNC41NDY4NzUsInkiOjM5MX0seyJ4IjoxNTM5LjU0Njg3NSwieSI6MzkxfSx7IngiOjE2ODkuMDE1NjI1LCJ5IjozOTF9LHsieCI6MTgzOC40ODQzNzUsInkiOjM5MX0seyJ4IjoxOTI5LjU3ODEyNSwieSI6MzkxfSx7IngiOjIwMjAuNjcxODc1LCJ5IjozOTF9LHsieCI6MjA0NS42NzE4NzUsInkiOjM5MC41MTYxMjkwMzIyNTgwNX1d" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/><path d="M1725.143,174L1744.033,200.667C1762.924,227.333,1800.704,280.667,1834.776,307.333C1868.849,334,1899.214,334,1929.578,334C1959.943,334,1990.307,334,2009.027,335.232C2027.746,336.465,2034.82,338.929,2038.357,340.161L2041.895,341.394" id="mermaid-svg-L_IP_SHTURVAL_0" class=" edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_IP_SHTURVAL_0" data-points="W3sieCI6MTcyNS4xNDMxNDI3NzI1MTIsInkiOjE3NH0seyJ4IjoxODM4LjQ4NDM3NSwieSI6MzM0fSx7IngiOjE5MjkuNTc4MTI1LCJ5IjozMzR9LHsieCI6MjAyMC42NzE4NzUsInkiOjMzNH0seyJ4IjoyMDQ1LjY3MTg3NSwieSI6MzQyLjcwOTY3NzQxOTM1NDh9XQ==" data-look="classic" marker-end="url(#mermaid-svg_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(390.4375, 364)"><g class="label" data-id="L_ORG_VDC_0" transform="translate(-55.59375, -12)"><foreignObject width="111.1875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>organizationUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(880.2890625, 246)"><g class="label" data-id="L_VDC_EDGE_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(880.2890625, 65)"><g class="label" data-id="L_ORG_IP_0" transform="translate(-74.2578125, -12)"><foreignObject width="148.515625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>modify: vIPConfigure</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 101)"><g class="label" data-id="L_EDGE_IP_0" transform="translate(-100, -24)"><foreignObject width="200" height="48"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="edgeLabel "><p>modify: SNAT (ipSpaceName)</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1134.546875, 406)"><g class="label" data-id="L_VDC_VAPP_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 220)"><g class="label" data-id="L_EDGE_VAPP_0" transform="translate(-26.6796875, -12)"><foreignObject width="53.359375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>nsxtUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 285)"><g class="label" data-id="L_VAPP_VM_0" transform="translate(-29.3515625, -12)"><foreignObject width="58.703125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vappUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 123)"><g class="label" data-id="L_IP_VM_0" transform="translate(-42.671875, -12)"><foreignObject width="85.34375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>внешний IP</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1414.546875, 456)"><g class="label" data-id="L_VDC_SHTURVAL_0" transform="translate(-24.453125, -12)"><foreignObject width="48.90625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>vdcUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1689.015625, 391)"><g class="label" data-id="L_EDGE_SHTURVAL_0" transform="translate(-26.6796875, -12)"><foreignObject width="53.359375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>nsxtUid</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1929.578125, 334)"><g class="label" data-id="L_IP_SHTURVAL_0" transform="translate(-66.09375, -12)"><foreignObject width="132.1875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel "><p>внешний IP (>= 3)</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default base " id="mermaid-svg-flowchart-ORG-0" data-look="classic" transform="translate(158.921875, 226)"><rect class="basic label-container" style="fill:#e1f5fe !important;stroke:#0288d1 !important;stroke-width:2px !important" x="-125.921875" y="-39" width="251.84375" height="78"/><g class="label" style="" transform="translate(-95.921875, -24)"><rect/><foreignObject width="191.84375" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Организация (vcOrg: 19)</b><br />• Тип: iaas / saas</p></span></div></foreignObject></g></g><g class="node default compute " id="mermaid-svg-flowchart-VDC-1" data-look="classic" transform="translate(626.03125, 364)"><rect class="basic label-container" style="fill:#fff3e0 !important;stroke:#f57c00 !important;stroke-width:2px !important" x="-130" y="-87" width="260" height="174"/><g class="label" style="" transform="translate(-100, -72)"><rect/><foreignObject width="200" height="144"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Виртуальный ДЦ (vcVdc: 21)</b><br />• cpuGuaranteed: 0% / 50% / 80%<br />• Пул: cpuAllocated, memAllocated, storage</p></span></div></foreignObject></g></g><g class="node default net " id="mermaid-svg-flowchart-EDGE-2" data-look="classic" transform="translate(1134.546875, 246)"><rect class="basic label-container" style="fill:#ede7f6 !important;stroke:#5e35b1 !important;stroke-width:2px !important" x="-130" y="-63" width="260" height="126"/><g class="label" style="" transform="translate(-100, -48)"><rect/><foreignObject width="200" height="96"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Шлюз Edge (vcNsxt: 22)</b><br />• AVI Load Balancer<br />• routed-сеть 10.10.102.0/24</p></span></div></foreignObject></g></g><g class="node default compute " id="mermaid-svg-flowchart-VAPP-3" data-look="classic" transform="translate(1689.015625, 285)"><rect class="basic label-container" style="fill:#fff3e0 !important;stroke:#f57c00 !important;stroke-width:2px !important" x="-103.7109375" y="-39" width="207.421875" height="78"/><g class="label" style="" transform="translate(-73.7109375, -24)"><rect/><foreignObject width="147.421875" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Каталог vApp (26)</b><br />(контейнер для ВМ)</p></span></div></foreignObject></g></g><g class="node default note " id="mermaid-svg-flowchart-IP-4" data-look="classic" transform="translate(1689.015625, 123)"><rect class="basic label-container" style="fill:#fafafa !important;stroke:#9e9e9e !important;stroke-width:1px !important;stroke-dasharray:4 4 !important" x="-124.46875" y="-51" width="248.9375" height="102"/><g class="label" style="" transform="translate(-94.46875, -36)"><rect/><foreignObject width="188.9375" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel "><p><b>Внешние IP</b><br />• vcOrg modify / 25<br />• квота адресов (ipSpace)</p></span></div></foreignObject></g></g><g class="node default target " id="mermaid-svg-flowchart-VM-5" data-look="classic" transform="translate(2175.671875, 167)"><rect class="basic label-container" style="fill:#e8f5e9 !important;stroke:#2e7d32 !important;stroke-width:2px !important" x="-130" y="-51" width="260" height="102"/><g class="label" style="" transform="translate(-100, -36)"><rect/><foreignObject width="200" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Виртуальная машина (28)</b><br />• vmCpu, vmRam, vmDisk</p></span></div></foreignObject></g></g><g class="node default target " id="mermaid-svg-flowchart-SHTURVAL-6" data-look="classic" transform="translate(2175.671875, 388)"><rect class="basic label-container" style="fill:#e8f5e9 !important;stroke:#2e7d32 !important;stroke-width:2px !important" x="-130" y="-51" width="260" height="102"/><g class="label" style="" transform="translate(-100, -36)"><rect/><foreignObject width="200" height="72"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel "><p><b>Кластер Штурвал (150)</b><br />• sizingPolicy, sizingDisk, count</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="mermaid-svg-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="mermaid-svg-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs></svg>
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Генератор схемы зависимостей облачных сервисов (PNG + SVG).
|
||||||
|
|
||||||
|
Содержание подтверждено YAML-контрактами generated/dev/resources_yaml.
|
||||||
|
PNG рисуется в 2x и уменьшается (сглаживание), SVG — вектор.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
W, H = 1600, 720
|
||||||
|
SS = 2 # supersample
|
||||||
|
|
||||||
|
FONT_R = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||||
|
FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
|
||||||
|
DARK = "#1A1A1A"
|
||||||
|
GRAY = "#5F6B76"
|
||||||
|
MUTED = "#7A8794"
|
||||||
|
TEXT = "#2E3A45"
|
||||||
|
RED = "#C62828"
|
||||||
|
LINE = "#455A64"
|
||||||
|
|
||||||
|
COLS = [
|
||||||
|
(16, "Шаг 1. Тенант", "базовый уровень изоляции", "#1565C0", "#E8F3FB"),
|
||||||
|
(340, "Шаг 2. Пул ресурсов", "CPU / RAM / Storage", "#E65100", "#FDF1E3"),
|
||||||
|
(664, "Шаг 3. Сетевой периметр", "маршрутизация и балансировка", "#4527A0", "#EEEAF8"),
|
||||||
|
(988, "Шаг 4. Среда запуска", "контейнер и внешние адреса", "#EF6C00", "#FDF4E3"),
|
||||||
|
(1312, "Шаг 5. Потребители", "что разворачивается в итоге", "#2E7D32", "#E9F5EA"),
|
||||||
|
]
|
||||||
|
COL_W = 270
|
||||||
|
|
||||||
|
# блоки: (x, y, w, h, заголовок-строки, подпись, строки, бейдж, рамка, фон, пунктир)
|
||||||
|
BLOCKS = [
|
||||||
|
(31, 150, 240, 150, ["Организация", "в Cloud Director"], "Сервис 19 · vcOrg",
|
||||||
|
["Типы: iaas / saas"], None, "#0288D1", "#FFFFFF", False),
|
||||||
|
(355, 130, 240, 230, ["Виртуальный", "датацентр (vDC)"], "Сервис 21 · vcVdc",
|
||||||
|
["cpuGuaranteed: 0% / 50% / 80%", "cpuAllocated, memAllocated", "storageConfig"],
|
||||||
|
"здесь выделяются CPU / RAM / Диск", "#F57C00", "#FFF3E0", False),
|
||||||
|
(679, 120, 240, 200, ["Сетевой шлюз", "периметра (Edge)"], "Сервис 22 · vcNsxt",
|
||||||
|
["AVI Load Balancer", "routed-сеть 10.10.102.0/24"],
|
||||||
|
"SNAT настраивается операцией modify", "#5E35B1", "#EDE7F6", False),
|
||||||
|
(1003, 100, 240, 110, ["Каталог ВМ (vApp)"], "Сервис 26 · vapp",
|
||||||
|
["логический контейнер для ВМ"], None, "#F57C00", "#FFF3E0", False),
|
||||||
|
(1003, 245, 180, 150, ["Внешние IP"], "Сервис 25",
|
||||||
|
["выделение: vcOrg", "NAT: сервис 25", "квота IP (ipSpace)"],
|
||||||
|
"здесь выделяются IP", "#9E9E9E", "#FAFAFA", True),
|
||||||
|
(1327, 100, 240, 120, ["Виртуальная машина"], "Сервис 28 · vc_vm_v3",
|
||||||
|
["vmCpu · vmRam · vmDisk"], None, "#2E7D32", "#E8F5E9", False),
|
||||||
|
(1327, 255, 240, 170, ["Kubernetes кластер", "Штурвал"], "Сервис 150",
|
||||||
|
["sizingPolicy · sizingDisk", "· count"], "требует ALB + VS ≥ 3",
|
||||||
|
"#2E7D32", "#E8F5E9", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
# связи: (точки, цвет, толщина, пунктир, подпись, позиция подписи, вертикальная)
|
||||||
|
EDGES = [
|
||||||
|
([(271, 225), (355, 225)], LINE, 2, False, "organizationUid", (313, 262), True),
|
||||||
|
([(595, 200), (679, 200)], LINE, 2, False, "vdcUid", (637, 186), False),
|
||||||
|
([(500, 130), (500, 92), (961, 92), (961, 140), (1003, 140)], LINE, 2, False,
|
||||||
|
"vdcUid", (706, 96), False),
|
||||||
|
([(919, 180), (1003, 180)], LINE, 2, False, "nsxtUid", (961, 166), False),
|
||||||
|
([(1243, 140), (1327, 140)], LINE, 2, False, "vappUid", (1285, 126), False),
|
||||||
|
([(500, 360), (500, 470), (1447, 470), (1447, 425)], LINE, 2, False,
|
||||||
|
"vdcUid", (1180, 456), False),
|
||||||
|
([(799, 320), (799, 505), (1467, 505), (1467, 425)], LINE, 2, False,
|
||||||
|
"nsxtUid", (1180, 491), False),
|
||||||
|
([(151, 300), (151, 540), (1050, 540), (1050, 395)], MUTED, 2, True,
|
||||||
|
"modify: vIPConfigure", (161, 526), False),
|
||||||
|
([(870, 320), (870, 400), (1140, 400), (1140, 395)], MUTED, 2, True,
|
||||||
|
"modify: SNAT", (880, 386), False),
|
||||||
|
([(1050, 210), (1050, 245)], MUTED, 2, True, "требует Каталог", (1060, 218), False),
|
||||||
|
([(1183, 270), (1255, 270), (1255, 160), (1327, 160)], MUTED, 2, True,
|
||||||
|
"внешний IP", (1187, 222), False),
|
||||||
|
([(1183, 350), (1327, 350)], RED, 2, True, "≥ 3 адреса", (1187, 330), False),
|
||||||
|
]
|
||||||
|
|
||||||
|
CAPTION = [
|
||||||
|
"Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал.",
|
||||||
|
"SNAT — не отдельный сервис, а операция modify у Edge.",
|
||||||
|
"vDC и Edge могут быть переиспользованы существующие.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def op_rect(x, y, w, h, fill, outline, lw=2, r=10, dash=False):
|
||||||
|
return ("rect", (x, y, w, h, fill, outline, lw, r, dash))
|
||||||
|
|
||||||
|
|
||||||
|
def op_text(x, y, s, size, color, bold=False, anchor="lt", vertical=False):
|
||||||
|
return ("text", (x, y, s, size, color, bold, anchor, vertical))
|
||||||
|
|
||||||
|
|
||||||
|
def op_path(pts, color, lw, dash):
|
||||||
|
return ("path", (pts, color, lw, dash))
|
||||||
|
|
||||||
|
|
||||||
|
def op_dot(x, y, r, color):
|
||||||
|
return ("dot", (x, y, r, color))
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
ops = []
|
||||||
|
for cx, title, sub, accent, tint in COLS:
|
||||||
|
ops.append(op_rect(cx, 8, COL_W, 552, tint, "#FFFFFF", 0, 12))
|
||||||
|
ops.append(op_rect(cx, 14, COL_W, 44, accent, accent, 0, 8))
|
||||||
|
ops.append(op_text(cx + COL_W / 2, 25, title, 15, "#FFFFFF", True, "mt"))
|
||||||
|
ops.append(op_text(cx + COL_W / 2, 68, sub, 12, MUTED, False, "mt"))
|
||||||
|
|
||||||
|
for x, y, w, h, titles, sub, lines, badge, border, fill, dashed in BLOCKS:
|
||||||
|
ops.append(op_rect(x, y, w, h, fill, border, 2, 10, dashed))
|
||||||
|
ops.append(op_rect(x + 12, y + 12, 22, 22, border, border, 0, 5))
|
||||||
|
tx = x + 42
|
||||||
|
ty = y + 12
|
||||||
|
for i, t in enumerate(titles):
|
||||||
|
ops.append(op_text(tx, ty + i * 20, t, 15, DARK, True))
|
||||||
|
sy = ty + len(titles) * 20
|
||||||
|
ops.append(op_text(tx, sy, sub, 11, GRAY))
|
||||||
|
ly = sy + 22
|
||||||
|
for ln in lines:
|
||||||
|
ops.append(op_dot(x + 20, ly + 7, 2.4, border))
|
||||||
|
ops.append(op_text(x + 30, ly, ln, 10, TEXT))
|
||||||
|
ly += 20
|
||||||
|
if badge:
|
||||||
|
ops.append(op_rect(x + 12, y + h - 42, w - 24, 30, "#FFFFFF", border, 1, 6))
|
||||||
|
ops.append(op_text(x + w / 2, y + h - 32, badge, 10, border, False, "mt"))
|
||||||
|
|
||||||
|
for pts, color, lw, dash, label, pos, vertical in EDGES:
|
||||||
|
ops.append(op_path(pts, color, lw, dash))
|
||||||
|
ops.append(op_text(pos[0], pos[1], label, 10, color, False,
|
||||||
|
"lt" if not vertical else "lt", vertical))
|
||||||
|
|
||||||
|
for i, s in enumerate(CAPTION):
|
||||||
|
ops.append(op_text(24, 590 + i * 26, "> " + s, 14, TEXT))
|
||||||
|
|
||||||
|
ops.append(op_path([(980, 606), (1040, 606)], LINE, 2, False))
|
||||||
|
ops.append(op_text(1052, 597, "Сплошная линия — обязательная зависимость", 11, TEXT))
|
||||||
|
ops.append(op_text(1052, 613, "(ссылка на UUID существующего инстанса)", 10, MUTED))
|
||||||
|
ops.append(op_path([(980, 650), (1040, 650)], MUTED, 2, True))
|
||||||
|
ops.append(op_text(1052, 641, "Пунктир — выделение ресурса / modify / предусловие", 11, TEXT))
|
||||||
|
|
||||||
|
swatches = [("#1565C0", "Тенант"), ("#F57C00", "Ресурсы"), ("#5E35B1", "Сеть"),
|
||||||
|
("#2E7D32", "Потребители"), ("#9E9E9E", "Адреса")]
|
||||||
|
sx = 980
|
||||||
|
for color, name in swatches:
|
||||||
|
ops.append(op_rect(sx, 684, 12, 12, color, color, 0, 3))
|
||||||
|
ops.append(op_text(sx + 16, 682, name, 10, TEXT))
|
||||||
|
sx += 20 + int(len(name) * 6.4)
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
def accent_of(border):
|
||||||
|
return border
|
||||||
|
|
||||||
|
|
||||||
|
def dash_segments(p1, p2, on=9, off=6):
|
||||||
|
(x1, y1), (x2, y2) = p1, p2
|
||||||
|
segs, total = [], ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
|
||||||
|
if total == 0:
|
||||||
|
return segs
|
||||||
|
ux, uy = (x2 - x1) / total, (y2 - y1) / total
|
||||||
|
pos = 0.0
|
||||||
|
while pos < total:
|
||||||
|
end = min(pos + on, total)
|
||||||
|
segs.append(((x1 + ux * pos, y1 + uy * pos), (x1 + ux * end, y1 + uy * end)))
|
||||||
|
pos = end + off
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def arrow_head(d, tip, tail, color, size=9):
|
||||||
|
(tx, ty), (px, py) = tip, tail
|
||||||
|
dx, dy = tx - px, ty - py
|
||||||
|
n = (dx * dx + dy * dy) ** 0.5 or 1
|
||||||
|
ux, uy = dx / n, dy / n
|
||||||
|
bx, by = tx - ux * size, ty - uy * size
|
||||||
|
ox, oy = -uy * size * 0.42, ux * size * 0.42
|
||||||
|
d.polygon([(tx, ty), (bx + ox, by + oy), (bx - ox, by - oy)], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_polyline(d, pts, color, lw, dash):
|
||||||
|
for a, b in zip(pts, pts[1:]):
|
||||||
|
segs = dash_segments(a, b) if dash else [(a, b)]
|
||||||
|
for s in segs:
|
||||||
|
d.line([s[0], s[1]], fill=color, width=lw)
|
||||||
|
if len(pts) >= 2:
|
||||||
|
tail = pts[-2]
|
||||||
|
tip = pts[-1]
|
||||||
|
dx, dy = tip[0] - tail[0], tip[1] - tail[1]
|
||||||
|
n = (dx * dx + dy * dy) ** 0.5 or 1
|
||||||
|
shrink = 9
|
||||||
|
tip2 = (tip[0] - dx / n * shrink, tip[1] - dy / n * shrink)
|
||||||
|
d.line([tail, tip2], fill=color, width=lw)
|
||||||
|
arrow_head(d, tip, tail, color)
|
||||||
|
|
||||||
|
|
||||||
|
def render_png(ops, path):
|
||||||
|
img = Image.new("RGB", (W * SS, H * SS), "#FFFFFF")
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
fonts = {}
|
||||||
|
|
||||||
|
def fnt(size, bold):
|
||||||
|
key = (size, bold)
|
||||||
|
if key not in fonts:
|
||||||
|
fonts[key] = ImageFont.truetype(FONT_B if bold else FONT_R, size * SS)
|
||||||
|
return fonts[key]
|
||||||
|
|
||||||
|
for kind, a in ops:
|
||||||
|
if kind == "rect":
|
||||||
|
x, y, w, h, fill, outline, lw, r, dashed = a
|
||||||
|
box = [x * SS, y * SS, (x + w) * SS, (y + h) * SS]
|
||||||
|
if dashed:
|
||||||
|
d.rounded_rectangle(box, radius=r * SS, fill=fill)
|
||||||
|
for a1, b1 in [((x, y), (x + w, y)), ((x + w, y), (x + w, y + h)),
|
||||||
|
((x + w, y + h), (x, y + h)), ((x, y + h), (x, y))]:
|
||||||
|
for s in dash_segments((a1[0] * SS, a1[1] * SS), (b1[0] * SS, b1[1] * SS),
|
||||||
|
on=8 * SS, off=6 * SS):
|
||||||
|
d.line([s[0], s[1]], fill=outline, width=max(1, lw * SS))
|
||||||
|
else:
|
||||||
|
d.rounded_rectangle(box, radius=r * SS, fill=fill,
|
||||||
|
outline=outline if lw else None,
|
||||||
|
width=max(1, lw * SS) if lw else 0)
|
||||||
|
elif kind == "text":
|
||||||
|
x, y, s, size, color, bold, anchor, vertical = a
|
||||||
|
f = fnt(size, bold)
|
||||||
|
if vertical:
|
||||||
|
bbox = f.getbbox(s)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
tmp = Image.new("RGBA", (tw + 4, th + 4), (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(tmp).text((-bbox[0] + 2, -bbox[1] + 2), s, font=f, fill=color)
|
||||||
|
tmp = tmp.rotate(90, expand=True)
|
||||||
|
img.paste(tmp, (int(x * SS - tmp.width / 2), int(y * SS - tmp.height / 2)), tmp)
|
||||||
|
else:
|
||||||
|
d.text((x * SS, y * SS), s, font=f, fill=color, anchor=anchor)
|
||||||
|
elif kind == "path":
|
||||||
|
pts, color, lw, dash = a
|
||||||
|
draw_polyline(d, [(px * SS, py * SS) for px, py in pts], color,
|
||||||
|
max(1, lw * SS), dash)
|
||||||
|
elif kind == "dot":
|
||||||
|
x, y, r, color = a
|
||||||
|
d.ellipse([(x - r) * SS, (y - r) * SS, (x + r) * SS, (y + r) * SS], fill=color)
|
||||||
|
|
||||||
|
img = img.resize((W, H), Image.LANCZOS)
|
||||||
|
img.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(ops, path):
|
||||||
|
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
|
||||||
|
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Verdana, sans-serif">',
|
||||||
|
f'<rect width="{W}" height="{H}" fill="#FFFFFF"/>']
|
||||||
|
|
||||||
|
def esc(s):
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
for kind, a in ops:
|
||||||
|
if kind == "rect":
|
||||||
|
x, y, w, h, fill, outline, lw, r, dashed = a
|
||||||
|
stroke = f' stroke="{outline}" stroke-width="{lw}"' if lw else ""
|
||||||
|
dash = ' stroke-dasharray="9 6"' if dashed else ""
|
||||||
|
out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" '
|
||||||
|
f'fill="{fill}"{stroke}{dash}/>')
|
||||||
|
elif kind == "text":
|
||||||
|
x, y, s, size, color, bold, anchor, vertical = a
|
||||||
|
weight = ' font-weight="bold"' if bold else ""
|
||||||
|
if vertical:
|
||||||
|
ta = ' text-anchor="middle" dominant-baseline="middle"'
|
||||||
|
transform = f' transform="rotate(-90 {x} {y})"'
|
||||||
|
else:
|
||||||
|
ta = (' text-anchor="middle" dominant-baseline="hanging"' if anchor == "mt"
|
||||||
|
else ' dominant-baseline="hanging"')
|
||||||
|
transform = ""
|
||||||
|
out.append(f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}"'
|
||||||
|
f'{weight}{ta}{transform}>{esc(s)}</text>')
|
||||||
|
elif kind == "path":
|
||||||
|
pts, color, lw, dash = a
|
||||||
|
d = " ".join(("M" if i == 0 else "L") + f"{p[0]} {p[1]}"
|
||||||
|
for i, p in enumerate(pts))
|
||||||
|
dash = ' stroke-dasharray="9 6"' if dash else ""
|
||||||
|
out.append(f'<path d="{d}" fill="none" stroke="{color}" '
|
||||||
|
f'stroke-width="{lw}"{dash} stroke-linejoin="round"/>')
|
||||||
|
(tx, ty), (px, py) = pts[-1], pts[-2]
|
||||||
|
dx, dy = tx - px, ty - py
|
||||||
|
n = (dx * dx + dy * dy) ** 0.5 or 1
|
||||||
|
ux, uy = dx / n, dy / n
|
||||||
|
s_ = 9
|
||||||
|
bx, by = tx - ux * s_, ty - uy * s_
|
||||||
|
ox, oy = -uy * s_ * 0.42, ux * s_ * 0.42
|
||||||
|
out.append(f'<polygon points="{tx},{ty} {bx + ox},{by + oy} '
|
||||||
|
f'{bx - ox},{by - oy}" fill="{color}"/>')
|
||||||
|
elif kind == "dot":
|
||||||
|
x, y, r, color = a
|
||||||
|
out.append(f'<circle cx="{x}" cy="{y}" r="{r}" fill="{color}"/>')
|
||||||
|
|
||||||
|
out.append("</svg>")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("\n".join(out))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Файлы кладём рядом со скриптом: docs/diagrams/
|
||||||
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ops = build()
|
||||||
|
print(render_png(ops, os.path.join(base, "infra_services_diagram.png")))
|
||||||
|
print(render_svg(ops, os.path.join(base, "infra_services_diagram.svg")))
|
||||||
@@ -48,6 +48,11 @@
|
|||||||
- map/json → "{}"
|
- map/json → "{}"
|
||||||
- list/array → "[]"
|
- list/array → "[]"
|
||||||
- Пустые строки в JSON-параметрах запрещены (валидаторы на plan).
|
- Пустые строки в JSON-параметрах запрещены (валидаторы на plan).
|
||||||
|
- UUID-подстроки в JSON (map-fixed) приводятся к lowercase **и при сравнении, и при отправке**
|
||||||
|
в API (`resources_core.BuildJSON` → `jsonutil.LowercaseUUIDsInText`). Платформа сравнивает
|
||||||
|
регистр UUID при create, а ресурсы могут отдавать `id` в UPPERCASE (например `nsxtUid`).
|
||||||
|
Поэтому `lower(...)` в конфигах стендов не нужен — см.
|
||||||
|
`docs/60_strategy/terraform_case_sensitivity_fix.md` §10–§11.
|
||||||
|
|
||||||
## 8. Soft Delete и карантин
|
## 8. Soft Delete и карантин
|
||||||
- Для тяжёлых ресурсов delete часто заменён на suspend с периодом удержания.
|
- Для тяжёлых ресурсов delete часто заменён на suspend с периодом удержания.
|
||||||
|
|||||||
+1
-1
@@ -61,5 +61,5 @@ nav:
|
|||||||
- Глоссарий: 30_registry/guides/glossary.md
|
- Глоссарий: 30_registry/guides/glossary.md
|
||||||
- Проверенные примеры:
|
- Проверенные примеры:
|
||||||
- PostgreSQL: curated/postgres/pg_user_db.md
|
- PostgreSQL: curated/postgres/pg_user_db.md
|
||||||
- Пайплайн vDC → Edge → IP → SNAT → Штурвал: curated/pipeline/vdc_edge_ip_snat.md
|
- Пайплайн vDC → Edge → IP → SNAT → vApp → ВМ → Штурвал: curated/pipeline/vdc_edge_ip_snat.md
|
||||||
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
|
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
|
||||||
|
|||||||
@@ -52,8 +52,111 @@ func TestNormalizeValue_TrimSpace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== waitForOperationFinish tests =====
|
func TestIsRetryable(t *testing.T) {
|
||||||
|
retryable := []int{
|
||||||
|
http.StatusUnauthorized, // 401 — транзиентный отказ Gateway
|
||||||
|
http.StatusTooManyRequests, // 429
|
||||||
|
http.StatusServiceUnavailable, // 503
|
||||||
|
http.StatusBadGateway, // 502
|
||||||
|
http.StatusGatewayTimeout, // 504
|
||||||
|
}
|
||||||
|
for _, code := range retryable {
|
||||||
|
if !isRetryable(code) {
|
||||||
|
t.Errorf("expected isRetryable(%d)=true", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notRetryable := []int{
|
||||||
|
http.StatusBadRequest, // 400
|
||||||
|
http.StatusForbidden, // 403
|
||||||
|
http.StatusNotFound, // 404
|
||||||
|
http.StatusInternalServerError, // 500
|
||||||
|
}
|
||||||
|
for _, code := range notRetryable {
|
||||||
|
if isRetryable(code) {
|
||||||
|
t.Errorf("expected isRetryable(%d)=false", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent-пропуск НЕ должен создавать операцию: иначе останется «черновик»
|
||||||
|
// (POST /instanceOperations без run), и следующий modify будет ждать idle до таймаута.
|
||||||
|
func TestRunInstanceOperationByIdempotent_SkipsWithoutCreatingOperation(t *testing.T) {
|
||||||
|
var opPosts atomic.Int32
|
||||||
|
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/instances/inst-1":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"instance":{"instanceUid":"inst-1","serviceId":22,"explainedStatus":"running","isDeleted":false,"operationIsPending":false,"operationIsInProgress":false,"availableOperations":[{"svcOperationId":207,"operation":"modify"}],"state":{"params":{"ipSpaceName":"internet-ipv4-v1"}}}}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/instanceOperations/default/207":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"svcOperation":{"cfsParams":[{"svcOperationCfsParamId":372,"code":"ipSpaceName","dataType":"string"}]}}`))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
|
||||||
|
opPosts.Add(1)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"instanceOperationUid":"op-x"}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := c.RunInstanceOperationUniversalByIdempotent(context.Background(), "inst-1", "modify",
|
||||||
|
map[string]string{"ipSpaceName": "internet-ipv4-v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error on idempotent skip, got: %v", err)
|
||||||
|
}
|
||||||
|
if n := opPosts.Load(); n != 0 {
|
||||||
|
t.Fatalf("operation must NOT be created when live already matches, got %d POST(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
|
||||||
|
// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает:
|
||||||
|
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
|
||||||
|
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
|
||||||
|
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/instances":
|
||||||
|
w.Header().Set("Location", "./inst-9")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"instanceUid":"inst-9"}`))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"ERROR":"boom"}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from failed operation")
|
||||||
|
}
|
||||||
|
if uid != "inst-9" {
|
||||||
|
t.Fatalf("expected instanceUid preserved on error, got %q", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инстанс НЕ создан: uid обязан быть пустым (иначе запишем в state несуществующий ресурс).
|
||||||
|
func TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails(t *testing.T) {
|
||||||
|
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"ERROR":"bad request"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if uid != "" {
|
||||||
|
t.Fatalf("expected empty uid before creation, got %q", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== waitForOperationFinish tests =====
|
||||||
func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) {
|
func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) {
|
||||||
srv := httptest.NewServer(handler)
|
srv := httptest.NewServer(handler)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -117,8 +117,13 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isRetryable returns true for transient HTTP errors that can be retried.
|
// isRetryable returns true for transient HTTP errors that can be retried.
|
||||||
|
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
|
||||||
|
// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера),
|
||||||
|
// поэтому при постоянном 401 это даст 3 холостых повтора с backoff.
|
||||||
|
// Ретраится только для GET (см. условие в doRequest).
|
||||||
func isRetryable(statusCode int) bool {
|
func isRetryable(statusCode int) bool {
|
||||||
return statusCode == http.StatusTooManyRequests || // 429
|
return statusCode == http.StatusUnauthorized || // 401
|
||||||
|
statusCode == http.StatusTooManyRequests || // 429
|
||||||
statusCode == http.StatusServiceUnavailable || // 503
|
statusCode == http.StatusServiceUnavailable || // 503
|
||||||
statusCode == http.StatusBadGateway || // 502
|
statusCode == http.StatusBadGateway || // 502
|
||||||
statusCode == http.StatusGatewayTimeout // 504
|
statusCode == http.StatusGatewayTimeout // 504
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
|
|||||||
|
|
||||||
opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload)
|
opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
// Инстанс УЖЕ создан: возвращаем uid вместе с ошибкой, чтобы вызывающий
|
||||||
|
// мог зафиксировать его в state (иначе облачный инстанс осиротеет — Q5).
|
||||||
|
return instanceUid, err
|
||||||
}
|
}
|
||||||
|
|
||||||
opUid := extractUIDFromLocation(opHeaders.Get("Location"))
|
opUid := extractUIDFromLocation(opHeaders.Get("Location"))
|
||||||
@@ -63,7 +65,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opUid == "" {
|
if opUid == "" {
|
||||||
return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location"))
|
return instanceUid, fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil)
|
||||||
@@ -71,12 +73,12 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
|
|||||||
hasOpDetails := false
|
hasOpDetails := false
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil {
|
if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil {
|
||||||
return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr)
|
return instanceUid, fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr)
|
||||||
}
|
}
|
||||||
hasOpDetails = true
|
hasOpDetails = true
|
||||||
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
|
params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return instanceUid, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
|
|||||||
}
|
}
|
||||||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
return instanceUid, fmt.Errorf("не удалось установить параметр %d: %w", paramId, err)
|
||||||
}
|
}
|
||||||
sent[paramId] = true
|
sent[paramId] = true
|
||||||
}
|
}
|
||||||
@@ -118,27 +120,27 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context,
|
|||||||
}
|
}
|
||||||
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
_, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
return instanceUid, fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
_, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("валидация не пройдена: %w", err)
|
return instanceUid, fmt.Errorf("валидация не пройдена: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
_, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("выполнение не удалось: %w", err)
|
return instanceUid, fmt.Errorf("выполнение не удалось: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
// НЕ МЕНЯТЬ: завершение операции определяется по dtFinish
|
||||||
if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil {
|
if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil {
|
||||||
return "", err
|
return instanceUid, err
|
||||||
}
|
}
|
||||||
if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil {
|
if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil {
|
||||||
return "", err
|
return instanceUid, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return instanceUid, nil
|
return instanceUid, nil
|
||||||
|
|||||||
@@ -1,68 +1,73 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"terraform-provider-nubes/internal/core/jsonutil"
|
"terraform-provider-nubes/internal/core/jsonutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// modifierDesiredEqualsCurrent сравнивает желаемые значения полей модификатора
|
// modifierValuesEqual сравнивает одно значение с учётом типа параметра:
|
||||||
// (keyed by code) с живыми значениями из cfsParams (ParamValue).
|
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
|
||||||
|
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
|
||||||
|
func modifierValuesEqual(wanted, current string, param universalCfsParam) bool {
|
||||||
|
dataType := strings.ToLower(strings.TrimSpace(param.DataType))
|
||||||
|
if strings.HasPrefix(dataType, "array") ||
|
||||||
|
strings.HasPrefix(dataType, "map") ||
|
||||||
|
strings.Contains(dataType, "json") {
|
||||||
|
return jsonutil.JSONStringsEquivalent(wanted, current)
|
||||||
|
}
|
||||||
|
return normalizeUniversalValueV6(wanted, param) == normalizeUniversalValueV6(current, param)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifierDesiredEqualsLive — idempotency-pre-check по ЖИВЫМ параметрам инстанса
|
||||||
|
// (state.params), а не по paramValue формы операции.
|
||||||
//
|
//
|
||||||
// Возвращает true, если ВСЕ поля совпали (можно пропустить run при idempotency).
|
// Зачем: paramValue из cfsParams — это дефолт ФОРМЫ операции, а не состояние
|
||||||
// Семантика сравнения:
|
// инстанса (см. instanceLiveParams, HAR/edge_.har). Пропуск modify на основе
|
||||||
// - bool/int/string: нормализуются через normalizeUniversalValueV6 и сравниваются строками;
|
// формы может быть ложным. Здесь источник — live.
|
||||||
// - map-fixed: JSON-сравнение (порядок ключей не значим);
|
//
|
||||||
// - array-map-fixed: JSON-сравнение сырых значений (normalize не строит дефолт для массивов).
|
// (true, nil) возвращается только если ВСЕ desired-поля найдены в live и совпали.
|
||||||
func (c *UniversalClient) modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool {
|
// Любая неопределённость (пусто, поле не найдено, ошибка) → (false, …): modify
|
||||||
|
// лучше выполнить лишний раз, чем пропустить нужный.
|
||||||
|
func (c *UniversalClient) modifierDesiredEqualsLive(ctx context.Context, instanceUid string, desired map[string]string, cfsParams []universalCfsParam) (bool, error) {
|
||||||
if len(desired) == 0 {
|
if len(desired) == 0 {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
live, err := c.instanceLiveParams(ctx, instanceUid)
|
||||||
codeToParam := make(map[string]universalCfsParam)
|
if err != nil {
|
||||||
for _, p := range cfsParams {
|
return false, err
|
||||||
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
|
|
||||||
codeToParam[key] = p
|
|
||||||
}
|
|
||||||
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
|
|
||||||
codeToParam[key] = p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if len(live) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
codeToParam := modifierCodeMap(cfsParams)
|
||||||
for code, wanted := range desired {
|
for code, wanted := range desired {
|
||||||
param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))]
|
param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))]
|
||||||
if !ok {
|
if !ok {
|
||||||
// Код не найден в схеме — не можем сравнить, считаем «не равно».
|
return false, nil
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
current, ok := lookupLiveParam(live, param)
|
||||||
current := ""
|
if !ok {
|
||||||
if param.ParamValue != nil {
|
return false, nil
|
||||||
current = *param.ParamValue
|
|
||||||
}
|
}
|
||||||
|
if !modifierValuesEqual(wanted, current, param) {
|
||||||
dataType := strings.ToLower(strings.TrimSpace(param.DataType))
|
return false, nil
|
||||||
if strings.HasPrefix(dataType, "array") {
|
|
||||||
// array-map-fixed: сравнивать сырые значения как JSON.
|
|
||||||
if !jsonutil.JSONStringsEquivalent(wanted, current) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(dataType, "map") || strings.Contains(dataType, "json") {
|
|
||||||
if !jsonutil.JSONStringsEquivalent(wanted, current) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скаляры: нормализуем обе стороны.
|
|
||||||
nw := normalizeUniversalValueV6(wanted, param)
|
|
||||||
nc := normalizeUniversalValueV6(current, param)
|
|
||||||
if nw != nc {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true, nil
|
||||||
return true
|
}
|
||||||
|
|
||||||
|
// modifierCodeMap индексирует cfsParams по нижнему регистру Code и SvcOperationCfsParam.
|
||||||
|
func modifierCodeMap(cfsParams []universalCfsParam) map[string]universalCfsParam {
|
||||||
|
m := make(map[string]universalCfsParam, len(cfsParams)*2)
|
||||||
|
for _, p := range cfsParams {
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
|
||||||
|
m[key] = p
|
||||||
|
}
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
|
||||||
|
m[key] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,100 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) {
|
"net/http"
|
||||||
c := &UniversalClient{}
|
"testing"
|
||||||
cfsParams := []universalCfsParam{
|
)
|
||||||
{SvcOperationCfsParamId: 340, Code: "needEnableAVI", DataType: "boolean", ParamValue: strPtr("false")},
|
|
||||||
{SvcOperationCfsParamId: 369, Code: "virtualServicesCount", DataType: "integer > 0", ParamValue: strPtr("3")},
|
|
||||||
{SvcOperationCfsParamId: 856, Code: "qosProfile", DataType: "string", ParamValue: strPtr("QoS-100Mbit")},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func TestModifierValuesEqual_Scalars(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
desired map[string]string
|
wanted, current string
|
||||||
want bool
|
param universalCfsParam
|
||||||
|
want bool
|
||||||
}{
|
}{
|
||||||
{"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true},
|
{"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true},
|
||||||
{"bool расхождение", map[string]string{"needEnableAVI": "true"}, false},
|
{"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false},
|
||||||
{"int расхождение", map[string]string{"virtualServicesCount": "1"}, false},
|
{"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true},
|
||||||
{"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true},
|
{"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false},
|
||||||
{"код не найден в схеме", map[string]string{"неизвестный": "x"}, false},
|
{"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want {
|
if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want {
|
||||||
t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want)
|
t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) {
|
func TestModifierValuesEqual_JSON(t *testing.T) {
|
||||||
c := &UniversalClient{}
|
mapParam := universalCfsParam{DataType: "map-fixed"}
|
||||||
// map-fixed: порядок ключей не значим
|
// map-fixed: порядок ключей не значим
|
||||||
cfsParams := []universalCfsParam{
|
if !modifierValuesEqual(
|
||||||
{SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed",
|
`{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
|
||||||
ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)},
|
`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`,
|
||||||
}
|
mapParam) {
|
||||||
desired := map[string]string{
|
|
||||||
"routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
|
|
||||||
}
|
|
||||||
if !c.modifierDesiredEqualsCurrent(desired, cfsParams) {
|
|
||||||
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
|
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) {
|
arrParam := universalCfsParam{DataType: "array-map-fixed"}
|
||||||
c := &UniversalClient{}
|
|
||||||
// array-map-fixed: порядок элементов массива значим
|
// array-map-fixed: порядок элементов массива значим
|
||||||
cfsParams := []universalCfsParam{
|
if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) {
|
||||||
{SvcOperationCfsParamId: 662, Code: "vIPConfigure", DataType: "array-map-fixed",
|
|
||||||
ParamValue: strPtr(`[{"name":"a","count":1},{"name":"b","count":2}]`)},
|
|
||||||
}
|
|
||||||
// тот же порядок — равно
|
|
||||||
if !c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"a","count":1},{"name":"b","count":2}]`}, cfsParams) {
|
|
||||||
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
|
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
|
||||||
}
|
}
|
||||||
// другой порядок — не равно
|
if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) {
|
||||||
if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) {
|
|
||||||
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
|
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModifierDesiredEqualsLive(t *testing.T) {
|
||||||
|
cfsParams := []universalCfsParam{
|
||||||
|
{SvcOperationCfsParamId: 372, Code: "ipSpaceName", DataType: "string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
newClient := func(liveParams string) (*UniversalClient, func()) {
|
||||||
|
return makeTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"instance":{"state":{"params":` + liveParams + `}}}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("совпало с live", func(t *testing.T) {
|
||||||
|
c, cleanup := newClient(`{"ipSpaceName":"internet-ipv4-v1"}`)
|
||||||
|
defer cleanup()
|
||||||
|
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if !equal {
|
||||||
|
t.Errorf("expected equal when live matches desired")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("отличается от live", func(t *testing.T) {
|
||||||
|
c, cleanup := newClient(`{"ipSpaceName":"no-needed"}`)
|
||||||
|
defer cleanup()
|
||||||
|
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if equal {
|
||||||
|
t.Errorf("expected not equal when live differs from desired")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("live недоступен — ошибка, не пропускаем", func(t *testing.T) {
|
||||||
|
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
equal, err := c.modifierDesiredEqualsLive(context.Background(), "inst-1", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, cfsParams)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error when live is unavailable")
|
||||||
|
}
|
||||||
|
if equal {
|
||||||
|
t.Errorf("must not report equal when live is unavailable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str
|
|||||||
}
|
}
|
||||||
return def.SvcOperation.CfsParams, nil
|
return def.SvcOperation.CfsParams, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchOperationSchemaByID возвращает схему операции по svcOperationId БЕЗ создания
|
||||||
|
// операции: GET /instanceOperations/default/{opId}.
|
||||||
|
//
|
||||||
|
// Зачем: idempotency pre-check обязан выполняться ДО POST /instanceOperations — иначе
|
||||||
|
// при пропуске останется созданная-но-незапущенная операция («черновик», pending),
|
||||||
|
// и следующий modify будет ждать idle до таймаута.
|
||||||
|
func (c *UniversalClient) fetchOperationSchemaByID(ctx context.Context, opId int) ([]universalCfsParam, error) {
|
||||||
|
defResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось получить схему операции %d: %w", opId, err)
|
||||||
|
}
|
||||||
|
var def universalOpDefaultResponse
|
||||||
|
if uerr := json.Unmarshal(defResp, &def); uerr != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось разобрать схему операции %d: %w", opId, uerr)
|
||||||
|
}
|
||||||
|
return def.SvcOperation.CfsParams, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
|
|||||||
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Схема операции нужна и для idempotency pre-check, и для маппинга кодов.
|
||||||
|
// Получаем её ДО создания операции (GET /instanceOperations/default/{opId}): иначе
|
||||||
|
// при пропуске останется созданная, но незапущенная операция («черновик», pending).
|
||||||
|
schemaParams, err := c.fetchOperationSchemaByID(ctx, opId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
|
||||||
|
// выходим, НЕ создавая операцию вовсе (источник — state.params, см. instanceLiveParams).
|
||||||
|
if idempotent {
|
||||||
|
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, schemaParams)
|
||||||
|
if liveErr == nil && equal {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Ошибка/несовпадение live — не пропускаем: выполняем modify.
|
||||||
|
}
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"instanceUid": instanceUid,
|
"instanceUid": instanceUid,
|
||||||
"svcOperationId": opId,
|
"svcOperationId": opId,
|
||||||
@@ -60,21 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run.
|
codeToParam := modifierCodeMap(cfsParams)
|
||||||
// desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
|
|
||||||
if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
codeToParam := make(map[string]universalCfsParam)
|
|
||||||
for _, p := range cfsParams {
|
|
||||||
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
|
|
||||||
codeToParam[key] = p
|
|
||||||
}
|
|
||||||
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
|
|
||||||
codeToParam[key] = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
paramsByID := map[int]string{}
|
paramsByID := map[int]string{}
|
||||||
for code, value := range params {
|
for code, value := range params {
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ import (
|
|||||||
// указанного сервиса.
|
// указанного сервиса.
|
||||||
//
|
//
|
||||||
// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре.
|
// ВАЖНО (uuid-case, 2026-03): API облака возвращает UUID всегда в нижнем регистре.
|
||||||
// Для отправки в API нормализуем к lowercase, но в state сохраняем регистр
|
// Для отправки в API нормализуем к lowercase. В state регистр пользователя сохраняется
|
||||||
// пользователя (см. шаблон instance.go, блоки "Restore user-provided casing").
|
// потому, что ref_svc-атрибуты ИСКЛЮЧЕНЫ из read-back (шаблон instance.go добавляет
|
||||||
|
// InputField только при `eq .RefSvcId 0`), а UUID-подстроки ВНУТРИ JSON нормализуются
|
||||||
|
// при отправке (`resources_core.BuildJSON` -> `jsonutil.LowercaseUUIDsInText`).
|
||||||
|
// (Блока "Restore user-provided casing" в instance.go нет — он описан только в доке
|
||||||
|
// docs/60_strategy/terraform_case_sensitivity_fix.md как исторический подход.)
|
||||||
|
|
||||||
func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) {
|
func (c *UniversalClient) resolveRefSvcParamValues(ctx context.Context, opParams []universalCfsParam, params map[int]string) (map[int]string, error) {
|
||||||
if len(params) == 0 || len(opParams) == 0 {
|
if len(params) == 0 || len(opParams) == 0 {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-nubes/internal/core/jsonutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FormatBool(v types.Bool) string {
|
func FormatBool(v types.Bool) string {
|
||||||
@@ -81,6 +83,12 @@ func CompactParams(params map[string]string) map[string]string {
|
|||||||
|
|
||||||
// BuildJSON строит JSON-строку из map[string]string значений.
|
// BuildJSON строит JSON-строку из map[string]string значений.
|
||||||
// Ключи не кавычатся если они уже в кавычках.
|
// Ключи не кавычатся если они уже в кавычках.
|
||||||
|
//
|
||||||
|
// FIX(uuid-case): после сборки приводим UUID-подстроки к lowercase. Облако хранит
|
||||||
|
// UUID в lowercase, но ресурсы могут отдавать id в UPPERCASE (например nsxtUid у
|
||||||
|
// кластера Штурвала), а платформа сравнивает регистр при create и падает с
|
||||||
|
// «Edge не развёрнут в указанном vDC». Без этой нормализации приходилось писать
|
||||||
|
// lower(...) в HCL у каждого такого поля.
|
||||||
func BuildJSON(parts map[string]string) string {
|
func BuildJSON(parts map[string]string) string {
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "{}"
|
return "{}"
|
||||||
@@ -89,5 +97,5 @@ func BuildJSON(parts map[string]string) string {
|
|||||||
for k, v := range parts {
|
for k, v := range parts {
|
||||||
pairs = append(pairs, fmt.Sprintf(`"%s":%s`, k, v))
|
pairs = append(pairs, fmt.Sprintf(`"%s":%s`, k, v))
|
||||||
}
|
}
|
||||||
return "{" + strings.Join(pairs, ",") + "}"
|
return jsonutil.LowercaseUUIDsInText("{" + strings.Join(pairs, ",") + "}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,24 @@ func (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportS
|
|||||||
uid := strings.TrimSpace(req.ID)
|
uid := strings.TrimSpace(req.ID)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...)
|
||||||
|
|
||||||
|
// Required-атрибут ip_space_name нельзя оставить null после импорта: пользователь
|
||||||
|
// обязан указать его в конфиге. Заполняем live-значением; если SNAT не выставлен —
|
||||||
|
// каноном «выключено» (no-needed).
|
||||||
|
var live map[string]string
|
||||||
|
if r.client != nil {
|
||||||
|
live, _ = r.client.GetInstanceStateParams(ctx, uid)
|
||||||
|
}
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("ip_space_name"), importIpSpaceName(live))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importIpSpaceName выбирает значение ip_space_name для состояния после импорта:
|
||||||
|
// live-значение, если есть; иначе канон «SNAT выключен» (no-needed).
|
||||||
|
func importIpSpaceName(live map[string]string) string {
|
||||||
|
if v := strings.TrimSpace(live["ipSpaceName"]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return noNeededIpSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
|
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
|
||||||
@@ -250,9 +268,10 @@ func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ip
|
|||||||
unlock := r.client.LockInstance(uid)
|
unlock := r.client.LockInstance(uid)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
|
// Idempotent: core.RunInstanceOperationUniversalByIdempotent выполняет pre-check
|
||||||
// а не с live-состоянием инстанса — можно ложно пропустить modify.
|
// по LIVE-значениям инстанса (state.params) и пропускает modify, если
|
||||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
// ipSpaceName уже совпадает — повторный apply не дёргает платформу зря.
|
||||||
|
return r.client.RunInstanceOperationUniversalByIdempotent(ctx, uid, "modify", map[string]string{
|
||||||
"ipSpaceName": value,
|
"ipSpaceName": value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ var _ resource.Resource = &OrgIpAllocationResource{}
|
|||||||
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
||||||
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
||||||
|
|
||||||
|
// ===== Именованные ссылки на сервисы платформы (реестр данных, без магических чисел) =====
|
||||||
|
// Проверяется стражем TOOLS/scripts/check_hardcoded_service_ids.sh.
|
||||||
|
const (
|
||||||
|
// svcIDVcOrg — сервис 19 «vc_org»: организация Cloud Director.
|
||||||
|
// Организация создаётся вручную в ЛК, ресурс адресует её по имени/UUID.
|
||||||
|
svcIDVcOrg = 19
|
||||||
|
)
|
||||||
|
|
||||||
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
||||||
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
||||||
//
|
//
|
||||||
@@ -291,6 +299,25 @@ func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.
|
|||||||
uid := strings.TrimSpace(req.ID)
|
uid := strings.TrimSpace(req.ID)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
|
||||||
|
|
||||||
|
// Required-атрибут vip_configure нельзя оставить null после импорта.
|
||||||
|
// Заполняем live-значением; если аллокации нет — пустым массивом.
|
||||||
|
var live map[string]string
|
||||||
|
if r.client != nil {
|
||||||
|
live, _ = r.client.GetInstanceStateParams(ctx, uid)
|
||||||
|
}
|
||||||
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("vip_configure"), importVipConfigure(live))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importVipConfigure выбирает значение vip_configure для состояния после импорта:
|
||||||
|
// канонический JSON из live (если есть непустые элементы), иначе пустой массив.
|
||||||
|
func importVipConfigure(live map[string]string) string {
|
||||||
|
if raw, ok := live["vIPConfigure"]; ok {
|
||||||
|
if items, err := parseVipConfigure(raw); err == nil && len(items) > 0 {
|
||||||
|
return formatVipConfigure(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
|
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
|
||||||
@@ -305,7 +332,7 @@ func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, or
|
|||||||
return "", fmt.Errorf("organization обязателен")
|
return "", fmt.Errorf("organization обязателен")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
|
resolved, err := r.client.ResolveRefSvcParamValue(ctx, svcIDVcOrg, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
|
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,48 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importVipConfigure/importIpSpaceName — выбор значения Required-атрибутов при импорте
|
||||||
|
// (B7: Required не должен оставаться null после import).
|
||||||
|
func TestImportVipConfigure(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
live map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"live-аллокация", map[string]string{"vIPConfigure": `[{"name":"internet-ipv4-v1","count":"3"}]`}, `[{"name":"internet-ipv4-v1","count":"3"}]`},
|
||||||
|
{"нет ключа", map[string]string{}, "[]"},
|
||||||
|
{"nil", nil, "[]"},
|
||||||
|
{"свежая орга [{}]", map[string]string{"vIPConfigure": `[{}]`}, "[]"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := importVipConfigure(tc.live); got != tc.want {
|
||||||
|
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportIpSpaceName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
live map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"live-значение", map[string]string{"ipSpaceName": "internet-ipv4-v1"}, "internet-ipv4-v1"},
|
||||||
|
{"нет ключа", map[string]string{}, "no-needed"},
|
||||||
|
{"nil", nil, "no-needed"},
|
||||||
|
{"только пробелы", map[string]string{"ipSpaceName": " "}, "no-needed"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := importIpSpaceName(tc.live); got != tc.want {
|
||||||
|
t.Fatalf("получено %q, ожидалось %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
|
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
|
||||||
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
|
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
|
||||||
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
|
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ set -euo pipefail
|
|||||||
#
|
#
|
||||||
# Usage examples:
|
# Usage examples:
|
||||||
# ./scripts/publish-doc-page.sh \
|
# ./scripts/publish-doc-page.sh \
|
||||||
# --profile devops/profiles/test \
|
# --profile TOOLS/config/test \
|
||||||
# --version 5.0.17 \
|
# --version 5.0.17 \
|
||||||
# --page 30_registry/guides/getting-started/index.html
|
# --page 30_registry/guides/getting-started/index.html
|
||||||
#
|
#
|
||||||
# ./scripts/publish-doc-page.sh \
|
# ./scripts/publish-doc-page.sh \
|
||||||
# --profile devops/profiles/prod \
|
# --profile TOOLS/config/prod \
|
||||||
# --version 2.1.26 \
|
# --version 2.1.26 \
|
||||||
# --page 30_registry/guides/getting-started/
|
# --page 30_registry/guides/getting-started/
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Usage:
|
|||||||
publish-doc-page.sh --profile <path> --version <ver> --page <relative-path> [--site-dir <dir>]
|
publish-doc-page.sh --profile <path> --version <ver> --page <relative-path> [--site-dir <dir>]
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
--profile Path to stand profile dir (e.g. devops/profiles/test)
|
--profile Path to stand profile dir (e.g. TOOLS/config/test)
|
||||||
--version Docs version (e.g. 5.0.17)
|
--version Docs version (e.g. 5.0.17)
|
||||||
--page Relative path inside built site dir, e.g.:
|
--page Relative path inside built site dir, e.g.:
|
||||||
30_registry/guides/getting-started/index.html
|
30_registry/guides/getting-started/index.html
|
||||||
|
|||||||
Reference in New Issue
Block a user