Chapter 02

核心概念:Resource、Output 与资源选项

深入理解 Pulumi 的类型系统与资源依赖模型

Resource 资源创建模式

资源的构造函数签名

Pulumi 中每个云资源都对应一个 SDK 类,通过 new(TypeScript)或直接调用(Python)来创建。构造函数通常接受三个参数:

// TypeScript 资源创建签名
// new ResourceType(name, args, opts?)

import * as aws from "@pulumi/aws";

const bucket = new aws.s3.BucketV2(
    "my-bucket",          // name: Pulumi 内部名称(在 State 中标识资源)
    {                        // args: 资源属性
        tags: {
            Environment: "dev",
            ManagedBy: "Pulumi",
        },
    },
    {                        // opts: 资源选项(可选)
        protect: true,
    }
);
# Python 资源创建
import pulumi_aws as aws

bucket = aws.s3.BucketV2(
    "my-bucket",           # 第一个参数:Pulumi 资源名
    tags={                   # 资源属性(通过关键字参数传递)
        "Environment": "dev",
        "ManagedBy": "Pulumi",
    },
    opts=pulumi.ResourceOptions(  # 资源选项
        protect=True
    )
)
资源名称(name)是 Pulumi 的内部标识符

构造函数的第一个参数 name 是 Pulumi 在 State 中识别资源的唯一名称,修改它会导致资源被销毁并重建!它与云资源的实际名称(如 S3 Bucket 名)不同——云资源名由 args 中的属性控制(如 bucket="my-actual-name")。如果不指定,Pulumi 会在 Pulumi 资源名后附加随机后缀作为云资源名(实现名称唯一性)。

Output<T>:异步引用值

为什么需要 Output<T>?

当你创建一个 AWS EC2 实例时,它的公网 IP 在实例实际创建完成之前是未知的。Pulumi 用 Output<T> 类型来表示这类"部署后才能知道的值"。

Output<T> 就像 JavaScript 的 Promise<T>:它代表一个未来的值。你不能直接读取其内部的字符串,而必须用 apply() 方法来转换它。

import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

const server = new aws.ec2.Instance("web-server", {
    ami: "ami-0c02fb55956c7d316",
    instanceType: "t3.micro",
});

// server.publicIp 的类型是 Output<string>
// 不能直接这样做(编译错误!):
// const url = "http://" + server.publicIp;  // ❌

// 正确方式:用 apply() 转换 Output
const url: pulumi.Output<string> = server.publicIp.apply(
    ip => `http://${ip}`
);

// 导出(在 pulumi stack output 中查看)
export const serverUrl = url;
import pulumi
import pulumi_aws as aws

server = aws.ec2.Instance(
    "web-server",
    ami="ami-0c02fb55956c7d316",
    instance_type="t3.micro",
)

# server.public_ip 是 Output[str]
# 用 apply() 转换
url = server.public_ip.apply(lambda ip: f"http://{ip}")

# 导出
pulumi.export("server_url", url)

pulumi.concat():拼接包含 Output 的字符串

// TypeScript:使用 pulumi.interpolate 模板字符串
const connectionStr = pulumi.interpolate
    `postgres://user:pass@${db.endpoint}:5432/mydb`;

// 或使用 pulumi.concat()
const url = pulumi.concat("https://", domain.name, "/api");
# Python:Output.concat() 或 apply()
conn_str = pulumi.Output.concat(
    "postgres://user:pass@", db.endpoint, ":5432/mydb"
)

# 多个 Output 合并处理:pulumi.Output.all()
pulumi.Output.all(db.host, db.port, db.name).apply(
    lambda args: f"postgres://user@{args[0]}:{args[1]}/{args[2]}"
)

资源选项(Resource Options)

资源选项通过 ResourceOptions(Python)或第三个参数对象(TypeScript)传递,控制资源的生命周期行为。

dependsOn
显式声明资源依赖关系。Pulumi 通常能自动推断依赖(通过 Output 引用),但有时需要手动声明隐式依赖(如"在数据库就绪后才创建应用")。
protect
保护资源免于意外删除。设为 true 时,pulumi destroy 会失败,防止误删生产资源(如 RDS 数据库)。
ignoreChanges
忽略指定属性的变更。用于某些由外部系统管理的属性(如 auto-scaling 管理的实例数量),避免 Pulumi 每次都要"修正"它。
parent
指定父资源,建立资源层次结构(用于 ComponentResource)。子资源会出现在 pulumi up 输出的父资源缩进下。
provider
指定该资源使用的 Provider 实例。当需要在多个 AWS 账号或 region 中部署时,可以为不同 region 创建不同的 Provider 实例。
deleteBeforeReplace
强制替换时先删除再创建(默认行为相反:先创建新资源再删除旧资源)。用于不允许同名资源并存的服务。
retainOnDelete
删除 Pulumi 资源时,保留云上的实际资源(不调用云 API 删除)。用于将资源从 Pulumi 管理中"移出"但不销毁。
import pulumi
import pulumi_aws as aws

# 创建 VPC
vpc = aws.ec2.Vpc("main-vpc", cidr_block="10.0.0.0/16")

# 创建子网(父资源:vpc)
subnet = aws.ec2.Subnet(
    "main-subnet",
    vpc_id=vpc.id,           # Output[str] — 自动推断依赖
    cidr_block="10.0.1.0/24",
    opts=pulumi.ResourceOptions(parent=vpc),
)

# 创建受保护的 RDS 数据库
db = aws.rds.Instance(
    "prod-db",
    instance_class="db.t3.micro",
    engine="postgres",
    allocated_storage=20,
    username="admin",
    password="secret123",
    skip_final_snapshot=True,
    opts=pulumi.ResourceOptions(
        protect=True,                           # 防止意外删除
        ignore_changes=["password"],           # 忽略密码变更
        depends_on=[subnet],                     # 显式依赖子网
    ),
)

ComponentResource:自定义组件

ComponentResource 让你将多个资源封装为一个逻辑单元,就像定义一个类一样。这是 Pulumi 最强大的抽象机制之一。

import pulumi
import pulumi_aws as aws
from typing import Optional

class StaticWebsite(pulumi.ComponentResource):
    """封装 S3 静态网站的 ComponentResource"""

    def __init__(
        self,
        name: str,
        index_document: str = "index.html",
        opts: Optional[pulumi.ResourceOptions] = None
    ):
        # 调用父类构造器,注册组件类型
        super().__init__("mycompany:web:StaticWebsite", name, {}, opts)

        # 子资源:opts 中传入 parent=self
        child_opts = pulumi.ResourceOptions(parent=self)

        self.bucket = aws.s3.BucketV2(
            f"{name}-bucket",
            opts=child_opts
        )

        self.website_config = aws.s3.BucketWebsiteConfigurationV2(
            f"{name}-website",
            bucket=self.bucket.id,
            index_document=aws.s3.BucketWebsiteConfigurationV2IndexDocumentArgs(
                suffix=index_document
            ),
            opts=child_opts
        )

        # 注册输出(使组件的属性可被外部引用)
        self.register_outputs({
            "bucket_name": self.bucket.id,
            "website_url": self.website_config.website_endpoint,
        })

# 使用组件:像使用普通资源一样
site = StaticWebsite("my-site", index_document="index.html")
pulumi.export("website_url", site.website_config.website_endpoint)

Dynamic Provider:自定义资源类型

当 Pulumi 官方 Provider 不支持某个 API 时,可以用 Dynamic Provider 自定义资源类型,实现任意第三方 API 的 CRUD 操作。

import pulumi
from pulumi.dynamic import Resource, ResourceProvider, CreateResult

class GithubLabelProvider(ResourceProvider):
    """自定义 GitHub Issue Label 资源"""

    def create(self, props):
        # 调用 GitHub API 创建 Label
        import requests
        resp = requests.post(
            f"https://api.github.com/repos/{props['repo']}/labels",
            json={"name": props["name"], "color": props["color"]},
            headers={"Authorization": f"token {props['token']}"}
        )
        label_id = resp.json()["id"]
        return CreateResult(str(label_id), props)

    def delete(self, resource_id, props):
        import requests
        requests.delete(
            f"https://api.github.com/repos/{props['repo']}/labels/{props['name']}",
            headers={"Authorization": f"token {props['token']}"}
        )

class GithubLabel(Resource):
    def __init__(self, name, props, opts=None):
        super().__init__(GithubLabelProvider(), name, props, opts)

# 使用自定义资源
label = GithubLabel("bug-label", {
    "repo": "myorg/myrepo",
    "name": "bug",
    "color": "d73a4a",
    "token": "ghp_xxx",
})

实战:创建 AWS S3 存储桶(完整示例)

# __main__.py — 完整的 S3 Bucket 示例
import pulumi
import pulumi_aws as aws
import json

# 1. 创建 Bucket
bucket = aws.s3.BucketV2(
    "app-assets",
    tags={"Environment": "dev", "Team": "platform"},
)

# 2. 关闭公开访问屏蔽(允许静态网站访问)
public_access = aws.s3.BucketPublicAccessBlock(
    "app-assets-pab",
    bucket=bucket.id,
    block_public_acls=False,
    block_public_policy=False,
    ignore_public_acls=False,
    restrict_public_buckets=False,
    opts=pulumi.ResourceOptions(depends_on=[bucket]),
)

# 3. 配置静态网站
website = aws.s3.BucketWebsiteConfigurationV2(
    "app-assets-website",
    bucket=bucket.id,
    index_document=aws.s3.BucketWebsiteConfigurationV2IndexDocumentArgs(
        suffix="index.html"
    ),
    error_document=aws.s3.BucketWebsiteConfigurationV2ErrorDocumentArgs(
        key="404.html"
    ),
)

# 4. 添加公开读取策略(apply() 变换 Output)
policy = aws.s3.BucketPolicy(
    "app-assets-policy",
    bucket=bucket.id,
    policy=bucket.id.apply(
        lambda bucket_name: json.dumps({
            "Version": "2012-10-17",
            "Statement": [{
                "Effect": "Allow",
                "Principal": "*",
                "Action": ["s3:GetObject"],
                "Resource": f"arn:aws:s3:::{bucket_name}/*",
            }]
        })
    ),
    opts=pulumi.ResourceOptions(depends_on=[public_access]),
)

# 5. 导出输出值
pulumi.export("bucket_name", bucket.id)
pulumi.export("website_url", website.website_endpoint)
Output.apply() 中可以返回另一个 Output

如果 apply() 的回调函数返回另一个 Output,Pulumi 会自动"展开"嵌套的 Output,你得到的仍然是一个扁平的 Output<T>,无需手动处理嵌套。这与 Promise 的行为类似(类似 flatMap)。

本章小结

本章核心要点