Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ or investigating production regressions.
Examples worth consulting:

- Diff parity (`pkg/internal/tests/cross-tests/diff_cross_test.go`)
- Refresh regressions (`pkg/tests/refresh_cross_test.go`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: This line references pkg/tests/refresh_cross_test.go, but that file does not exist on main and is not among the files added by this PR. The existing file on main is pkg/tests/refresh_test.go. If the cross-test file is planned for a follow-up PR, please note that here; otherwise, point to the existing file to avoid misleading contributors.

- Create/Update flows (`pkg/tfbridge/tests/provider_test.go`)
- Provider configuration (`pkg/tfbridge/tests/provider_configure_test.go`, `pkg/pf/tests/provider_configure_test.go`)
- PF-specific diffing (`pkg/pf/tests/diff_test.go`)
Expand Down
1 change: 1 addition & 0 deletions pkg/internal/tests/cross-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Currently, SDKv2 cross-testing supports:

- [Create](./create.go)
- [Configure](./configure.go)
- [Refresh](./refresh.go)

Cross-tests can be written **either by** specify both the Terraform value *and* the Pulumi value **or** by
specifying *only* the Terraform value and letting the framework infer an equivalent Pulumi value.
Expand Down
215 changes: 215 additions & 0 deletions pkg/internal/tests/cross-tests/refresh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package crosstests

import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"unsafe"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/pulumi/providertest/providers"
"github.com/pulumi/providertest/pulumitest"
"github.com/pulumi/providertest/pulumitest/opttest"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil"
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
"github.com/stretchr/testify/require"
"github.com/zclconf/go-cty/cty"
"google.golang.org/grpc"

"github.com/pulumi/pulumi-terraform-bridge/v3/internal/logging"
crosstestsimpl "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/tests/cross-tests/impl"
"github.com/pulumi/pulumi-terraform-bridge/v3/pkg/internal/tests/pulcheck"
"github.com/pulumi/pulumi-terraform-bridge/v3/pkg/providerserver"
"github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge/info"
shimv2 "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim/sdk-v2"
)

// RefreshResult captures the outcome of refreshing the same resource through Terraform and Pulumi.
//
// Today this helper focuses on refresh success or failure parity and returns the raw errors so
// regression tests can assert on implementation-specific details when needed.
type RefreshResult struct {
TFRefreshErr error
PulumiRefreshErr error
PulumiRefreshResult auto.RefreshResult
}

type refreshOpts struct {
resourceInfo *info.Resource
puConfig *resource.PropertyMap
recoverReadPanics bool
skipParityCheck bool
}

// A RefreshOption customizes [Refresh].
type RefreshOption func(*refreshOpts)

// RefreshResourceInfo specifies an [info.Resource] to apply to the resource under test.
func RefreshResourceInfo(info info.Resource) RefreshOption {
return func(o *refreshOpts) { o.resourceInfo = &info }
}

// RefreshPulumiConfig specifies an explicit config value in Pulumi's value space.
func RefreshPulumiConfig(config resource.PropertyMap) RefreshOption {
return func(o *refreshOpts) { o.puConfig = &config }
}

// RefreshRecoverReadPanics converts Pulumi provider panics during Read into refresh errors.
//
// This is useful for regression tests that need to assert on the refresh failure instead of
// crashing the entire test process.
func RefreshRecoverReadPanics() RefreshOption {
return func(o *refreshOpts) { o.recoverReadPanics = true }
}

// RefreshSkipParityCheck disables the default success/failure parity assertion.
func RefreshSkipParityCheck() RefreshOption {
return func(o *refreshOpts) { o.skipParityCheck = true }
}

// Refresh validates refresh behavior for the same resource under Terraform CLI and Pulumi CLI.
//
// It provisions the resource once through each CLI, then runs refresh and compares whether the
// operation succeeded. Callers can opt out of the parity assertion and inspect the returned errors
// directly for regression tests that intentionally capture a mismatch.
func Refresh(t T, resourceUnderTest *schema.Resource, tfConfig cty.Value, options ...RefreshOption) RefreshResult {
var opts refreshOpts
for _, f := range options {
f(&opts)
}

var puConfig resource.PropertyMap
if opts.puConfig != nil {
puConfig = *opts.puConfig
} else {
puConfig = crosstestsimpl.InferPulumiValue(t,
shimv2.NewSchemaMap(resourceUnderTest.Schema),
opts.resourceInfo.GetFields(),
tfConfig,
)
}

tfwd := t.TempDir()
tfd := newTFResDriver(t, tfwd, defProviderShortName, defRtype, resourceUnderTest)
tfd.writePlanApply(t, resourceUnderTest.Schema, defRtype, "example", tfConfig, lifecycleArgs{})
tfErr := tfd.refreshErr(t, resourceUnderTest.Schema, defRtype, "example", tfConfig, lifecycleArgs{})

bridgedProvider := pulcheck.BridgedProvider(
t, defProviderShortName,
&schema.Provider{ResourcesMap: map[string]*schema.Resource{defRtype: resourceUnderTest}},
pulcheck.WithResourceInfo(map[string]*info.Resource{defRtype: opts.resourceInfo}),
)
pd := &pulumiDriver{
name: defProviderShortName,
pulumiResourceToken: defRtoken,
tfResourceName: defRtype,
}
yamlProgram := pd.generateYAML(t, puConfig)

pt := pulcheck.PulCheck(t, bridgedProvider, string(yamlProgram))
if opts.recoverReadPanics {
pt = pulCheckRecoveringReadPanics(t, bridgedProvider, string(yamlProgram))
}

pt.Up(t)
pulumiRes, pulumiErr := pt.CurrentStack().Refresh(pt.Context())
t.Logf("pulumi refresh stdout:\n%s", pulumiRes.StdOut)
t.Logf("pulumi refresh stderr:\n%s", pulumiRes.StdErr)

if !opts.skipParityCheck {
require.Equalf(t, tfErr == nil, pulumiErr == nil,
"terraform refresh error = %v, pulumi refresh error = %v\npulumi stdout:\n%s\npulumi stderr:\n%s",
tfErr, pulumiErr, pulumiRes.StdOut, pulumiRes.StdErr)
}

return RefreshResult{
TFRefreshErr: tfErr,
PulumiRefreshErr: pulumiErr,
PulumiRefreshResult: pulumiRes,
}
}

func pulCheckRecoveringReadPanics(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: pulCheckRecoveringReadPanics is intended as a drop-in replacement for pulcheck.PulCheck when panic recovery is needed, but it is missing the skipUnlessLinux(t) call that pulcheck.PulCheck makes. On a non-Linux CI run, the normal Refresh path (when recoverReadPanics is false) will be skipped, but a call with RefreshRecoverReadPanics() will not be skipped, causing asymmetric test execution. Adding skipUnlessLinux(t) as the first statement (or a call to pulcheck.PulCheck with an option to customise the server) would close this gap.

t T,
bridgedProvider info.Provider,
program string,
) *pulumitest.PulumiTest {
puwd := t.TempDir()
program = strings.ReplaceAll(program, "\t", " ")
err := os.WriteFile(filepath.Join(puwd, "Pulumi.yaml"), []byte(program), 0o600)
require.NoError(t, err)

return pulumitest.NewPulumiTest(t, puwd,
opttest.Env("PULUMI_DISABLE_AUTOMATIC_PLUGIN_ACQUISITION", "true"),
opttest.TestInPlace(),
opttest.SkipInstall(),
opttest.AttachProvider(
bridgedProvider.Name,
func(ctx context.Context, pt providers.PulumiTest) (providers.Port, error) {
prov, err := pulcheck.ProviderServerFromInfo(ctx, bridgedProvider)
if err != nil {
return 0, err
}
prepareProviderForRecoveredReadPanics(t, prov)

handle, err := rpcutil.ServeWithOptions(rpcutil.ServeOptions{
Init: func(srv *grpc.Server) error {
pulumirpc.RegisterResourceProviderServer(srv, &recoveringReadPanicsServer{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: pulcheck.ProviderServerFromInfo already returns a *providerserver.PanicRecoveringProviderServer, which wraps every method (including Read) with its own defer/recover. Registering &recoveringReadPanicsServer{ResourceProviderServer: prov} around it means a Read panic would be caught by the inner wrapper and turned into a gRPC error long before the outer defer/recover in recoveringReadPanicsServer.Read fires. The outer wrapper appears redundant. If there is a path where a panic can escape the inner server, that should be explained in a comment; otherwise the outer layer can be removed.

ResourceProviderServer: prov,
})
return nil
},
})
if err != nil {
return 0, err
}
return providers.Port(handle.Port), nil
},
),
)
}

type recoveringReadPanicsServer struct {
pulumirpc.ResourceProviderServer
}

func (r *recoveringReadPanicsServer) Read(
ctx context.Context,
req *pulumirpc.ReadRequest,
) (resp *pulumirpc.ReadResponse, err error) {
defer func() {
if panicValue := recover(); panicValue != nil {
err = fmt.Errorf("recovered provider panic: %v", panicValue)
}
}()
return r.ResourceProviderServer.Read(ctx, req)
}

func prepareProviderForRecoveredReadPanics(t T, server pulumirpc.ResourceProviderServer) {
t.Helper()

wrapped, ok := server.(*providerserver.PanicRecoveringProviderServer)
require.True(t, ok, "expected panic-recovering provider server")

setPanicRecoveringProviderServerField(t, wrapped, "logger", logging.NewDiscardSink())
setPanicRecoveringProviderServerField(t, wrapped, "omitStackTraces", true)
}

func setPanicRecoveringProviderServerField(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: setPanicRecoveringProviderServerField (and prepareProviderForRecoveredReadPanics) use unsafe.Pointer + reflect.NewAt to write to unexported fields of PanicRecoveringProviderServer. This creates a hidden compile-time-invisible coupling: renaming either logger or omitStackTraces would cause a silent runtime panic rather than a build failure. Consider surfacing a testing-only constructor or option on PanicRecoveringProviderServer (e.g., NewForTest(inner, logger, omitStackTraces)) to make this dependency explicit and statically checked. The same pattern is duplicated in pkg/tests/update_cross_test.go; centralising it would halve the surface area that needs updating when the struct changes.

t T,
server *providerserver.PanicRecoveringProviderServer,
fieldName string,
value any,
) {
t.Helper()

field := reflect.ValueOf(server).Elem().FieldByName(fieldName)
require.True(t, field.IsValid(), "missing field %q", fieldName)

reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
}
Loading
Loading