diff --git a/.github/workflows/iac-pipeline-alicloud-landingzone-baseline.yaml b/.github/workflows/iac-pipeline-alicloud-landingzone-baseline.yaml new file mode 100644 index 00000000..b0d9099d --- /dev/null +++ b/.github/workflows/iac-pipeline-alicloud-landingzone-baseline.yaml @@ -0,0 +1,62 @@ +name: Alicloud Landing Zone Baseline + +on: + push: + paths: + - 'iac_modules/pulumi/**' + - 'config/alicloud/**' + - '.github/workflows/iac-pipeline-alicloud-landingzone-baseline.yaml' + workflow_dispatch: + +env: + PULUMI_CI: 'true' + CONFIG_PATH: config/alicloud + +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: alicloud/baseline-dev + work-dir: iac_modules/pulumi + env: + ALICLOUD_ACCESS_KEY_ID: ${{ secrets.ALICLOUD_ACCESS_KEY_ID }} + ALICLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUD_ACCESS_KEY_SECRET }} + 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: alicloud/baseline-prod + work-dir: iac_modules/pulumi + env: + ALICLOUD_ACCESS_KEY_ID: ${{ secrets.ALICLOUD_ACCESS_KEY_ID }} + ALICLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUD_ACCESS_KEY_SECRET }} + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} diff --git a/config/alicloud/audit.yaml b/config/alicloud/audit.yaml new file mode 100644 index 00000000..b4afb693 --- /dev/null +++ b/config/alicloud/audit.yaml @@ -0,0 +1,8 @@ +audit: + actiontrail: + enabled: true + name: lz-mvp-actiontrail + oss_bucket_ref: lz-mvp-actiontrail-logs + oss_key_prefix: actiontrail + trail_region: cn-hangzhou + event_rw: All diff --git a/config/alicloud/base.yaml b/config/alicloud/base.yaml new file mode 100644 index 00000000..3bb66891 --- /dev/null +++ b/config/alicloud/base.yaml @@ -0,0 +1,5 @@ +alicloud: + region: cn-hangzhou + default_tags: + project: landingzone-mvp + owner: your-github-handle diff --git a/config/alicloud/config-service.yaml b/config/alicloud/config-service.yaml new file mode 100644 index 00000000..c1239e98 --- /dev/null +++ b/config/alicloud/config-service.yaml @@ -0,0 +1,27 @@ +config_service: + recorder: + name: lz-config-recorder + resource_types: + - ACS::ECS::Instance + - ACS::OSS::Bucket + - ACS::VPC::VSwitch + delivery_channel: + name: lz-config-delivery + display_name: LandingZoneBaseline + type: OSS + target_arn: acs:oss:cn-hangzhou:${AliUid}:lz-mvp-actiontrail-logs + assume_role_arn: acs:ram::${AliUid}:role/aliyunconfigdefaultrole + description: Deliver baseline compliance evaluations to OSS + status: 1 + rules: + - name: lz-required-env-tag + description: Ensure env tag exists on core resources + source_identifier: ecs-instance-required-tag + source_owner: ALIYUN + risk_level: 2 + trigger_types: ConfigurationItemChangeNotification + resource_types_scopes: + - ACS::ECS::Instance + input_parameters: + tagKey: env + maximum_execution_frequency: TwentyFour_Hours diff --git a/config/alicloud/identity.yaml b/config/alicloud/identity.yaml new file mode 100644 index 00000000..5cfb2ff0 --- /dev/null +++ b/config/alicloud/identity.yaml @@ -0,0 +1,28 @@ +identity: + users: + - name: ops-automation + display_name: Landing Zone Automation + comments: Dedicated RAM user for IaC pipelines + policies: + - name: AliyunOSSFullAccess + type: System + - name: AliyunVPCFullAccess + type: System + - name: AliyunConfigFullAccess + type: System + - name: audit-viewer + display_name: Landing Zone Auditor + comments: Read-only access for monitoring + policies: + - name: ReadOnlyAccess + type: System + groups: + - name: ops-admins + comments: Baseline operations team + policies: + - name: AliyunConfigFullAccess + type: System + - name: AliyunVPCFullAccess + type: System + users: + - ops-automation diff --git a/config/alicloud/network.yaml b/config/alicloud/network.yaml new file mode 100644 index 00000000..0ede31e4 --- /dev/null +++ b/config/alicloud/network.yaml @@ -0,0 +1,18 @@ +network: + vpcs: + - name: lz-main-vpc + cidr_block: 10.10.0.0/16 + description: Landing zone baseline VPC + tags: + env: shared + vswitches: + - name: lz-prod-subnet + cidr_block: 10.10.1.0/24 + zone_id: cn-hangzhou-h + tags: + env: prod + - name: lz-test-subnet + cidr_block: 10.10.2.0/24 + zone_id: cn-hangzhou-h + tags: + env: test diff --git a/config/alicloud/security.yaml b/config/alicloud/security.yaml new file mode 100644 index 00000000..d05f5e0d --- /dev/null +++ b/config/alicloud/security.yaml @@ -0,0 +1,18 @@ +security: + groups: + - name: lz-base-sg + vpc: lz-main-vpc + description: Baseline security group allowing outbound traffic only + tags: + env: shared + ingress: + - protocol: tcp + port_range: "22/22" + cidr_ip: 0.0.0.0/0 + description: Temporary SSH access for break-glass + policy: accept + egress: + - protocol: all + port_range: "-1/-1" + cidr_ip: 0.0.0.0/0 + policy: accept diff --git a/config/alicloud/storage.yaml b/config/alicloud/storage.yaml new file mode 100644 index 00000000..f0b651de --- /dev/null +++ b/config/alicloud/storage.yaml @@ -0,0 +1,17 @@ +storage: + oss_buckets: + - name: lz-mvp-actiontrail-logs + bucket: lz-mvp-actiontrail-logs + storage_class: Standard + versioning: + status: Enabled + lifecycle_rules: + - id: archive-audit-logs + enabled: true + transitions: + - storage_class: IA + days: 180 + - storage_class: Archive + days: 365 + tags: + env: prod diff --git a/iac_modules/pulumi/README.md b/iac_modules/pulumi/README.md index 8996c315..0ff67a2f 100644 --- a/iac_modules/pulumi/README.md +++ b/iac_modules/pulumi/README.md @@ -1,42 +1,55 @@ -# ☁️ Pulumi 多模块 AWS IaaS 模板 +# ☁️ Pulumi Alicloud Landing Zone Baseline -该目录基于 Pulumi 构建,支持以下模块: +该目录提供了与《docs/landingzone/alicloud-landingzone-mvp-single-account.md》一致的 Pulumi Python 实现,用于在单账号阿里云环境中快速落地身份、审计、配置合规、网络与安全基线。 -## ✅ 模块支持 +## ✅ 模块拆分 -- VPC + 子网(自动分配 CIDR,支持 enabled 控制) -- 安全组(通过 firewall.yaml 控制 ingress/egress) -- EC2 实例(支持 spot、AMI keyword、user_data、自动标签) -- AMI 自动识别(支持 `Ubuntu 22.04`, `Rocky Linux 8.10` 等) -- Pulumi Credentials 自动加载 ~/.aws/profile -- 环境配置文件支持多目录(如 `config/sit/`, `config/prod/`) +| 模块 | 说明 | +| --- | --- | +| `modules/identity/ram.py` | 创建 RAM 用户、用户组以及策略绑定,覆盖 `ops-automation`、`audit-viewer` 等身份。 | +| `modules/storage/oss.py` | 管理 OSS 日志桶,支持版本控制与生命周期策略,用于 ActionTrail & Pulumi 状态。 | +| `modules/audit/actiontrail.py` | 启用 ActionTrail,将操作日志投递到指定 OSS Bucket。 | +| `modules/config_service/baseline.py` | 初始化 Cloud Config Recorder、Delivery Channel 与基础规则。 | +| `modules/network/vpc.py` | 构建单 VPC + 双可用区交换机的网络基线。 | +| `modules/security/security_groups.py` | 创建默认安全组及入站/出站规则,默认仅放行出站流量。 | + +## 📂 配置结构 + +`config/alicloud/` 目录提供示例配置,按照 Landing Zone 设计拆分: + +- `base.yaml`:区域与全局标签 +- `identity.yaml`:RAM 用户/用户组与策略 +- `storage.yaml`:ActionTrail 日志桶(版本控制+生命周期) +- `network.yaml`:VPC / 交换机拓扑 +- `security.yaml`:安全组与规则 +- `audit.yaml`:ActionTrail 开关与目标 OSS +- `config-service.yaml`:Cloud Config 基线配置 + +> ⚠️ 其中 `target_arn`、`assume_role_arn` 等字段需替换为实际账号 ID(`${AliUid}`)。 + +## 🚀 使用方式 -## 🚀 快速部署 ```bash -初始化并部署 bash scripts/run.sh sit up - -## 📂 配置说明 - -# config/sit/instances.yaml - -```yaml -instances: - - name: master-1 - ami: Ubuntu 22.04 - type: t3.micro - subnet: public-subnet-1 - disk_size_gb: 20 - lifecycle: spot - ttl: 1h -```yaml - -## 🧹 清理资源 - -删除资源 + 刷新状态 -- bash scripts/run.sh sit down -- pulumi refresh --yes - -## 📦 依赖 -Python >= 3.8 +# 安装依赖 pip install -r requirements.txt -AWS CLI 已配置 ~/.aws/credentials + +# 设置配置目录(默认读取 config/,此处指向示例配置) +export CONFIG_PATH=config/alicloud + +# Pulumi 登录(可选:使用 OSS backend 或 Pulumi Service) +pulumi login + +# 预览或部署 +pulumi preview --cwd iac_modules/pulumi +pulumi up --cwd iac_modules/pulumi +``` + +## 🔒 GitHub Actions 自动化 + +新增 `.github/workflows/iac-pipeline-alicloud-landingzone-baseline.yaml`,结合 `pulumi/actions@v4` 实现 Preview + 主干自动部署,使用 Secrets 管理 `ALICLOUD_ACCESS_KEY_ID/SECRET` 与 `PULUMI_ACCESS_TOKEN`。 + +## 🧩 扩展建议 + +- 根据生产需求扩展 Cloud Config 规则或引入企业版聚合器。 +- 在安全组模块中追加环境专属规则(Prod/Test)。 +- 利用 `pulumi stack` 拆分 dev/prod 状态,配合 GitHub Environments 审批。 diff --git a/iac_modules/pulumi/__main__.py b/iac_modules/pulumi/__main__.py index b0884541..32735967 100644 --- a/iac_modules/pulumi/__main__.py +++ b/iac_modules/pulumi/__main__.py @@ -1,155 +1,59 @@ +from __future__ import annotations + import os -import sys +from typing import Dict + import pulumi -import pulumi_aws as aws -import boto3 -from botocore.exceptions import ProfileNotFound, NoCredentialsError +import pulumi_alicloud as alicloud +from modules import ( + create_oss_buckets, + create_ram_identity, + create_security_groups, + create_vpc_topology, + enable_actiontrail, + enable_config_baseline, +) from utils.config_loader import load_merged_config -from modules.vpc.vpc import create_vpcs -from modules.security_group.sg import create_security_group -from modules.ec2.ec2_instance import create_instances - -# ✅ 加载配置 -config_dir = os.environ.get("CONFIG_PATH", "config") -config = load_merged_config(config_dir) - -aws_conf = config.get("aws", {}) -region = aws_conf.get("region", "us-east-1") -profile = aws_conf.get("profile", "default") -key_pairs = aws_conf.get("key_pairs", []) - -# ✅ 设置 AWS 配置 -aws.config.region = region -aws.config.profile = profile -pulumi.runtime.set_config("aws:region", region) - -# ✅ 检查 AWS 凭证 -try: - session = boto3.Session(profile_name=profile) - credentials = session.get_credentials() - if not credentials: - raise NoCredentialsError() -except (ProfileNotFound, NoCredentialsError): - pulumi.log.error(f"❌ AWS profile '{profile}' 无效或找不到凭证") - sys.exit(1) -else: - pulumi.log.info(f"✅ AWS credentials loaded (profile: {profile}, region: {region})") - -# ✅ 初始化资源容器 -global_dependencies = [] -vpc = None -subnets = {} -sg = None -key_pair = None - -# ======================== -# ✅ [模块] VPC + Subnets -vpc_confs = config.get("vpcs", []) -if vpc_confs: - vpc_results = create_vpcs(vpc_confs, region) - all_subnets = {} - for vpc_name, result in vpc_results.items(): - pulumi.log.info(f"✅ VPC {vpc_name} 已创建") - global_dependencies.append(result["vpc"]) - global_dependencies.extend(result["subnets"].values()) - all_subnets.update(result["subnets"]) - subnets = all_subnets -else: - pulumi.log.warn("⏭️ 跳过 VPC 创建") - -# ======================== -# ✅ [模块] 多个 Security Group -# ======================== - -# ✅ 存储 VPC 结果(名字 → 资源) -vpc_map = {vpc_name: result["vpc"] for vpc_name, result in vpc_results.items()} - -firewall_rules = config.get("firewall_rules", []) -security_groups = {} - -if firewall_rules and config.get("security_group", {}).get("enabled", True): - for rule in firewall_rules: - if not rule.get("enabled", True): - pulumi.log.warn(f"⏭️ 跳过未启用的 SG: {rule.get('name')}") - continue - - vpc_name = rule.get("vpc_name") - if not vpc_name or vpc_name not in vpc_map: - pulumi.log.warn(f"❌ 未找到指定 VPC: {vpc_name},跳过 {rule.get('name')}") - continue - - vpc_resource = vpc_map[vpc_name] - - sg = create_security_group(vpc_resource.id, rule) - name = rule.get("name", "sg-unnamed") - security_groups[name] = sg - global_dependencies.append(sg) - - # 确保 SG 创建等待 VPC 完成 - pulumi.log.info(f"✅ Security Group '{name}' 已绑定 VPC: {vpc_name}") - - pulumi.export("security_groups", {k: sg.id for k, sg in security_groups.items()}) -else: - pulumi.log.warn("⏭️ 跳过 Security Group 创建") -# ======================== -# ✅ [模块] SSH Key Pair -# ======================== -if key_pairs: - key_cfg = key_pairs[0] - public_key_path = os.path.expanduser(key_cfg["key_file"]) - if not os.path.exists(public_key_path): - raise FileNotFoundError(f"❌ SSH 公钥文件不存在: {public_key_path}") - with open(public_key_path) as f: - public_key = f.read().strip() - key_pair = aws.ec2.KeyPair("main-key", - key_name=key_cfg["name"], - public_key=public_key - ) - global_dependencies.append(key_pair) - pulumi.log.info("✅ SSH KeyPair 已创建") -else: - pulumi.log.warn("⏭️ 跳过 KeyPair 创建") +def main() -> None: + config_dir = os.environ.get("CONFIG_PATH", "config/alicloud") + config = load_merged_config(config_dir) -# ======================== -# ✅ [模块] EC2 实例部署 -# ======================== + alicloud_conf: Dict[str, object] = config.get("alicloud", {}) # type: ignore[assignment] + region = alicloud_conf.get("region") + profile = alicloud_conf.get("profile") + default_tags = alicloud_conf.get("default_tags", {}) -# ======================== -# ✅ [模块] EC2 实例部署 -# ======================== -instances_conf = config.get("instances", []) -ec2_outputs = {} + if region: + alicloud.config.region = region + pulumi.export("region", region) + if profile: + alicloud.config.profile = profile -if instances_conf and config.get("ec2", {}).get("enabled", True): - # ✅ 遍历每个实例,按 sg_names 匹配对应 Security Group ID 列表 - def resolve_security_group_ids(instance_conf, sg_map): - sg_ids = [] - for name in instance_conf.get("sg_names", []): - sg = sg_map.get(name) - if sg: - sg_ids.append(sg.id) - else: - pulumi.log.warn(f"⚠️ 实例 {instance_conf['name']} 引用了未知 SG: {name}") - return sg_ids - - # ✅ 批量传入所有实例配置 - ec2_outputs = create_instances( - instances_conf, - subnets, - security_groups, # ✅ 多 SG 映射 sg_name → resource - key_pair.key_name if key_pair else None, - depends_on=global_dependencies + pulumi.log.info( + "Loaded Alicloud configuration", ) - pulumi.log.info("✅ EC2 实例已创建") -else: - pulumi.log.warn("⏭️ 跳过 EC2 实例部署") + identity_results = create_ram_identity(config.get("identity", {})) -# ======================== -# ✅ 导出所有实例信息 -# ======================== -for name, ip in ec2_outputs.items(): - pulumi.export(f"{name}", ip) + buckets = create_oss_buckets(config.get("storage", {}), default_tags) + + network_results = create_vpc_topology(config.get("network", {}), default_tags) + vpcs = network_results.get("vpcs", {}) + + security_groups = create_security_groups( + config.get("security", {}), vpcs, default_tags + ) + + enable_actiontrail(config.get("audit", {}), buckets) + + enable_config_baseline(config.get("config_service", {}), buckets) + + pulumi.export("ram_user_count", len(identity_results.get("users", {}))) + pulumi.export("security_group_count", len(security_groups)) + + +if __name__ == "__main__": + main() diff --git a/iac_modules/pulumi/modules/alicloud/__init__.py b/iac_modules/pulumi/modules/alicloud/__init__.py new file mode 100644 index 00000000..d14482d5 --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .audit.actiontrail import enable_actiontrail +from .config_service.baseline import enable_config_baseline +from .identity.ram import create_ram_identity +from .network.vpc import create_vpc_topology +from .security.security_groups import create_security_groups +from .storage.oss import create_oss_buckets + +__all__ = [ + "enable_actiontrail", + "enable_config_baseline", + "create_ram_identity", + "create_vpc_topology", + "create_security_groups", + "create_oss_buckets", +] diff --git a/iac_modules/pulumi/modules/alicloud/audit/__init__.py b/iac_modules/pulumi/modules/alicloud/audit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/audit/actiontrail.py b/iac_modules/pulumi/modules/alicloud/audit/actiontrail.py new file mode 100644 index 00000000..1149ccbd --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/audit/actiontrail.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Mapping, Optional + +import pulumi +import pulumi_alicloud as alicloud + + +def enable_actiontrail( + audit_conf: Mapping[str, object], + buckets: Mapping[str, pulumi.Resource], +) -> Optional[pulumi.Resource]: + trail_conf = audit_conf.get("actiontrail") if audit_conf else None + if not trail_conf: + pulumi.log.info("ActionTrail configuration not provided; skipping setup") + return None + + if not trail_conf.get("enabled", True): + pulumi.log.info("ActionTrail disabled via configuration; skipping setup") + return None + + bucket_name = trail_conf.get("oss_bucket_name") + bucket_reference = trail_conf.get("oss_bucket_ref") + if not bucket_name and bucket_reference: + bucket = buckets.get(bucket_reference) + if bucket is None: + pulumi.log.warn( + f"ActionTrail bucket reference '{bucket_reference}' could not be resolved" + ) + else: + bucket_name = bucket.bucket + + if not bucket_name: + pulumi.log.warn("No OSS bucket specified for ActionTrail; skipping trail creation") + return None + + name = trail_conf.get("name", "landingzone-actiontrail") + trail = alicloud.actiontrail.Trail( + name, + trail_name=trail_conf.get("trail_name", name), + event_rw=trail_conf.get("event_rw", "All"), + oss_bucket_name=bucket_name, + oss_key_prefix=trail_conf.get("oss_key_prefix"), + trail_region=trail_conf.get("trail_region"), + is_organization_trail=trail_conf.get("is_organization_trail"), + oss_write_role_arn=trail_conf.get("oss_write_role_arn"), + sls_project_arn=trail_conf.get("sls_project_arn"), + sls_write_role_arn=trail_conf.get("sls_write_role_arn"), + status=trail_conf.get("status"), + ) + + pulumi.export("actiontrail_trail", trail.trail_name) + return trail diff --git a/iac_modules/pulumi/modules/alicloud/common/__init__.py b/iac_modules/pulumi/modules/alicloud/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/common/tags.py b/iac_modules/pulumi/modules/alicloud/common/tags.py new file mode 100644 index 00000000..00aefb90 --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/common/tags.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Dict, Mapping, Optional + +import pulumi + + +def merge_tags(*tag_sets: Optional[Mapping[str, str]]) -> Optional[Dict[str, str]]: + """Merge multiple tag dictionaries while filtering out falsy values.""" + merged: Dict[str, str] = {} + for tags in tag_sets: + if not tags: + continue + for key, value in tags.items(): + if value is None: + continue + merged[str(key)] = str(value) + return merged or None + + +def taggable_args( + default_tags: Optional[Mapping[str, str]], + resource_tags: Optional[Mapping[str, str]] = None, +) -> Dict[str, object]: + """Helper to build Pulumi args with merged tags.""" + tags = merge_tags(default_tags, resource_tags) + return {"tags": tags} if tags else {} + + +def annotate_resource_with_tags(resource: pulumi.CustomResource, tags: Optional[Mapping[str, str]]) -> None: + """Attach merged tags to the Pulumi resource options after creation.""" + if tags: + pulumi.export(f"tags::{resource._name}", tags) diff --git a/iac_modules/pulumi/modules/alicloud/config_service/__init__.py b/iac_modules/pulumi/modules/alicloud/config_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/config_service/baseline.py b/iac_modules/pulumi/modules/alicloud/config_service/baseline.py new file mode 100644 index 00000000..698d0224 --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/config_service/baseline.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Dict, Mapping + +import pulumi +import pulumi_alicloud as alicloud + + +def enable_config_baseline( + config_conf: Mapping[str, object], + buckets: Mapping[str, pulumi.Resource], +) -> Dict[str, object]: + if not config_conf: + pulumi.log.info("Cloud Config configuration not provided; skipping setup") + return {} + + resources: Dict[str, object] = {} + + recorder_conf = config_conf.get("recorder") + recorder = None + if recorder_conf: + recorder = alicloud.cfg.ConfigurationRecorder( + recorder_conf.get("name", "config-recorder"), + enterprise_edition=recorder_conf.get("enterprise_edition"), + resource_types=recorder_conf.get("resource_types"), + ) + resources["recorder"] = recorder + + delivery_conf = config_conf.get("delivery_channel") + delivery_channel = None + if delivery_conf: + target_arn = delivery_conf.get("target_arn") + bucket_ref = delivery_conf.get("oss_bucket_ref") + if not target_arn and bucket_ref: + bucket = buckets.get(bucket_ref) + if bucket: + target_arn = delivery_conf.get("target_arn_fallback") + pulumi.log.info( + "Delivery channel target ARN not provided explicitly; using fallback" + ) + else: + pulumi.log.warn( + f"Delivery channel bucket reference '{bucket_ref}' could not be resolved" + ) + if target_arn: + delivery_channel = alicloud.cfg.DeliveryChannel( + delivery_conf.get("name", "config-delivery-channel"), + delivery_channel_name=delivery_conf.get("display_name"), + description=delivery_conf.get("description"), + delivery_channel_type=delivery_conf.get("type", "OSS"), + delivery_channel_target_arn=target_arn, + delivery_channel_assume_role_arn=delivery_conf.get("assume_role_arn"), + delivery_channel_condition=delivery_conf.get("condition"), + status=delivery_conf.get("status"), + ) + resources["delivery_channel"] = delivery_channel + else: + pulumi.log.warn("Cloud Config delivery channel requires a target ARN; skipping") + + for rule_conf in config_conf.get("rules", []) or []: + required_fields = ["name", "source_identifier"] + if any(field not in rule_conf for field in required_fields): + pulumi.log.warn( + f"Skipping Cloud Config rule definition due to missing fields: {rule_conf}" + ) + continue + + rule_args = { + "rule_name": rule_conf["name"], + "description": rule_conf.get("description"), + "risk_level": rule_conf.get("risk_level", 2), + "source_owner": rule_conf.get("source_owner", "ALIYUN"), + "source_identifier": rule_conf["source_identifier"], + "config_rule_trigger_types": rule_conf.get( + "trigger_types", "ConfigurationItemChangeNotification" + ), + "resource_types_scopes": rule_conf.get("resource_types_scopes"), + "region_ids_scope": rule_conf.get("region_ids_scope"), + "resource_group_ids_scope": rule_conf.get("resource_group_ids_scope"), + "tag_key_scope": rule_conf.get("tag_key_scope"), + "tag_value_scope": rule_conf.get("tag_value_scope"), + "input_parameters": rule_conf.get("input_parameters"), + "maximum_execution_frequency": rule_conf.get("maximum_execution_frequency"), + "status": rule_conf.get("status"), + } + rule_args = {key: value for key, value in rule_args.items() if value is not None} + if delivery_channel: + rule_args["delivery_channel_id"] = delivery_channel.id + if recorder: + rule_args["configuration_recorder_id"] = recorder.id + + rule = alicloud.cfg.Rule(rule_conf["name"], **rule_args) + resources.setdefault("rules", {})[rule_conf["name"]] = rule + + return resources diff --git a/iac_modules/pulumi/modules/alicloud/identity/__init__.py b/iac_modules/pulumi/modules/alicloud/identity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/identity/ram.py b/iac_modules/pulumi/modules/alicloud/identity/ram.py new file mode 100644 index 00000000..25e3eefd --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/identity/ram.py @@ -0,0 +1,89 @@ +from __future__ import annotations + + +from typing import Dict, Iterable, Mapping, Optional + +import pulumi +import pulumi_alicloud as alicloud + + +PolicyConfig = Mapping[str, str] +UserConfig = Mapping[str, object] +GroupConfig = Mapping[str, object] + + +def _normalize_policies(policies: Optional[Iterable[PolicyConfig]]) -> Iterable[PolicyConfig]: + return policies or [] + + +def create_ram_identity( + identity_conf: Mapping[str, object], +) -> Dict[str, Dict[str, pulumi.Resource]]: + """Create RAM users, groups, and policy attachments based on configuration.""" + users_conf = identity_conf.get("users", []) or [] + groups_conf = identity_conf.get("groups", []) or [] + + users: Dict[str, pulumi.Resource] = {} + groups: Dict[str, pulumi.Resource] = {} + + for user_conf in users_conf: + name = user_conf["name"] + args = { + "name": name, + "display_name": user_conf.get("display_name"), + "email": user_conf.get("email"), + "mobile": user_conf.get("mobile"), + "comments": user_conf.get("comments"), + "force": user_conf.get("force_destroy"), + } + args = {k: v for k, v in args.items() if v is not None} + user = alicloud.ram.User(name, **args) + users[name] = user + + for index, policy in enumerate(_normalize_policies(user_conf.get("policies"))): + alicloud.ram.UserPolicyAttachment( + f"{name}-policy-{index}", + policy_name=policy["name"], + policy_type=policy.get("type", "System"), + user_name=name, + opts=pulumi.ResourceOptions(depends_on=[user]), + ) + + for group_conf in groups_conf: + name = group_conf["name"] + args = { + "group_name": name, + "comments": group_conf.get("comments"), + "force": group_conf.get("force_destroy"), + } + args = {k: v for k, v in args.items() if v is not None} + group = alicloud.ram.Group(name, **args) + groups[name] = group + + for index, policy in enumerate(_normalize_policies(group_conf.get("policies"))): + alicloud.ram.GroupPolicyAttachment( + f"{name}-policy-{index}", + group_name=name, + policy_name=policy["name"], + policy_type=policy.get("type", "System"), + opts=pulumi.ResourceOptions(depends_on=[group]), + ) + + members = group_conf.get("users") or [] + missing_members = [user for user in members if user not in users] + if missing_members: + pulumi.log.warn( + f"RAM group '{name}' references users not defined in configuration: {', '.join(missing_members)}" + ) + if members: + alicloud.ram.GroupMembership( + f"{name}-membership", + group_name=name, + user_names=[user for user in members if user in users], + opts=pulumi.ResourceOptions(depends_on=[group] + [users[user] for user in members if user in users]), + ) + + pulumi.export("ram_users", {name: user.name for name, user in users.items()}) + pulumi.export("ram_groups", {name: group.group_name for name, group in groups.items()}) + + return {"users": users, "groups": groups} diff --git a/iac_modules/pulumi/modules/alicloud/network/__init__.py b/iac_modules/pulumi/modules/alicloud/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/network/vpc.py b/iac_modules/pulumi/modules/alicloud/network/vpc.py new file mode 100644 index 00000000..8db3e139 --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/network/vpc.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Dict, Mapping, Optional + +import pulumi +import pulumi_alicloud as alicloud + +from ..common.tags import merge_tags + + +def create_vpc_topology( + network_conf: Mapping[str, object], + default_tags: Optional[Mapping[str, str]] = None, +) -> Dict[str, Dict[str, pulumi.Resource]]: + """Create VPCs and VSwitches as described in the network configuration.""" + vpcs_conf = network_conf.get("vpcs", []) or [] + + vpcs: Dict[str, pulumi.Resource] = {} + vswitches: Dict[str, pulumi.Resource] = {} + + for vpc_conf in vpcs_conf: + name = vpc_conf["name"] + vpc_tags = merge_tags(default_tags, vpc_conf.get("tags")) + vpc = alicloud.vpc.Network( + name, + vpc_name=vpc_conf.get("display_name", name), + cidr_block=vpc_conf["cidr_block"], + description=vpc_conf.get("description"), + **({"tags": vpc_tags} if vpc_tags else {}), + ) + vpcs[name] = vpc + + for switch_conf in vpc_conf.get("vswitches", []) or []: + switch_name = switch_conf["name"] + switch_tags = merge_tags(vpc_tags, switch_conf.get("tags")) + vswitch = alicloud.vpc.Switch( + switch_name, + vswitch_name=switch_conf.get("display_name", switch_name), + vpc_id=vpc.id, + cidr_block=switch_conf["cidr_block"], + zone_id=switch_conf["zone_id"], + description=switch_conf.get("description"), + **({"tags": switch_tags} if switch_tags else {}), + opts=pulumi.ResourceOptions(depends_on=[vpc]), + ) + vswitches[switch_name] = vswitch + + pulumi.export("vpc_ids", {name: vpc.id for name, vpc in vpcs.items()}) + pulumi.export("vswitch_ids", {name: sw.id for name, sw in vswitches.items()}) + + return {"vpcs": vpcs, "vswitches": vswitches} diff --git a/iac_modules/pulumi/modules/alicloud/security/__init__.py b/iac_modules/pulumi/modules/alicloud/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/security/security_groups.py b/iac_modules/pulumi/modules/alicloud/security/security_groups.py new file mode 100644 index 00000000..ef0fe3f6 --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/security/security_groups.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Dict, Mapping, Optional + +import pulumi +import pulumi_alicloud as alicloud + +from ..common.tags import merge_tags + + +def create_security_groups( + security_conf: Mapping[str, object], + vpcs: Mapping[str, pulumi.Resource], + default_tags: Optional[Mapping[str, str]] = None, +) -> Dict[str, pulumi.Resource]: + groups_conf = security_conf.get("groups", []) or [] + security_groups: Dict[str, pulumi.Resource] = {} + + for group_conf in groups_conf: + name = group_conf["name"] + vpc_name = group_conf.get("vpc") + vpc = vpcs.get(vpc_name) if vpc_name else None + if vpc is None: + pulumi.log.warn(f"Skip security group '{name}' because VPC '{vpc_name}' was not found") + continue + + tags = merge_tags(default_tags, group_conf.get("tags")) + sg = alicloud.ecs.SecurityGroup( + name, + security_group_name=group_conf.get("display_name", name), + description=group_conf.get("description"), + security_group_type=group_conf.get("type", "normal"), + vpc_id=vpc.id, + **({"tags": tags} if tags else {}), + opts=pulumi.ResourceOptions(depends_on=[vpc]), + ) + security_groups[name] = sg + + for index, rule in enumerate(group_conf.get("ingress", []) or []): + _create_rule(sg, rule, "ingress", index) + + for index, rule in enumerate(group_conf.get("egress", []) or []): + _create_rule(sg, rule, "egress", index) + + pulumi.export("security_group_ids", {name: sg.id for name, sg in security_groups.items()}) + return security_groups + + +def _create_rule( + sg: pulumi.Resource, + rule_conf: Mapping[str, object], + rule_type: str, + index: int, +) -> None: + protocol = rule_conf.get("protocol", "all") + cidr_ip = rule_conf.get("cidr_ip") + ipv6_cidr_ip = rule_conf.get("ipv6_cidr_ip") + source_sg = rule_conf.get("source_security_group_id") + prefix_list_id = rule_conf.get("prefix_list_id") + + if not any([cidr_ip, ipv6_cidr_ip, source_sg, prefix_list_id]): + pulumi.log.warn( + f"Security group {sg._name} {rule_type} rule #{index} does not define a source/destination; skipping" + ) + return + + args = { + "security_group_id": sg.id, + "type": rule_type, + "ip_protocol": protocol, + "port_range": rule_conf.get("port_range", "-1/-1"), + "cidr_ip": cidr_ip, + "ipv6_cidr_ip": ipv6_cidr_ip, + "source_security_group_id": source_sg, + "prefix_list_id": prefix_list_id, + "policy": rule_conf.get("policy", "accept"), + "description": rule_conf.get("description"), + "priority": rule_conf.get("priority"), + "nic_type": rule_conf.get("nic_type"), + } + args = {key: value for key, value in args.items() if value is not None} + alicloud.ecs.SecurityGroupRule( + f"{sg._name}-{rule_type}-{index}", + **args, + opts=pulumi.ResourceOptions(depends_on=[sg]), + ) diff --git a/iac_modules/pulumi/modules/alicloud/storage/__init__.py b/iac_modules/pulumi/modules/alicloud/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iac_modules/pulumi/modules/alicloud/storage/oss.py b/iac_modules/pulumi/modules/alicloud/storage/oss.py new file mode 100644 index 00000000..cfa3b3ab --- /dev/null +++ b/iac_modules/pulumi/modules/alicloud/storage/oss.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Dict, List, Mapping, Optional + +import pulumi +import pulumi_alicloud as alicloud + +from ..common.tags import merge_tags + + +LifecycleConfig = Mapping[str, object] + + +def create_oss_buckets( + storage_conf: Mapping[str, object], + default_tags: Optional[Mapping[str, str]] = None, +) -> Dict[str, pulumi.Resource]: + buckets_conf = storage_conf.get("oss_buckets", []) or [] + buckets: Dict[str, pulumi.Resource] = {} + + for bucket_conf in buckets_conf: + name = bucket_conf["name"] + tags = merge_tags(default_tags, bucket_conf.get("tags")) + lifecycle_rules = [_build_lifecycle_rule(rule) for rule in bucket_conf.get("lifecycle_rules", [])] + lifecycle_rules = [rule for rule in lifecycle_rules if rule is not None] + + bucket = alicloud.oss.Bucket( + name, + bucket=bucket_conf.get("bucket", name), + storage_class=bucket_conf.get("storage_class", "Standard"), + acl=bucket_conf.get("acl"), + force_destroy=bucket_conf.get("force_destroy", False), + logging=_build_logging(bucket_conf.get("logging")), + versioning=_build_versioning(bucket_conf.get("versioning")), + lifecycle_rules=lifecycle_rules or None, + **({"tags": tags} if tags else {}), + ) + buckets[name] = bucket + + pulumi.export("oss_bucket_names", {name: bucket.bucket for name, bucket in buckets.items()}) + return buckets + + +def _build_versioning(config: Optional[Mapping[str, object]]) -> Optional[alicloud.oss.BucketVersioningArgs]: + if not config: + return None + if isinstance(config, str): + status = config + else: + status = config.get("status", "Enabled") + return alicloud.oss.BucketVersioningArgs(status=status) + + +def _build_logging(config: Optional[Mapping[str, object]]) -> Optional[alicloud.oss.BucketLoggingArgs]: + if not config: + return None + target_bucket = config.get("target_bucket") + if not target_bucket: + return None + return alicloud.oss.BucketLoggingArgs( + target_bucket=target_bucket, + target_prefix=config.get("target_prefix"), + ) + + +def _build_lifecycle_rule(config: LifecycleConfig) -> Optional[alicloud.oss.BucketLifecycleRuleArgs]: + if not config: + return None + + transitions = [ + alicloud.oss.BucketLifecycleRuleTransitionArgs( + storage_class=transition["storage_class"], + days=transition.get("days"), + created_before_date=transition.get("created_before_date"), + is_access_time=transition.get("is_access_time"), + return_to_std_when_visit=transition.get("return_to_standard_when_visited"), + ) + for transition in config.get("transitions", []) + if "storage_class" in transition + ] + + expiration_cfg = config.get("expiration") + if expiration_cfg is None and config.get("expiration_days"): + expiration_cfg = {"days": config["expiration_days"]} + + expirations: List[alicloud.oss.BucketLifecycleRuleExpirationArgs] = [] + if expiration_cfg: + expirations.append( + alicloud.oss.BucketLifecycleRuleExpirationArgs( + days=expiration_cfg.get("days"), + date=expiration_cfg.get("date"), + created_before_date=expiration_cfg.get("created_before_date"), + expired_object_delete_marker=expiration_cfg.get("expired_object_delete_marker"), + ) + ) + + return alicloud.oss.BucketLifecycleRuleArgs( + id=config.get("id"), + enabled=config.get("enabled", True), + prefix=config.get("prefix"), + transitions=transitions or None, + expirations=expirations or None, + )