SuperAPI AI 工具集

provider-ephemeral-resources

部署与运维

查看项目

更新于 10-15 14:00

技能介绍

用途

临时资源(Ephemeral Resources,Terraform 1.10+)产生的值永远不会持久化到 state 或 plan。它们只做一件事:把机密——令牌、生成的密码、短期证书、解密后的值——交给配置中需要它们的部分,而不写入磁盘。任何返回敏感值的数据源,都可以考虑(或同时)作为临时资源存在。

何时使用

  • 只读查询非敏感数据:使用数据源。
  • 值敏感且仅在 apply 时需要(例如 provider 块使用的数据库密码、只写属性的令牌):使用临时资源。
  • 敏感值需要被下游托管资源存储(例如作为属性):使用常规资源/数据源,但尽可能配合只写属性。
  • 凭据在操作中途过期(STS 风格令牌、短 TTL 租约):使用带 Renew 的临时资源。

临时结果可用于 provider 配置、只写属性、provisioner 配置以及其他临时上下文,但不能用于常规属性,因为那些会持久化到 state。

生命周期

Terraform 每次操作最多调用三个方法:

  • Open(必需):获取或创建值;在 plan 和/或 apply 期间需要结果时运行。没有 state 可刷新,也没有可导入的内容。
  • Renew(可选):当墙上时钟超过 Open/Renew 返回的 RenewAt 时调用,用于在 Terraform 仍在运行时过期的值。Renew 不能返回新结果——它只能延长/刷新 Open 产生的值(例如重新租用同一凭据);如果值本身在续期时改变,则该 API 在此意义上不可续期,Open 必须返回更长期的值。
  • Close(可选):Terraform 用完该值时调用;在此撤销租约或删除临时凭据。

Open 可以通过 resp.Private 向前传递字节;Renew 和 Close 接收它们——用于续期/撤销所需的租约 ID。

实现

代码示例(Go):

var (
    _ ephemeral.EphemeralResource              = &tokenEphemeralResource{}
    _ ephemeral.EphemeralResourceWithConfigure = &tokenEphemeralResource{}
    _ ephemeral.EphemeralResourceWithRenew     = &tokenEphemeralResource{}
    _ ephemeral.EphemeralResourceWithClose     = &tokenEphemeralResource{}
)

func NewTokenEphemeralResource() ephemeral.EphemeralResource {
    return &tokenEphemeralResource{}
}

type tokenEphemeralResource struct {
    client *examplecloud.Client
}

type tokenEphemeralResourceModel struct {
    RoleName types.String `tfsdk:"role_name"`
    Token    types.String `tfsdk:"token"`
    LeaseID  types.String `tfsdk:"lease_id"`
}

func (r *tokenEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) {
    resp.TypeName = req.ProviderTypeName + "_token"
}

func (r *tokenEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "role_name": schema.StringAttribute{
                Required:            true,
                MarkdownDescription: "Role to obtain a token for.",
            },
            "token": schema.StringAttribute{
                Computed:            true,
                Sensitive:           true,
                MarkdownDescription: "The issued token. Never persisted to state.",
            },
            "lease_id": schema.StringAttribute{
                Computed:            true,
                MarkdownDescription: "Identifier of the token lease.",
            },
        },
    }
}

func (r *tokenEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) {
    var data tokenEphemeralResourceModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
    if resp.Diagnostics.HasError() {
        return
    }

    lease, err := r.client.IssueToken(ctx, data.RoleName.ValueString())
    if err != nil {
        resp.Diagnostics.AddError(
            "Error opening Token",
            fmt.Sprintf("issuing token for role (%s): %s", data.RoleName.ValueString(), err),
        )
        return
    }

    data.Token = types.StringValue(lease.Token)
    data.LeaseID = types.StringValue(lease.ID)

    resp.RenewAt = lease.ExpiresAt.Add(-2 * time.Minute) // renew with margin
    resp.Private.SetKey(ctx, "lease_id", []byte(lease.ID))
    resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...)
}

func (r *tokenEphemeralResource) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) {
    leaseID, diags := req.Private.GetKey(ctx, "lease_id")
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    lease, err := r.client.RenewLease(ctx, string(leaseID))
    if err != nil {
        resp.Diagnostics.AddError("Error renewing Token", err.Error())
        return
    }
    resp.RenewAt = lease.ExpiresAt.Add(-2 * time.Minute)
}

func (r *tokenEphemeralResource) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) {
    leaseID, diags := req.Private.GetKey(ctx, "lease_id")
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    if err := r.client.RevokeLease(ctx, string(leaseID)); err != nil {
        resp.Diagnostics.AddError("Error closing Token", err.Error())
    }
}

Configure 遵循与资源相同的 ProviderData 类型转换模式(provider-resources 技能,如可用,展示了该模式);客户端来自 provider 的 Configure 中设置的 resp.EphemeralResourceData。

注册

Provider 通过 provider.ProviderWithEphemeralResources 选择加入:

var _ provider.ProviderWithEphemeralResources = &examplecloudProvider{}

func (p *examplecloudProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource {
    return []func() ephemeral.EphemeralResource{
        NewTokenEphemeralResource(),
    }
}

在 provider 的 Configure 中,与 ResourceData/DataSourceData 一起设置 resp.EphemeralResourceData = client。

设计规则

  • 永远不要记录该值,永远不要将其放入诊断信息。整个要点就是非持久化;包含令牌的错误消息会破坏这一点。
  • 仍然将机密属性标记为 Sensitive: true——它保护临时值自身生命周期输出中的渲染。
  • 没有 plan 修饰符、没有导入、没有 id 约定——没有 state 可供它们作用。
  • Schema 输入遵循与数据源参数相同的规则;暴露 API 的标识符(role_name),而不是编造的。
  • 在真实过期前设置带安全余量的 RenewAt;Terraform 是惰性续期,而非精确计时器。
  • 如果上游值无法撤销,跳过 Close,而不是实现一个暗示会撤销的空操作。

测试

临时结果永远不会到达 state,因此测试间接断言它们——标准模式通过 echoprovider 将临时值回显到测试可检查的常规资源中。最低覆盖:一个基本的打开并使用测试,以及针对必需字段的逐属性测试。使用 provider-test-patterns 技能(如可用)——其临时测试参考涵盖 echoprovider 设置、版本门控(tfversion.SkipBelow(tfversion.Version1_10_0))和多步骤模式。

文档

Registry 文档位于 docs/ephemeral-resources/<name>.md,由 tfplugindocs 像其他页面类型一样生成。使用 provider-docs 技能(如可用)了解工作流;明确记录续期/撤销行为——用户需要知道关闭 Terraform 运行是否会撤销凭据。

检查清单

  • 值确实必须不持久化(否则数据源更简单)
  • 已实现 Open;仅在 API 支持处实现 Renew/Close
  • 机密属性 Sensitive: true;值从不记录或出现在诊断中
  • 租约/句柄通过 Private 传递,而非通过结果
  • 对会过期的凭据设置带余量的 RenewAt
  • 在 EphemeralResources() 中注册;在 provider Configure 中设置 EphemeralResourceData
  • Echo-provider 验收测试,版本门控到 Terraform >= 1.10
  • 文档页面解释生命周期、续期和撤销行为