diff --git a/provider/provider_program_test.go b/provider/provider_program_test.go index 192e94d65..76e457be3 100644 --- a/provider/provider_program_test.go +++ b/provider/provider_program_test.go @@ -6,7 +6,9 @@ package cloudflare import ( "context" "encoding/json" + "fmt" "os" + "strings" "testing" _ "embed" @@ -88,6 +90,105 @@ func testProgram(t *testing.T, dir string, opts ...opttest.Option) *pulumitest.P return pt } +func testProgramNoCloudflareConfig(t *testing.T, dir string, opts ...opttest.Option) *pulumitest.PulumiTest { + rpFactory := providers.ResourceProviderFactory(providerFactory) + opts = append(opts, opttest.AttachProvider(providerName, rpFactory), opttest.SkipInstall()) + return pulumitest.NewPulumiTest(t, dir, opts...) +} + +func pulumiCommandEnv(pt *pulumitest.PulumiTest) []string { + workspace := pt.CurrentStack().Workspace() + env := []string{"PULUMI_DEBUG_COMMANDS=true"} + if pulumiHome := workspace.PulumiHome(); pulumiHome != "" { + env = append(env, "PULUMI_HOME="+pulumiHome) + } + for k, v := range workspace.GetEnvVars() { + env = append(env, strings.Join([]string{k, v}, "=")) + } + return env +} + +func importStackWithDisabledIntegrity(t *testing.T, pt *pulumitest.PulumiTest, source apitype.UntypedDeployment) { + t.Helper() + stack := pt.CurrentStack() + require.NotNil(t, stack) + + f, err := os.CreateTemp(t.TempDir(), "stack-*.json") + require.NoError(t, err) + defer func() { require.NoError(t, f.Close()) }() + + require.NoError(t, json.NewEncoder(f).Encode(source)) + + workspace := stack.Workspace() + stdout, stderr, _, err := workspace.PulumiCommand().Run( + pt.Context(), + workspace.WorkDir(), + nil, + nil, + nil, + pulumiCommandEnv(pt), + "--disable-integrity-checking", + "stack", + "import", + "--file", + f.Name(), + "--stack", + stack.Name(), + ) + require.NoError(t, err, fmt.Sprintf("stdout:\n%s\nstderr:\n%s", stdout, stderr)) +} + +func previewWithDisabledIntegrity(t *testing.T, pt *pulumitest.PulumiTest) (string, string, error) { + t.Helper() + stack := pt.CurrentStack() + require.NotNil(t, stack) + + workspace := stack.Workspace() + stdout, stderr, _, err := workspace.PulumiCommand().Run( + pt.Context(), + workspace.WorkDir(), + nil, + nil, + nil, + pulumiCommandEnv(pt), + "--disable-integrity-checking", + "preview", + "--non-interactive", + "--diff", + "--stack", + stack.Name(), + ) + return stdout, stderr, err +} + +func withArgoTieredCachingSchemaVersion( + t *testing.T, source apitype.UntypedDeployment, version string, +) apitype.UntypedDeployment { + t.Helper() + var deployment map[string]interface{} + require.NoError(t, json.Unmarshal(source.Deployment, &deployment)) + resources, ok := deployment["resources"].([]interface{}) + require.True(t, ok) + found := false + for _, rawResource := range resources { + res, ok := rawResource.(map[string]interface{}) + require.True(t, ok) + if res["type"] != "cloudflare:index/argoTieredCaching:ArgoTieredCaching" { + continue + } + found = true + outputs, ok := res["outputs"].(map[string]interface{}) + require.True(t, ok) + outputs["__meta"] = fmt.Sprintf(`{"schema_version":"%s"}`, version) + break + } + require.True(t, found, "did not find ArgoTieredCaching resource in test state") + updatedDeployment, err := json.Marshal(deployment) + require.NoError(t, err) + source.Deployment = updatedDeployment + return source +} + func testUpgrade( t *testing.T, dir1 string, opts ...optproviderupgrade.PreviewProviderUpgradeOpt, ) auto.PreviewResult { @@ -152,6 +253,29 @@ func TestZeroTrustAccessApplicationFromState(t *testing.T) { pt.Preview(t) } +func TestArgoTieredCachingFromV612State(t *testing.T) { + state, err := os.ReadFile("testdata/argo_tiered_caching_state_v6_12.json") + require.NoError(t, err) + depl := apitype.UntypedDeployment{} + require.NoError(t, json.Unmarshal(state, &depl)) + + t.Run("direct upgrade skips legacy argo migration for Pulumi state", func(t *testing.T) { + pt := testProgramNoCloudflareConfig(t, "test-programs/argo_tiered_caching_state", + opttest.NewStackOptions(optnewstack.DisableAutoDestroy())) + importStackWithDisabledIntegrity(t, pt, depl) + stdout, stderr, err := previewWithDisabledIntegrity(t, pt) + require.NoError(t, err, fmt.Sprintf("stdout:\n%s\nstderr:\n%s", stdout, stderr)) + }) + + t.Run("schema version bump avoids legacy argo migration", func(t *testing.T) { + pt := testProgramNoCloudflareConfig(t, "test-programs/argo_tiered_caching_state", + opttest.NewStackOptions(optnewstack.DisableAutoDestroy())) + importStackWithDisabledIntegrity(t, pt, withArgoTieredCachingSchemaVersion(t, depl, "500")) + stdout, stderr, err := previewWithDisabledIntegrity(t, pt) + require.NoError(t, err, fmt.Sprintf("stdout:\n%s\nstderr:\n%s", stdout, stderr)) + }) +} + func TestRuleSetHeadersUpgrade(t *testing.T) { testUpgrade( t, "test-programs/ruleset_headers/ruleset_headers_v5", diff --git a/provider/resources.go b/provider/resources.go index 8fcb8446f..2be7dabb8 100644 --- a/provider/resources.go +++ b/provider/resources.go @@ -125,6 +125,10 @@ func Provider() info.Provider { ComputeID: delegateID("imageId"), }, + "cloudflare_argo_tiered_caching": { + PreStateUpgradeHook: argoTieredCachingPreStateUpgradeHook, + }, + "cloudflare_ruleset": { Tok: "cloudflare:index/ruleset:Ruleset", PreStateUpgradeHook: func( @@ -792,6 +796,37 @@ func resetMigratedResourcesSchemaVersion(prov *info.Provider) { } } +// argoTieredCachingPreStateUpgradeHook handles Pulumi state written before the +// upstream resource had a current schema marker. Upstream now treats schema +// version 0 as legacy Terraform cloudflare_argo state and expects +// tiered_caching, but old Pulumi ArgoTieredCaching state is already shaped like +// the current resource. Mark that Pulumi-shaped state as version 500 so the +// wrong legacy migration is skipped. +func argoTieredCachingPreStateUpgradeHook( + args info.PreStateUpgradeHookArgs, +) (int64, resource.PropertyMap, error) { + if args.PriorStateSchemaVersion == 0 && isPulumiArgoTieredCachingState(args.PriorState) { + return 500, args.PriorState, nil + } + return args.PriorStateSchemaVersion, args.PriorState, nil +} + +// isPulumiArgoTieredCachingState narrowly identifies old Pulumi-created +// cloudflare_argo_tiered_caching state. The camelCase zoneId key distinguishes +// it from Terraform's legacy snake_case cloudflare_argo state, and the absence +// of tiered_caching keeps the real legacy migration path intact. +func isPulumiArgoTieredCachingState(state resource.PropertyMap) bool { + value, hasValue := state["value"] + zoneID, hasZoneID := state["zoneId"] + _, hasTerraformTieredCaching := state["tiered_caching"] + _, hasPulumiTieredCaching := state["tieredCaching"] + + return hasValue && value.IsString() && + hasZoneID && zoneID.IsString() && + !hasTerraformTieredCaching && + !hasPulumiTieredCaching +} + func delegateID(pulumiField resource.PropertyKey) tfbridge.ComputeID { repoURL := "https://github.com/pulumi/pulumi-cloudflare" d := tfbridge.DelegateIDField(pulumiField, "cloudflare", repoURL) diff --git a/provider/resources_test.go b/provider/resources_test.go index 090fca9e7..943ed94d5 100644 --- a/provider/resources_test.go +++ b/provider/resources_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfbridge" shim "github.com/pulumi/pulumi-terraform-bridge/v3/pkg/tfshim" "github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" @@ -80,6 +81,44 @@ func TestZeroTrustAccessApplicationVersionReminder(t *testing.T) { "custom Pulumi PreStateUpgradeHook needs to be revisited or possibly dropped") } +func TestArgoTieredCachingVersionReminder(t *testing.T) { + version.Version = "0.0.4" + p := Provider() + r := p.P.ResourcesMap().Get("cloudflare_argo_tiered_caching") + // See https://github.com/pulumi/pulumi-cloudflare/issues/1575 + assert.Equalf(t, 500, r.SchemaVersion(), + "Reminder: cloudflare_argo_tiered_caching advanced schema version from 500 and "+ + "custom Pulumi PreStateUpgradeHook needs to be revisited or possibly dropped") +} + +func TestArgoTieredCachingPreStateUpgradeHook(t *testing.T) { + pulumiState := resource.PropertyMap{ + "value": resource.NewStringProperty("on"), + "zoneId": resource.NewStringProperty("00000000000000000000000000000000"), + } + version, state, err := argoTieredCachingPreStateUpgradeHook( + tfbridge.PreStateUpgradeHookArgs{ + PriorStateSchemaVersion: 0, + PriorState: pulumiState, + }) + require.NoError(t, err) + assert.Equal(t, int64(500), version) + assert.Equal(t, pulumiState, state) + + legacyArgoState := resource.PropertyMap{ + "tiered_caching": resource.NewStringProperty("on"), + "zone_id": resource.NewStringProperty("00000000000000000000000000000000"), + } + version, state, err = argoTieredCachingPreStateUpgradeHook( + tfbridge.PreStateUpgradeHookArgs{ + PriorStateSchemaVersion: 0, + PriorState: legacyArgoState, + }) + require.NoError(t, err) + assert.Equal(t, int64(0), version) + assert.Equal(t, legacyArgoState, state) +} + func Test_delegateID(t *testing.T) { type testCase struct { diff --git a/provider/test-programs/argo_tiered_caching_state/Pulumi.yaml b/provider/test-programs/argo_tiered_caching_state/Pulumi.yaml new file mode 100644 index 000000000..cacc3732d --- /dev/null +++ b/provider/test-programs/argo_tiered_caching_state/Pulumi.yaml @@ -0,0 +1,9 @@ +name: argo-tiered-caching-state +runtime: yaml + +resources: + argo: + type: cloudflare:ArgoTieredCaching + properties: + zoneId: 00000000000000000000000000000000 + value: "on" diff --git a/provider/testdata/argo_tiered_caching_state_v6_12.json b/provider/testdata/argo_tiered_caching_state_v6_12.json new file mode 100644 index 000000000..588c704c1 --- /dev/null +++ b/provider/testdata/argo_tiered_caching_state_v6_12.json @@ -0,0 +1,67 @@ +{ + "version": 3, + "deployment": { + "manifest": { + "time": "2026-05-18T00:00:00Z", + "magic": "0000000000000000000000000000000000000000000000000000000000000000", + "version": "v3.228.0" + }, + "secrets_providers": { + "type": "passphrase", + "state": { + "salt": "v1:O1t5suqMRIo=:v1:7DqVMCoY+uij+EZC:Bc3SzKdajmbRZU5OXZWfP0oEcpUIJw==" + } + }, + "resources": [ + { + "urn": "urn:pulumi:test::argo-tiered-caching-state::pulumi:pulumi:Stack::argo-tiered-caching-state-test", + "custom": false, + "type": "pulumi:pulumi:Stack", + "outputs": {} + }, + { + "urn": "urn:pulumi:test::argo-tiered-caching-state::pulumi:providers:cloudflare::default", + "custom": true, + "id": "00000000-0000-0000-0000-000000000001", + "type": "pulumi:providers:cloudflare", + "inputs": { + "apiClientLogging": "false", + "maxBackoff": "30", + "minBackoff": "1", + "retries": "3", + "rps": "4" + }, + "outputs": { + "apiClientLogging": "false", + "maxBackoff": "30", + "minBackoff": "1", + "retries": "3", + "rps": "4" + } + }, + { + "urn": "urn:pulumi:test::argo-tiered-caching-state::cloudflare:index/argoTieredCaching:ArgoTieredCaching::argo", + "custom": true, + "id": "00000000000000000000000000000000", + "type": "cloudflare:index/argoTieredCaching:ArgoTieredCaching", + "inputs": { + "value": "on", + "zoneId": "00000000000000000000000000000000" + }, + "outputs": { + "editable": false, + "id": "00000000000000000000000000000000", + "modifiedOn": "2025-08-15T09:49:33Z", + "value": "on", + "zoneId": "00000000000000000000000000000000" + }, + "parent": "urn:pulumi:test::argo-tiered-caching-state::pulumi:pulumi:Stack::argo-tiered-caching-state-test", + "provider": "urn:pulumi:test::argo-tiered-caching-state::pulumi:providers:cloudflare::default::00000000-0000-0000-0000-000000000001", + "propertyDependencies": { + "value": null, + "zoneId": null + } + } + ] + } +}