feat(vultr-vps): ai-workspace IaC env with YAML+Jinja2 render and Ansible linkage

- envs/ai-workspace: hosts.yaml -> generate.py renders explicit Terraform
  module/resource blocks via Jinja2 (no for_each/count); terraform runtime
  output merged with YAML -> cmdb.json + inventory.ini for Ansible.
- modules/compute: backups bool -> "enabled"/"disabled" (vultr provider
  2.19+); add required_providers to compute & iam modules.
- skills/terraform-yaml-render-pattern + terraform-hcl-standard/AGENTS.md:
  binding spec for the render pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Haitao Pan 2026-06-23 20:57:19 +08:00
parent 4755198a9d
commit de7fe511d7
15 changed files with 666 additions and 1 deletions

View File

@ -0,0 +1,75 @@
# Skill: terraform-yaml-render-pattern
## Purpose
约束性规范:在 `iac_modules/terraform-hcl-standard/**` 下编排可批量创建的资源
(典型如多主机 VPS**必须**采用「YAML 描述 → Python+Jinja2 渲染显式 HCL 块
→ Terraform apply → Python 合并生成 CMDB/Inventory」的范式**不得**在 HCL 内做循环。
这是 binding skill与本文件冲突的写法一律以本文件为准。
配套约束见 `iac_modules/terraform-hcl-standard/AGENTS.md`
参考实现(基准,新 env 照此结构落地):`terraform-hcl-standard/vultr-vps/envs/ai-workspace/`
## Pattern强制数据流
```
hosts.yaml (唯一人工入口:资源描述 / CMDB 源)
│ generate.py render —— 循环在 Python+Jinja2 侧完成
├─▶ generated_hosts.tf 每实例/每 key 一个独立显式 module/resource/data 块
└─▶ terraform.auto.tfvars.json ──▶ variables.tf (global 段 -> 变量)
▼ terraform apply
output "cmdb_runtime" (仅运行时事实ip / instance_id / 解析后的 os_id)
│ generate.py inventory —— 合并 YAML 静态字段 + 运行时输出
├─▶ cmdb.json (IaC ↔ Ansible 契约)
└─▶ inventory.ini
▼ Ansible 动态 inventory: playbooks/inventory/terraform_cmdb.py (只读 cmdb.json)
```
## Rules
### MUST NOT
- 不在 env 的 `.tf` 中使用 `for_each` / `count` / `dynamic`
- 不用 `templatefile()` + `%{ for }` / `%{ if }` 等 HCL 模板控制结构做渲染。
### MUST
- 资源信息由 env 内 `hosts.yaml` 描述;多份资源由 Jinja2 展开为**多个命名唯一的显式块**。
- YAML 全局段经 `terraform.auto.tfvars.json` 传给 `variables.tf`;逐实例字段由 Jinja2 进 `.tf`
- 机密走环境变量(如 `TF_VAR_vultr_api_key`**禁止**写入 YAML/tfvars公钥可入 YAML。
- 每个 env 提供 `generate.py`,至少含 `render``inventory` 两个子命令(职责见上图)。
- Terraform 只输出运行时才确定的事实静态字段os_name/plan/groups/host_vars…由 Python 合并。
- 渲染产物(`generated_hosts.tf`、`terraform.auto.tfvars.json`、`cmdb.json`、`inventory.ini`
加入 `.gitignore`,不入库。
- `inventory.ini` 中含空格的 host_var 值加引号(`key="a b c"`)。
- Ansible 动态 inventory 只消费 `cmdb.json`,不直接耦合 tfstateIaC 变更后重跑 `generate.py inventory`
### SHOULD
- 复用 `modules/compute` 等既有模块,不在 env 内重写 provider 资源。
- 每个用到 provider 的子模块声明 `required_providers`(含正确 `source`)。
- OS 用 `data "vultr_os"``os_name` 解析 `os_id`,避免硬编码漂移 ID解析不到时允许直接给 `os_id`
## Reference Layout新 env 必备文件)
```
envs/<name>/
hosts.yaml # 唯一人工入口global / ssh_keys / hosts
generate.py # render + inventory 两个子命令
templates/
hosts.tf.j2 # 渲染逐实例 module/data + 资源块
inventory.ini.j2 # 渲染静态 inventory
variables.tf # 全局变量声明(值来自 tfvars.json
provider.tf
provision.sh # 一键 render -> apply -> inventory -> (可选) ansible
.gitignore # 忽略渲染产物与 .terraform/tfstate
# 渲染产物不入库generated_hosts.tf / terraform.auto.tfvars.json / cmdb.json / inventory.ini
```
## Operator Checklist提交前自检
- `terraform fmt` 无 diff`terraform validate` 通过。
- `python3 generate.py render` 产出合法 `.tf``validate` 通过)。
- 生成的 `inventory.ini` 能被 `ansible-inventory -i <file> --graph` 正确解析。
- 渲染产物已被 `.gitignore` 忽略;机密未入库。
- HCL 内无 `for_each`/`count`/`dynamic`/`templatefile` 控制结构。

View File

@ -0,0 +1,87 @@
# AGENTS.md —— Terraform 模块约束规范(强制)
适用范围:`iac_modules/terraform-hcl-standard/**`。本文件为**约束性规范**
在本目录下创建/修改 Terraform env 时 **必须遵守**。冲突时以本文件为准。
## 0. 核心范式MUST
新建可批量编排的 env如多主机 VPS**必须**采用如下数据流,
**不得**在 HCL 内做循环:
```
YAML 描述资源
│ (Python + Jinja2 渲染,循环在此完成)
显式 Terraform module/resource 块 ── 每个实例/资源一个独立块,不用 for_each
terraform apply
Python 合并「YAML 静态信息」+「Terraform 运行时输出」
CMDB (cmdb.json) + Ansible inventory (inventory.ini / 动态 inventory)
```
**参考实现(基准,新 env 照此结构落地):**
`vultr-vps/envs/ai-workspace/`
**配套 binding skill完整规则与自检清单**
`../skills/terraform-yaml-render-pattern/SKILL.md`
## 1. HCL 控制结构禁令MUST NOT
- ❌ 禁止在 env 的 `.tf` 中使用 `for_each`、`count`、`dynamic` 块。
- ❌ 禁止用 `templatefile()` + `%{ for }`/`%{ if }` 等 HCL 模板控制结构做渲染。
- ✅ 允许纯函数:`concat`、`merge`、`coalesce`、`jsonencode` 等(非控制结构)。
- ✅ 资源的“多份”由 Python+Jinja2 在生成阶段展开为**多个显式块**
每台主机 / 每个 key 对应一个命名唯一的 `module`/`resource`/`data` 块。
> 理由:把循环与条件收敛到 Python/Jinja2 单一处HCL 保持“扁平、可审计、
> diff 友好”,资源命名稳定,避免 for_each key 漂移导致的重建。
## 2. 资源描述与变量传递MUST
- 资源信息**必须**由 env 目录下的 `hosts.yaml`(或等价 `*.yaml`)描述,作为唯一人工入口。
- YAML 的全局段经渲染写入 `terraform.auto.tfvars.json`**传给 `variables.tf`**
逐实例字段由 Jinja2 展开进生成的 `.tf`
- 机密API Key、私钥等**禁止**写入 YAML / tfvars**必须**走环境变量
(如 `TF_VAR_vultr_api_key`)。公钥可入 YAML。
## 3. 渲染器约定MUST
每个采用本范式的 env **必须**提供 `generate.py`,至少含两个子命令:
- `render``hosts.yaml` → `generated_hosts.tf` + `terraform.auto.tfvars.json`
- `inventory``terraform output`(运行时事实)+ `hosts.yaml`(静态字段)
`cmdb.json` + `inventory.ini`
约定:
- Jinja2 模板放 `templates/*.j2`;标识符经 `tf_id` 过滤器净化为合法 HCL 名。
- Terraform 仅输出 **运行时才确定** 的事实(如 `cmdb_runtime`ip / instance_id /
解析后的 os_id。静态字段os_name/plan/groups/host_vars 等)由 Python 从 YAML 合并。
- `inventory.ini` 中含空格的 host_var 值**必须**加引号(`key="a b c"`)。
- 渲染产物(`generated_hosts.tf`、`terraform.auto.tfvars.json`、`cmdb.json`、
`inventory.ini`)为派生物,**必须**加入 `.gitignore`,不入库。
## 4. 模块复用与 OS 解析SHOULD
- 实例**应**复用 `modules/compute` 等既有模块,不在 env 内重写 provider 资源。
- 每个使用 provider 的子模块**必须**声明 `required_providers`(含正确 `source`
否则 Terraform 误判为 `hashicorp/<name>`
- OS **应**用 `data "vultr_os"``os_name` 解析 `os_id`,避免硬编码漂移 ID
解析不到时允许在 YAML 直接给 `os_id`
## 5. IaC ↔ Ansible 联动MUST
- CMDB`cmdb.json`)是 IaC 与 Ansible 的契约。Ansible 侧的动态 inventory
`playbooks/inventory/terraform_cmdb.py`**只**消费 `cmdb.json`,不直接耦合 tfstate。
- IaC 变更后**必须**重跑 `generate.py inventory` 刷新 CMDB/inventory保持二者一致。
## 6. 提交前自检MUST
- `terraform fmt` 无 diff`terraform validate` 通过。
- `python3 generate.py render` 能产出合法 `.tf``validate` 通过)。
- 生成的 `inventory.ini` 能被 `ansible-inventory -i <file> --graph` 正确解析。
- 确认渲染产物已被 `.gitignore` 忽略、机密未入库。

View File

@ -0,0 +1,13 @@
# generate.py 渲染产物(由 hosts.yaml 派生,不入库)
generated_hosts.tf
terraform.auto.tfvars.json
cmdb.json
inventory.ini
# terraform 运行时
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
terraform.tfvars
__pycache__/

View File

@ -0,0 +1,70 @@
# ai-workspace env —— YAML 描述资源 + Python/Jinja2 渲染 + Ansible 动态 inventory
`vultr-vps` 模块基础上,做到 **IaC 创建主机 ↔ Ansible inventory** 的联动,且满足约束:
- **不使用 HCL 控制结构**(无 `for_each`/`count`/`dynamic`/`templatefile` 循环)。
- **用 Python + Jinja2 渲染 YAML** 生成显式的 Terraform `module`/`resource`/`data` 块。
- `hosts.yaml` 描述资源信息,全局段经 `terraform.auto.tfvars.json` 传给 `variables.tf`
默认创建两台机器(均 **4 核 8G / 公网 IP / 不开备份**`ai-debian13`(Debian 13)、`ai-ubuntu2604`(Ubuntu 26.04)。
## 数据流
```
hosts.yaml ──generate.py render──▶ generated_hosts.tf (逐主机显式 module 块,无 for_each)
│ └ terraform.auto.tfvars.json ──▶ variables.tf
└──────────────▶ terraform apply ──▶ output cmdb_runtime (ip/instance_id/os_id)
hosts.yaml(静态字段) + cmdb_runtime ──generate.py inventory──▶ cmdb.json + inventory.ini
playbooks/inventory/terraform_cmdb.py (Ansible 动态 inventory 读 cmdb.json)
```
> 循环逻辑全部在 Python/Jinja2 侧:每台主机 / 每个 SSH key 都被展开成
> `generated_hosts.tf` 里独立的显式块HCL 内不出现任何控制结构。
## 文件
| 文件 | 角色 |
|------|------|
| `hosts.yaml` | **唯一人工入口**global / ssh_keys / hosts 资源描述 (CMDB 源) |
| `generate.py` | `render`YAML→tf+tfvars`inventory`tf 输出+YAML→cmdb.json+inventory.ini |
| `templates/hosts.tf.j2` | 渲染逐主机 module/data 块与 `vultr_ssh_key` 资源 |
| `templates/inventory.ini.j2` | 渲染静态 inventory.ini |
| `variables.tf` | 全局变量声明(值来自 tfvars.json |
| `provider.tf` / `cloud-init.yaml` | Provider 与云初始化 |
| `provision.sh` | 一键render → apply → inventory →(可选)跑 Ansible |
| `../../../../../playbooks/inventory/terraform_cmdb.py` | Ansible 动态 inventory 脚本 |
> `generated_hosts.tf` / `terraform.auto.tfvars.json` / `cmdb.json` / `inventory.ini`
> 均为渲染产物,已在 `.gitignore` 中忽略。
## 用法
```bash
export TF_VAR_vultr_api_key=xxxxxxxx
# 编辑 hosts.yaml填入真实 ssh 公钥 / 调整主机),然后:
./provision.sh # 渲染 + 创建 + 生成 inventory
# 用动态 inventory 驱动 Ansible
cd ../../../../../playbooks
ansible ai_workspace -i inventory/terraform_cmdb.py -m ping
ansible-playbook -i inventory/terraform_cmdb.py setup-ai-workspace-all-in-one.yml
```
分步执行:
```bash
python3 generate.py render # YAML -> generated_hosts.tf + terraform.auto.tfvars.json
terraform init && terraform apply # 建机
python3 generate.py inventory # -> cmdb.json + inventory.ini
```
## 调整主机
`hosts.yaml` 即可增删机器或改套餐/区域/分组/host_vars重跑 `generate.py render`
`groups` 决定主机进入哪些 Ansible 组;`host_vars` 会进入 inventory 行与动态 inventory 的 `hostvars`
> OS 通过 `os_name` 自动解析 `os_id`(避免硬编码漂移的镜像 ID。名称查不到时用
> `vultr-cli os list` 核对,或在该主机直接写 `os_id`

View File

@ -0,0 +1,6 @@
#cloud-config
package_update: true
package_upgrade: true
runcmd:
- echo "Provisioned by Terraform ai-workspace env" > /etc/motd
- systemctl enable --now ssh || systemctl enable --now sshd || true

View File

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""hosts.yaml -> Terraform 资源 / Ansible inventory 渲染器。
设计要点满足约束
- 不在 HCL 里使用 for_each/count 等控制结构 Python + Jinja2 YAML
展开成 generated_hosts.tf 中逐个的显式 module/resource/data
- hosts.yaml global 段渲染成 terraform.auto.tfvars.json传给 variables.tf
- apply 后用 terraform 运行时输出 + YAML 静态字段合并出 cmdb.json
再渲染 inventory.ini二者供 Ansible含动态 inventory 脚本消费
子命令
render hosts.yaml -> generated_hosts.tf + terraform.auto.tfvars.json
inventory terraform output(cmdb_runtime) + hosts.yaml -> cmdb.json + inventory.ini
"""
import argparse
import json
import os
import re
import subprocess
import sys
import yaml
from jinja2 import Environment, FileSystemLoader
HERE = os.path.dirname(os.path.abspath(__file__))
HOSTS_YAML = os.path.join(HERE, "hosts.yaml")
TEMPLATE_DIR = os.path.join(HERE, "templates")
GENERATED_TF = os.path.join(HERE, "generated_hosts.tf")
TFVARS_JSON = os.path.join(HERE, "terraform.auto.tfvars.json")
CMDB_JSON = os.path.join(HERE, "cmdb.json")
INVENTORY_INI = os.path.join(HERE, "inventory.ini")
def _tf_id(value):
"""把任意名字转成合法的 Terraform 标识符。"""
return re.sub(r"[^0-9a-zA-Z_]", "_", str(value))
def _env():
env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
trim_blocks=True,
lstrip_blocks=False,
keep_trailing_newline=True,
)
env.filters["tf_id"] = _tf_id
return env
def load_yaml():
with open(HOSTS_YAML, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def cmd_render(_args):
data = load_yaml()
glob = data.get("global", {}) or {}
ssh_keys = data.get("ssh_keys", []) or []
hosts = data.get("hosts", []) or []
env = _env()
rendered = env.get_template("hosts.tf.j2").render(
ssh_keys=ssh_keys,
hosts=hosts,
true=True,
false=False,
)
with open(GENERATED_TF, "w", encoding="utf-8") as fh:
fh.write(rendered)
tfvars = {
"region": glob.get("region", "nrt"),
"name_prefix": glob.get("name_prefix", "ai-workspace"),
"user_data_file": glob.get("user_data_file", "cloud-init.yaml"),
}
with open(TFVARS_JSON, "w", encoding="utf-8") as fh:
json.dump(tfvars, fh, indent=2, ensure_ascii=False)
fh.write("\n")
print(f" wrote {os.path.relpath(GENERATED_TF, HERE)}")
print(f" wrote {os.path.relpath(TFVARS_JSON, HERE)}")
print(" next: terraform init && terraform apply")
def _terraform_output(name):
out = subprocess.check_output(
["terraform", f"-chdir={HERE}", "output", "-json", name],
stderr=subprocess.PIPE,
)
return json.loads(out)
def cmd_inventory(_args):
data = load_yaml()
glob = data.get("global", {}) or {}
hosts = data.get("hosts", []) or []
default_region = glob.get("region", "nrt")
try:
runtime = _terraform_output("cmdb_runtime")
except (OSError, subprocess.CalledProcessError) as exc:
msg = getattr(exc, "stderr", b"") or b""
sys.exit(
"无法读取 terraform 输出 cmdb_runtime请先在本目录 terraform apply\n"
+ msg.decode(errors="replace")
)
cmdb = {}
groups = {}
for host in hosts:
name = host["name"]
rt = runtime.get(name, {})
host_vars = dict(host.get("host_vars", {}) or {})
host_vars.setdefault("os_name", host.get("os_name", ""))
host_vars.setdefault("plan", host.get("plan", "vc2-4c-8gb"))
host_vars.setdefault("region", host.get("region") or default_region)
cmdb[name] = {
"name": name,
"ip": rt.get("ip"),
"instance_id": rt.get("instance_id"),
"os_id": rt.get("os_id"),
"os_name": host.get("os_name", ""),
"plan": host.get("plan", "vc2-4c-8gb"),
"region": host.get("region") or default_region,
"ansible_user": host.get("ansible_user", "root"),
"groups": host.get("groups", []) or [],
"tags": host.get("tags", []) or [],
"host_vars": host_vars,
}
for group in cmdb[name]["groups"] or ["ungrouped"]:
groups.setdefault(group, []).append(name)
with open(CMDB_JSON, "w", encoding="utf-8") as fh:
json.dump(cmdb, fh, indent=2, ensure_ascii=False)
fh.write("\n")
# 每台主机整行在 Python 侧拼好(含带引号的 host_vars模板里只做表达式
# 输出,避免 Jinja2 trim_blocks 把行尾 block 标签后的换行吃掉。
lines = {}
for name, host in cmdb.items():
parts = [
name,
f"ansible_host={host['ip']}",
f"ansible_user={host['ansible_user']}",
]
for k, v in host["host_vars"].items():
parts.append(f'{k}="{v}"')
lines[name] = " ".join(parts)
env = _env()
rendered = env.get_template("inventory.ini.j2").render(
cmdb=cmdb,
lines=lines,
groups={g: sorted(m) for g, m in sorted(groups.items())},
)
with open(INVENTORY_INI, "w", encoding="utf-8") as fh:
fh.write(rendered)
print(f" wrote {os.path.relpath(CMDB_JSON, HERE)}")
print(f" wrote {os.path.relpath(INVENTORY_INI, HERE)}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("render", help="YAML -> generated_hosts.tf + tfvars").set_defaults(
func=cmd_render
)
sub.add_parser(
"inventory", help="terraform output + YAML -> cmdb.json + inventory.ini"
).set_defaults(func=cmd_inventory)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,47 @@
# =============================================================================
# ai-workspace 资源描述 (CMDB 源数据)
#
# 这是唯一的人工维护入口。generate.py 读取本文件:
# - global 段 -> terraform.auto.tfvars.json (传给 variables.tf)
# - ssh_keys -> generated_hosts.tf 里的显式 vultr_ssh_key 资源块
# - hosts -> generated_hosts.tf 里逐主机的显式 module/data 块 (无 for_each)
#
# 改完 YAML 后执行: python3 generate.py render
# =============================================================================
global:
region: nrt # 默认区域:东京。可选 ewr/sgp/fra ...
name_prefix: ai-workspace
user_data_file: cloud-init.yaml
# 注入实例的登录公钥(公钥非敏感,可入库;私钥/API Key 不要写这里)
ssh_keys:
- name: ai-workspace-admin
public: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO37O6S1Kf0QRV3/hQ7OVGZetEEcP7AevwXrRi8HRpHg ai-workspace-vultr"
# 主机清单。map key 风格用 name 字段;每台机器渲染成一个独立 module 块。
hosts:
- name: ai-debian13
os_name: "Debian 13 x64 (trixie)" # Vultr 实际镜像名(含 trixie也可写 os_id: 2625
plan: vc2-4c-8gb # 4 核 8G
backups: false # 不开备份
enable_ipv6: true # 公网 IPv4 默认分配,这里附带开 IPv6
ansible_user: root
groups: [ai_workspace, debian]
tags: [debian13]
host_vars:
role: primary
# 逗号分隔的服务域名cloudflare_dns 角色据此为本机 IP 建 A 记录
service_domains: ai-debian13.svc.plus
- name: ai-ubuntu2604
os_name: "Ubuntu 26.04 LTS x64"
plan: vc2-4c-8gb
backups: false
enable_ipv6: true
ansible_user: root
groups: [ai_workspace, ubuntu]
tags: [ubuntu2604]
host_vars:
role: secondary
service_domains: ai-ubuntu2604.svc.plus

View File

@ -0,0 +1,15 @@
terraform {
required_version = ">= 1.5"
required_providers {
vultr = {
source = "vultr/vultr"
version = "~> 2.19"
}
}
}
provider "vultr" {
api_key = var.vultr_api_key
rate_limit = 700
}

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 一键联动YAML -> 渲染 TF -> apply -> 生成 CMDB/inventory ->(可选)跑 Ansible
#
# 用法:
# export TF_VAR_vultr_api_key=xxxx
# ./provision.sh # 渲染+创建+生成 inventory
# ./provision.sh ping # 之后对 ai_workspace 组跑 ping
# ./provision.sh playbook setup-ai-workspace-all-in-one.yml
set -euo pipefail
ENV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${ENV_DIR}/../../../../.." && pwd)"
PLAYBOOKS_DIR="${REPO_ROOT}/playbooks"
DYN_INV="${PLAYBOOKS_DIR}/inventory/terraform_cmdb.py"
echo "==> [1/4] generate.py render (hosts.yaml -> generated_hosts.tf + tfvars)"
python3 "${ENV_DIR}/generate.py" render
echo "==> [2/4] terraform init & apply"
terraform -chdir="${ENV_DIR}" init -input=false >/dev/null
terraform -chdir="${ENV_DIR}" apply -auto-approve -input=false
echo "==> [3/4] generate.py inventory (terraform output + YAML -> cmdb.json + inventory.ini)"
python3 "${ENV_DIR}/generate.py" inventory
echo "==> [4/4] 完成。生成文件:"
echo " ${ENV_DIR}/cmdb.json"
echo " ${ENV_DIR}/inventory.ini"
action="${1:-none}"
case "${action}" in
none)
echo "可手动执行: ansible ai_workspace -i ${DYN_INV} -m ping"
;;
ping)
( cd "${PLAYBOOKS_DIR}" && ansible ai_workspace -i "${DYN_INV}" -m ping )
;;
playbook)
pb="${2:?用法: provision.sh playbook <playbook.yml>}"
( cd "${PLAYBOOKS_DIR}" && ansible-playbook -i "${DYN_INV}" "${pb}" )
;;
*)
echo "未知动作: ${action} (支持: none|ping|playbook)" >&2
exit 1
;;
esac

View File

@ -0,0 +1,69 @@
# =============================================================================
# 由 generate.py 从 hosts.yaml 渲染生成 —— 请勿手工编辑
# 重新生成: python3 generate.py render
#
# 约束: 不使用 for_each/count/dynamic 等 HCL 控制结构。
# 每台主机、每个 SSH key 都渲染成独立的显式 module/resource/data 块。
# (循环在 Python+Jinja2 侧完成,而非 HCL 侧)
# =============================================================================
{% for key in ssh_keys -%}
resource "vultr_ssh_key" "{{ key.name | tf_id }}" {
name = "{{ key.name }}"
ssh_key = "{{ key.public }}"
}
{% endfor -%}
{% for host in hosts -%}
{%- set hid = host.name | tf_id -%}
{%- if host.os_id is not defined or host.os_id is none %}
data "vultr_os" "{{ hid }}" {
filter {
name = "name"
values = ["{{ host.os_name }}"]
}
}
{% endif %}
module "compute_{{ hid }}" {
source = "../../modules/compute"
label = "${var.name_prefix}-{{ host.name }}"
region = {% if host.region %}"{{ host.region }}"{% else %}var.region{% endif %}
plan = "{{ host.plan | default('vc2-4c-8gb') }}"
os_id = {% if host.os_id %}{{ host.os_id }}{% else %}data.vultr_os.{{ hid }}.id{% endif %}
enable_ipv6 = {{ 'true' if host.get('enable_ipv6', true) else 'false' }}
backups = {{ 'true' if host.get('backups', false) else 'false' }}
tags = concat([var.name_prefix], {{ host.get('tags', []) | tojson }})
ssh_key_ids = [{% for k in ssh_keys %}vultr_ssh_key.{{ k.name | tf_id }}.id{{ ", " if not loop.last }}{% endfor %}]
user_data = file(var.user_data_file)
}
{% endfor -%}
# 运行时事实:仅暴露 apply 后才确定的动态值。静态字段(os_name/plan/groups/
# host_vars 等)由 generate.py 从 hosts.yaml 合并,最终一起写入 cmdb.json。
output "cmdb_runtime" {
description = "name -> {ip, instance_id, os_id}"
value = {
{% for host in hosts -%}
{%- set hid = host.name | tf_id %}
"{{ host.name }}" = {
ip = module.compute_{{ hid }}.main_ip
instance_id = module.compute_{{ hid }}.instance_id
os_id = {% if host.os_id %}{{ host.os_id }}{% else %}data.vultr_os.{{ hid }}.id{% endif %}
}
{% endfor -%}
}
}
output "hosts_summary" {
description = "name -> public_ip"
value = {
{% for host in hosts -%}
{%- set hid = host.name | tf_id %}
"{{ host.name }}" = module.compute_{{ hid }}.main_ip
{% endfor -%}
}
}

View File

@ -0,0 +1,13 @@
# =============================================================================
# 由 generate.py 从 cmdb.json 渲染生成 —— 请勿手工编辑
# 重新生成: python3 generate.py inventory
# =============================================================================
{% for group, members in groups.items() %}
[{{ group }}]
{% for name in members %}
{{ lines[name] }}
{% endfor %}
{% endfor %}
[all:vars]
ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'

View File

@ -0,0 +1,28 @@
# generate.py hosts.yaml global
# terraform.auto.tfvars.json YAML -> variables.tf
# generate.py Jinja2 generated_hosts.tf
# module/resource 使 for_each/count HCL
variable "vultr_api_key" {
description = "Vultr API Key请通过环境变量 TF_VAR_vultr_api_key 提供(勿写入 YAML/tfvars"
type = string
sensitive = true
}
variable "region" {
description = "默认部署区域,主机未单独指定 region 时使用"
type = string
default = "nrt"
}
variable "name_prefix" {
description = "实例 label 前缀"
type = string
default = "ai-workspace"
}
variable "user_data_file" {
description = "cloud-init 脚本路径"
type = string
default = "cloud-init.yaml"
}

View File

@ -60,7 +60,7 @@ resource "vultr_instance" "this" {
plan = var.plan plan = var.plan
os_id = var.os_id os_id = var.os_id
enable_ipv6 = var.enable_ipv6 enable_ipv6 = var.enable_ipv6
backups = var.backups backups = var.backups ? "enabled" : "disabled"
tags = var.tags tags = var.tags
vpc_ids = var.vpc_id == null ? [] : [var.vpc_id] vpc_ids = var.vpc_id == null ? [] : [var.vpc_id]
ssh_key_ids = var.ssh_key_ids ssh_key_ids = var.ssh_key_ids

View File

@ -0,0 +1,8 @@
terraform {
required_providers {
vultr = {
source = "vultr/vultr"
version = "~> 2.19"
}
}
}

View File

@ -0,0 +1,8 @@
terraform {
required_providers {
vultr = {
source = "vultr/vultr"
version = "~> 2.19"
}
}
}