关于 provider-ephemeral-resources
面向 Terraform 1.10+ Provider 的临时资源实现技能,讲解如何传递短期敏感值而不将其写入状态或计划,并涵盖生命周期、注册、测试和文档。
主要功能
- 01Open、Renew、Close 生命周期
- 02通过 Private 传递租约信息
- 03注册临时资源
- 04回显式测试方法
- 05生命周期文档指南
适用场景
- 01传递短期访问令牌
- 02生成不写入状态的密码
- 03管理可续期凭据租约
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ hashicorp/ agent-skills --skill provider-ephemeral-resources安装后在 Claude Code 中输入/provider-ephemeral-resources确认已加载。
详细介绍
Terraform Provider 短暂资源
短暂资源(Terraform 1.10+)生成的值绝不会持久化到状态或计划中。它们只用于一件事:将密钥——令牌、生成的密码、短期证书、解密后的值——传递给配置中需要它们的部分,而不将其写入磁盘。任何返回敏感值的数据源都可以考虑改为短暂资源,或同时提供短暂资源。
官方文档:短暂资源。
何时使用
| 情况 | 用途 |
|---|---|
| 只读查询非敏感数据 | 数据源 |
| 值是敏感的,且只在 apply 时需要(例如 provider 配置块中的数据库密码、只写属性所需的令牌) | 短暂资源 |
| 下游托管资源必须存储的敏感值(例如作为某个属性) | 常规资源/数据源——但尽可能搭配只写属性使用 |
| 操作过程中会过期的凭据(STS 风格的令牌、短 TTL 租约) | 带有 Renew 的短暂资源 |
短暂资源的结果可用于 provider 配置、只写属性、置备程序配置及其他短暂上下文,但不能用于常规属性,因为这些属性会持久化到状态中。
生命周期
Terraform 在每次操作中最多会调用三个方法:
Open(必需)——获取或创建值;在需要结果的计划和/或应用期间运行。没有状态可供刷新,也无需导入。Renew(可选)——当系统时钟超过Open/Renew返回的RenewAt时调用,适用于 Terraform 仍在运行时会过期的值。Renew不能返回新结果,只能延长/刷新Open生成的值(例如重新租赁相同的凭据);如果值本身在续期时会改变,那么从这个意义上说,该 API 不支持续期,Open必须返回有效期更长的值。Close(可选)——Terraform 不再需要该值时调用;可在此撤销租约或删除临时凭据。
Open 可以通过 resp.Private 传递字节;Renew 和 Close 会接收这些字节——可用它传递续期/撤销所需的租约 ID。
实现
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 中设置 resp.EphemeralResourceData = client,并与 ResourceData/DataSourceData 一并设置。
设计规则
- 绝不记录该值,也绝不将其写入诊断信息。 不持久化正是短暂资源的意义所在;错误消息中如果包含令牌,就违背了这一点。
- 仍应将密钥属性标记为
Sensitive: true——这可以避免在短暂值自身的生命周期输出中渲染该值。 - 不使用计划修饰器,不支持导入,也没有
id惯例——这些机制都没有状态可供操作。 - 架构输入遵循与数据源参数相同的规则;公开 API 的标识符(
role_name),而非自行发明的标识符。 - 设置
RenewAt时,应在实际过期时间之前留出安全余量;Terraform 采用延迟续期,而不是按精确计时器续期。 - 如果上游值无法撤销,请省略
Close,而不要实现一个暗示会执行撤销的空操作。
测试
短暂资源的结果不会进入状态,因此测试需间接验证这些结果——标准做法是通过 echoprovider 将短暂值回显到测试可以检查的常规资源中。最低限度的覆盖范围:基本的打开并使用测试,以及与必需字段一同进行的逐属性测试。请使用 provider-test-patterns 技能(如果可用)——其中的短暂资源测试参考涵盖了 echoprovider 设置、版本门控(tfversion.SkipBelow(tfversion.Version1_10_0))和多步骤模式。
文档
注册表文档位于 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 进行版本门控
- 文档页面说明有效期、续期和撤销行为