From c71a76c0d4d1a26cb6a4bf1eef03d6d29005f677 Mon Sep 17 00:00:00 2001 From: shenlan Date: Mon, 29 Sep 2025 18:50:37 +0800 Subject: [PATCH] feat: add Vultr landing zone baseline --- ...c-pipeline-vultr-landingzone-baseline.yaml | 80 +++++++++++++ config/vultr/base.yaml | 5 + config/vultr/compute.yaml | 16 +++ config/vultr/network.yaml | 7 ++ config/vultr/security.yaml | 16 +++ iac_modules/pulumi/vultr/Pulumi.yaml | 3 + iac_modules/pulumi/vultr/__init__.py | 1 + iac_modules/pulumi/vultr/__main__.py | 53 +++++++++ iac_modules/pulumi/vultr/modules/__init__.py | 11 ++ .../pulumi/vultr/modules/compute/__init__.py | 0 .../pulumi/vultr/modules/compute/instances.py | 105 ++++++++++++++++++ .../pulumi/vultr/modules/network/__init__.py | 0 .../pulumi/vultr/modules/network/vpc.py | 51 +++++++++ .../pulumi/vultr/modules/security/__init__.py | 0 .../pulumi/vultr/modules/security/firewall.py | 82 ++++++++++++++ requirements.txt | 1 + 16 files changed, 431 insertions(+) create mode 100644 .github/workflows/iac-pipeline-vultr-landingzone-baseline.yaml create mode 100644 config/vultr/base.yaml create mode 100644 config/vultr/compute.yaml create mode 100644 config/vultr/network.yaml create mode 100644 config/vultr/security.yaml create mode 100644 iac_modules/pulumi/vultr/Pulumi.yaml create mode 100644 iac_modules/pulumi/vultr/__init__.py create mode 100644 iac_modules/pulumi/vultr/__main__.py create mode 100644 iac_modules/pulumi/vultr/modules/__init__.py create mode 100644 iac_modules/pulumi/vultr/modules/compute/__init__.py create mode 100644 iac_modules/pulumi/vultr/modules/compute/instances.py create mode 100644 iac_modules/pulumi/vultr/modules/network/__init__.py create mode 100644 iac_modules/pulumi/vultr/modules/network/vpc.py create mode 100644 iac_modules/pulumi/vultr/modules/security/__init__.py create mode 100644 iac_modules/pulumi/vultr/modules/security/firewall.py diff --git a/.github/workflows/iac-pipeline-vultr-landingzone-baseline.yaml b/.github/workflows/iac-pipeline-vultr-landingzone-baseline.yaml new file mode 100644 index 00000000..4c62c672 --- /dev/null +++ b/.github/workflows/iac-pipeline-vultr-landingzone-baseline.yaml @@ -0,0 +1,80 @@ +name: Vultr Landing Zone Baseline + +on: + push: + paths: + - 'iac_modules/pulumi/vultr/**' + - 'config/vultr/**' + - '.github/workflows/iac-pipeline-vultr-landingzone-baseline.yaml' + pull_request: + branches: [main] + workflow_dispatch: + inputs: + deploy_action: + description: "Deployment action to execute" + type: choice + options: + - init + - upgrade + - backup + - restore + - destroy + default: upgrade + deploy_dry_run: + description: "Run deployment steps in dry-run mode" + type: choice + options: + - 'true' + - 'false' + default: 'true' + +env: + PULUMI_CI: 'true' + CONFIG_PATH: config/vultr + +jobs: + preview: + name: Preview baseline changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Pulumi preview + uses: pulumi/actions@v4 + with: + command: preview + stack-name: vultr/baseline-dev + work-dir: iac_modules/pulumi/vultr + env: + VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }} + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} + + apply: + name: Apply to production stack + needs: preview + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Pulumi up + uses: pulumi/actions@v4 + with: + command: up + stack-name: vultr/baseline-prod + work-dir: iac_modules/pulumi/vultr + env: + VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }} + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} diff --git a/config/vultr/base.yaml b/config/vultr/base.yaml new file mode 100644 index 00000000..c29307c2 --- /dev/null +++ b/config/vultr/base.yaml @@ -0,0 +1,5 @@ +vultr: + region: ewr + default_tags: + environment: baseline + project: modern-container-app diff --git a/config/vultr/compute.yaml b/config/vultr/compute.yaml new file mode 100644 index 00000000..eed1a7cd --- /dev/null +++ b/config/vultr/compute.yaml @@ -0,0 +1,16 @@ +compute: + instances: + - name: baseline-bastion + plan: vc2-1c-1gb + region: ewr + os_id: 1743 + hostname: baseline-bastion + label: baseline-bastion + enable_ipv6: false + backups: disabled + firewall_group: baseline-fw + vpcs: + - baseline-vpc + tags: + - bastion + - baseline diff --git a/config/vultr/network.yaml b/config/vultr/network.yaml new file mode 100644 index 00000000..c1415d3f --- /dev/null +++ b/config/vultr/network.yaml @@ -0,0 +1,7 @@ +network: + vpcs: + - name: baseline-vpc + description: Baseline landing zone VPC + region: ewr + v4_subnet: 10.50.0.0 + v4_subnet_mask: 16 diff --git a/config/vultr/security.yaml b/config/vultr/security.yaml new file mode 100644 index 00000000..3627c0d9 --- /dev/null +++ b/config/vultr/security.yaml @@ -0,0 +1,16 @@ +security: + firewall_groups: + - name: baseline-fw + description: Baseline perimeter firewall rules + rules: + - name: allow-ssh + protocol: tcp + ip_type: v4 + cidr: 0.0.0.0/0 + port: "22" + notes: Allow SSH for operations + - name: allow-icmp + protocol: icmp + ip_type: v4 + cidr: 0.0.0.0/0 + notes: Allow ICMP diagnostics diff --git a/iac_modules/pulumi/vultr/Pulumi.yaml b/iac_modules/pulumi/vultr/Pulumi.yaml new file mode 100644 index 00000000..08685e6c --- /dev/null +++ b/iac_modules/pulumi/vultr/Pulumi.yaml @@ -0,0 +1,3 @@ +name: vultr-landingzone-baseline +runtime: python +description: Pulumi baseline project for provisioning Vultr landing zone resources diff --git a/iac_modules/pulumi/vultr/__init__.py b/iac_modules/pulumi/vultr/__init__.py new file mode 100644 index 00000000..7aa76b61 --- /dev/null +++ b/iac_modules/pulumi/vultr/__init__.py @@ -0,0 +1 @@ +"""Vultr landing zone baseline Pulumi modules.""" diff --git a/iac_modules/pulumi/vultr/__main__.py b/iac_modules/pulumi/vultr/__main__.py new file mode 100644 index 00000000..0b5b4cd5 --- /dev/null +++ b/iac_modules/pulumi/vultr/__main__.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any, Dict + +import pulumi +import ediri_vultr as vultr + +# 允许导入共享的工具模块 +PROJECT_DIR = Path(__file__).resolve().parent +PULUMI_ROOT = PROJECT_DIR.parent +REPO_ROOT = PULUMI_ROOT.parent.parent +sys.path.append(str(PULUMI_ROOT)) + +from utils.config_loader import load_merged_config # noqa: E402 +from vultr.modules import create_firewall_groups, create_instances, create_vpcs # noqa: E402 + + +def main() -> None: + default_config_dir = REPO_ROOT / "config" / "vultr" + config_dir = os.environ.get("CONFIG_PATH", str(default_config_dir)) + + config = load_merged_config(config_dir) + + vultr_conf: Dict[str, Any] = config.get("vultr", {}) # type: ignore[assignment] + region = vultr_conf.get("region") + default_tags = vultr_conf.get("default_tags", {}) + + if region: + vultr.config.region = region + pulumi.export("region", region) + + pulumi.log.info("Loaded Vultr configuration") + + network_results = create_vpcs(config.get("network", {}), region) + firewall_results = create_firewall_groups(config.get("security", {})) + instance_results = create_instances( + config.get("compute", {}), + region, + default_tags, + firewall_results, + network_results, + ) + + pulumi.export("vpc_count", len(network_results)) + pulumi.export("firewall_group_count", len(firewall_results)) + pulumi.export("instance_count", len(instance_results)) + + +if __name__ == "__main__": + main() diff --git a/iac_modules/pulumi/vultr/modules/__init__.py b/iac_modules/pulumi/vultr/modules/__init__.py new file mode 100644 index 00000000..63aa6073 --- /dev/null +++ b/iac_modules/pulumi/vultr/modules/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .network.vpc import create_vpcs +from .security.firewall import create_firewall_groups +from .compute.instances import create_instances + +__all__ = [ + "create_vpcs", + "create_firewall_groups", + "create_instances", +] diff --git a/iac_modules/pulumi/vultr/modules/compute/__init__.py b/iac_modules/pulumi/vultr/modules/compute/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/vultr/modules/compute/instances.py b/iac_modules/pulumi/vultr/modules/compute/instances.py new file mode 100644 index 00000000..a5a54166 --- /dev/null +++ b/iac_modules/pulumi/vultr/modules/compute/instances.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping + +import pulumi +import ediri_vultr as vultr + + +def _merge_tags(default_tags: Mapping[str, str] | None, instance_tags: Any) -> list[str] | None: + tags = [] + if default_tags: + tags.extend(f"{key}:{value}" for key, value in default_tags.items()) + if isinstance(instance_tags, list): + tags.extend(str(tag) for tag in instance_tags) + elif isinstance(instance_tags, Mapping): + tags.extend(f"{key}:{value}" for key, value in instance_tags.items()) + return tags or None + + +def create_instances( + compute_config: Mapping[str, Any], + default_region: str | None, + default_tags: Mapping[str, str] | None, + firewall_groups: Mapping[str, Dict[str, object]], + vpcs: Mapping[str, vultr.Vpc], +) -> Dict[str, vultr.Instance]: + """Provision baseline compute instances.""" + results: Dict[str, vultr.Instance] = {} + + instances = compute_config.get("instances", []) + if not isinstance(instances, list): + pulumi.log.warn("compute.instances 配置不是列表,将跳过实例创建") + return results + + for index, inst_conf in enumerate(instances): + if not isinstance(inst_conf, Mapping): + pulumi.log.warn(f"忽略索引 {index} 的实例配置,因其不是字典结构") + continue + + name = inst_conf.get("name") or f"instance-{index}" + region = inst_conf.get("region", default_region) + plan = inst_conf.get("plan") + if not (region and plan): + raise ValueError(f"实例 {name} 缺少必要的 region 或 plan 参数") + + resource_name = inst_conf.get("resource_name", name.replace("_", "-")) + + instance_args: Dict[str, Any] = { + "region": region, + "plan": plan, + } + + optional_fields = [ + "os_id", + "image_id", + "hostname", + "label", + "enable_ipv6", + "backups", + "user_data", + "activation_email", + "ddos_protection", + "disable_public_ipv4", + "reserved_ip_id", + "script_id", + "snapshot_id", + "user_scheme", + ] + for field in optional_fields: + if field in inst_conf: + instance_args[field] = inst_conf[field] + + if ssh_keys := inst_conf.get("ssh_key_ids"): + instance_args["ssh_key_ids"] = ssh_keys + + tags = _merge_tags(default_tags, inst_conf.get("tags")) + if tags: + instance_args["tags"] = tags + + firewall_group_name = inst_conf.get("firewall_group") + if firewall_group_name: + group = firewall_groups.get(firewall_group_name) + if not group: + raise KeyError( + f"实例 {name} 引用了未定义的防火墙组 '{firewall_group_name}'" + ) + instance_args["firewall_group_id"] = group["group"].id + + vpc_names = inst_conf.get("vpcs", []) + if vpc_names: + resolved_vpc_ids = [] + for vpc_name in vpc_names: + vpc_resource = vpcs.get(vpc_name) + if not vpc_resource: + raise KeyError( + f"实例 {name} 引用了未定义的 VPC '{vpc_name}'" + ) + resolved_vpc_ids.append(vpc_resource.id) + instance_args["vpc_ids"] = resolved_vpc_ids + + instance = vultr.Instance(resource_name, **instance_args) + results[name] = instance + pulumi.export(f"instance::{name}::id", instance.id) + + return results diff --git a/iac_modules/pulumi/vultr/modules/network/__init__.py b/iac_modules/pulumi/vultr/modules/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/vultr/modules/network/vpc.py b/iac_modules/pulumi/vultr/modules/network/vpc.py new file mode 100644 index 00000000..3f6cf443 --- /dev/null +++ b/iac_modules/pulumi/vultr/modules/network/vpc.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping + +import pulumi +import ediri_vultr as vultr + + +def create_vpcs( + network_config: Mapping[str, Any], + default_region: str | None, +) -> Dict[str, vultr.Vpc]: + """Create Vultr VPC networks from configuration.""" + vpc_results: Dict[str, vultr.Vpc] = {} + + vpcs = network_config.get("vpcs", []) + if not isinstance(vpcs, list): + pulumi.log.warn("network.vpcs 配置不是列表,将跳过 VPC 创建") + return vpc_results + + for index, vpc_conf in enumerate(vpcs): + if not isinstance(vpc_conf, Mapping): + pulumi.log.warn(f"忽略索引 {index} 的 VPC 配置,因其不是字典结构") + continue + + name = vpc_conf.get("name") or f"vpc-{index}" + region = vpc_conf.get("region", default_region) + if not region: + raise ValueError(f"VPC '{name}' 缺少 region 参数且未设置默认 region") + + v4_subnet = vpc_conf.get("v4_subnet") + v4_subnet_mask = vpc_conf.get("v4_subnet_mask") + if not (v4_subnet and v4_subnet_mask is not None): + raise ValueError( + f"VPC '{name}' 需要提供 v4_subnet 与 v4_subnet_mask 用于定义网络范围" + ) + + description = vpc_conf.get("description", f"VPC network for {name}") + resource_name = vpc_conf.get("resource_name", name.replace("_", "-")) + + vpc = vultr.Vpc( + resource_name, + region=region, + description=description, + v4_subnet=v4_subnet, + v4_subnet_mask=int(v4_subnet_mask), + ) + vpc_results[name] = vpc + pulumi.export(f"vpc::{name}::id", vpc.id) + + return vpc_results diff --git a/iac_modules/pulumi/vultr/modules/security/__init__.py b/iac_modules/pulumi/vultr/modules/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/vultr/modules/security/firewall.py b/iac_modules/pulumi/vultr/modules/security/firewall.py new file mode 100644 index 00000000..dbd7d48c --- /dev/null +++ b/iac_modules/pulumi/vultr/modules/security/firewall.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import ipaddress +from typing import Any, Dict, List, Mapping + +import pulumi +import ediri_vultr as vultr + + +def create_firewall_groups( + security_config: Mapping[str, Any], +) -> Dict[str, Dict[str, object]]: + """Create Vultr firewall groups and rules.""" + results: Dict[str, Dict[str, object]] = {} + + groups = security_config.get("firewall_groups", []) + if not isinstance(groups, list): + pulumi.log.warn("security.firewall_groups 配置不是列表,将跳过防火墙创建") + return results + + for index, group_conf in enumerate(groups): + if not isinstance(group_conf, Mapping): + pulumi.log.warn(f"忽略索引 {index} 的防火墙配置,因其不是字典结构") + continue + + name = group_conf.get("name") or f"firewall-{index}" + description = group_conf.get("description", f"Baseline firewall group {name}") + resource_name = group_conf.get("resource_name", name.replace("_", "-")) + + firewall_group = vultr.FirewallGroup(resource_name, description=description) + + rules_conf = group_conf.get("rules", []) + if not isinstance(rules_conf, list): + pulumi.log.warn(f"防火墙 {name} 的 rules 配置不是列表,将跳过规则创建") + rules_conf = [] + + rules: List[vultr.FirewallRule] = [] + for rule_index, rule_conf in enumerate(rules_conf): + if not isinstance(rule_conf, Mapping): + pulumi.log.warn( + f"忽略防火墙 {name} 中索引 {rule_index} 的规则配置,因其不是字典结构" + ) + continue + + cidr = rule_conf.get("cidr") + ip_type = rule_conf.get("ip_type", "v4") + if not cidr: + pulumi.log.warn( + f"防火墙 {name} 规则 {rule_conf.get('name', rule_index)} 缺少 cidr,将跳过" + ) + continue + + network = ipaddress.ip_network(cidr, strict=False) + if (ip_type == "v6" and network.version != 6) or ( + ip_type == "v4" and network.version != 4 + ): + pulumi.log.warn( + f"防火墙 {name} 规则 {rule_conf.get('name', rule_index)} 的 cidr 与 ip_type 不匹配" + ) + continue + + rule_name = rule_conf.get("name") or f"rule-{rule_index}" + rule_resource_name = f"{resource_name}-{rule_name}".replace("_", "-") + + rule = vultr.FirewallRule( + rule_resource_name, + firewall_group_id=firewall_group.id, + protocol=rule_conf.get("protocol", "tcp"), + ip_type=ip_type, + subnet=str(network.network_address), + subnet_size=network.prefixlen, + port=rule_conf.get("port"), + notes=rule_conf.get("notes"), + source=rule_conf.get("source"), + ) + rules.append(rule) + + results[name] = {"group": firewall_group, "rules": rules} + pulumi.export(f"firewall::{name}::id", firewall_group.id) + pulumi.export(f"firewall::{name}::rule_count", len(rules)) + + return results diff --git a/requirements.txt b/requirements.txt index 58fc5d7e..eb2f817d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ pulumi-aws pulumi-gcp pulumi-azure-native pulumi-alicloud +ediri_vultr PyYAML jinja2