Skip to content
Open
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
224 changes: 224 additions & 0 deletions internal/services/logpush_job/migrations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package logpush_job_test

import (
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"

"github.com/cloudflare/terraform-provider-cloudflare/internal/acctest"
"github.com/cloudflare/terraform-provider-cloudflare/internal/utils"
)

// TestMigrateCloudflareLogpushJob_Migration_Basic_MultiVersion tests the most fundamental
// logpush job migration scenario with output_options block to attribute transformation.
// This test ensures that:
// 1. output_options block { ... } → output_options = { ... } (block to attribute syntax)
// 2. cve20214428 field is renamed to cve_2021_44228
// 3. kind = "instant-logs" is converted to kind = ""
// 4. Numeric fields are properly converted (max_upload_* fields)
// 5. The migration tool successfully transforms both configuration and state files
func TestMigrateCloudflareLogpushJob_Migration_Basic_MultiVersion(t *testing.T) {
testCases := []struct {
name string
version string
configFn func(accountID, rnd string) string
}{
{
name: "from_v4_52_1",
version: "4.52.1",
configFn: testAccCloudflareLogpushJobMigrationConfigV4Basic,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
accountID := acctest.TestAccCloudflareAccountID
rnd := utils.GenerateRandomResourceName()
resourceName := "cloudflare_logpush_job." + rnd
testConfig := tc.configFn(accountID, rnd)
tmpDir := t.TempDir()

resource.Test(t, resource.TestCase{
PreCheck: func() {
acctest.TestAccPreCheck(t)
acctest.TestAccPreCheck_AccountID(t)
},
WorkingDir: tmpDir,
Steps: []resource.TestStep{
{
// Step 1: Create logpush job with v4 provider
ExternalProviders: map[string]resource.ExternalProvider{
"cloudflare": {
VersionConstraint: tc.version,
Source: "cloudflare/cloudflare",
},
},
Config: testConfig,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("account_id"), knownvalue.StringExact(accountID)),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled"), knownvalue.Bool(true)),
},
},
// Step 2: Migrate to v5 provider
acctest.MigrationV2TestStep(t, testConfig, tmpDir, tc.version, "v4", "v5", []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("account_id"), knownvalue.StringExact(accountID)),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled"), knownvalue.Bool(true)),
}),
{
// Step 3: Apply migrated config with v5 provider
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
ConfigDirectory: config.StaticDirectory(tmpDir),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("account_id"), knownvalue.StringExact(accountID)),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled"), knownvalue.Bool(true)),
},
},
},
})
})
}
}

// TestMigrateCloudflareLogpushJob_Migration_OutputOptions tests migration of logpush jobs
// with output_options blocks. This test verifies that:
// 1. output_options block syntax is converted to attribute syntax with =
// 2. cve20214428 field is properly renamed to cve_2021_44228
// 3. All nested fields within output_options are preserved
// 4. State transformation converts array [{...}] to object {...}
func TestMigrateCloudflareLogpushJob_Migration_OutputOptions(t *testing.T) {
accountID := acctest.TestAccCloudflareAccountID
rnd := utils.GenerateRandomResourceName()
resourceName := "cloudflare_logpush_job." + rnd
v4Config := testAccCloudflareLogpushJobMigrationConfigV4OutputOptions(accountID, rnd)
tmpDir := t.TempDir()

resource.Test(t, resource.TestCase{
PreCheck: func() {
acctest.TestAccPreCheck(t)
acctest.TestAccPreCheck_AccountID(t)
},
WorkingDir: tmpDir,
Steps: []resource.TestStep{
{
// Step 1: Create logpush job with v4 provider
ExternalProviders: map[string]resource.ExternalProvider{
"cloudflare": {
VersionConstraint: "4.52.1",
Source: "cloudflare/cloudflare",
},
},
Config: v4Config,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
},
},
// Step 2: Migrate to v5 provider
acctest.MigrationV2TestStep(t, v4Config, tmpDir, "4.52.1", "v4", "v5", []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
}),
{
// Step 3: Apply migrated config with v5 provider
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
ConfigDirectory: config.StaticDirectory(tmpDir),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("dataset"), knownvalue.StringExact("audit_logs")),
},
},
},
})
}

// TestMigrateCloudflareLogpushJob_Migration_InstantLogs tests migration of logpush jobs
// with kind = "instant-logs" which needs to be converted to empty string in v5.
func TestMigrateCloudflareLogpushJob_Migration_InstantLogs(t *testing.T) {
zoneID := acctest.TestAccCloudflareZoneID
rnd := utils.GenerateRandomResourceName()
resourceName := "cloudflare_logpush_job." + rnd
v4Config := testAccCloudflareLogpushJobMigrationConfigV4InstantLogs(zoneID, rnd)
tmpDir := t.TempDir()

resource.Test(t, resource.TestCase{
PreCheck: func() {
acctest.TestAccPreCheck(t)
acctest.TestAccPreCheck_ZoneID(t)
},
WorkingDir: tmpDir,
Steps: []resource.TestStep{
{
// Step 1: Create logpush job with v4 provider
ExternalProviders: map[string]resource.ExternalProvider{
"cloudflare": {
VersionConstraint: "4.52.1",
Source: "cloudflare/cloudflare",
},
},
Config: v4Config,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("kind"), knownvalue.StringExact("instant-logs")),
},
},
// Step 2: Migrate to v5 provider
acctest.MigrationV2TestStep(t, v4Config, tmpDir, "4.52.1", "v4", "v5", []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("kind"), knownvalue.StringExact("")),
}),
{
// Step 3: Apply migrated config with v5 provider
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
ConfigDirectory: config.StaticDirectory(tmpDir),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("kind"), knownvalue.StringExact("")),
},
},
},
})
}

// V4 Configuration Functions

func testAccCloudflareLogpushJobMigrationConfigV4Basic(accountID, rnd string) string {
return fmt.Sprintf(`
resource "cloudflare_logpush_job" "%[2]s" {
account_id = "%[1]s"
dataset = "audit_logs"
destination_conf = "https://logpush-receiver.sd.cfplat.com"
enabled = true
}
`, accountID, rnd)
}

func testAccCloudflareLogpushJobMigrationConfigV4OutputOptions(accountID, rnd string) string {
return fmt.Sprintf(`
resource "cloudflare_logpush_job" "%[2]s" {
account_id = "%[1]s"
dataset = "audit_logs"
destination_conf = "https://logpush-receiver.sd.cfplat.com"
enabled = true

output_options {
cve20214428 = true
output_type = "ndjson"
field_names = ["ClientIP", "EdgeStartTimestamp"]
}
}
`, accountID, rnd)
}

func testAccCloudflareLogpushJobMigrationConfigV4InstantLogs(zoneID, rnd string) string {
return fmt.Sprintf(`
resource "cloudflare_logpush_job" "%[2]s" {
zone_id = "%[1]s"
dataset = "http_requests"
destination_conf = "https://logpush-receiver.sd.cfplat.com"
enabled = true
kind = "instant-logs"
}
`, zoneID, rnd)
}
24 changes: 24 additions & 0 deletions internal/services/logpush_job/resource.go
Copy link
Author

Choose a reason for hiding this comment

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

Updates are being made in resource.go to account for an update in kind in logpush_job resource. instant-logs was a valid kind option in v4, but is not valid in v5. The API was adding instant-logs back to the state even though it was not valid, so we explicitly ignore that as a value.

Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ func (r *LogpushJobResource) Update(ctx context.Context, req resource.UpdateRequ
return
}

// Handle kind field: treat "" and "instant-logs" as semantically equivalent
// The API doesn't allow changing kind, and "instant-logs" is deprecated in v5
// If both plan and state have semantically equivalent values, omit kind from the update
planKind := data.Kind.ValueString()
stateKind := state.Kind.ValueString()

// Treat "" and "instant-logs" as equivalent
if (planKind == "" || planKind == "instant-logs") && (stateKind == "" || stateKind == "instant-logs") {
// Make kind null so it won't be sent in the update at all
data.Kind = types.StringNull()
}

dataBytes, err := data.MarshalJSONForUpdate(*state)
if err != nil {
resp.Diagnostics.AddError("failed to serialize http request", err.Error())
Expand Down Expand Up @@ -153,6 +165,12 @@ func (r *LogpushJobResource) Update(ctx context.Context, req resource.UpdateRequ
}
data = &env.Result

// Normalize instant-logs to empty string (v5 no longer supports instant-logs as a valid value)
// The API may still return "instant-logs" for backwards compatibility, but we treat it as ""
if data.Kind.ValueString() == "instant-logs" {
data.Kind = types.StringValue("")
}

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

Expand Down Expand Up @@ -199,6 +217,12 @@ func (r *LogpushJobResource) Read(ctx context.Context, req resource.ReadRequest,
}
data = &env.Result

// Normalize instant-logs to empty string (v5 no longer supports instant-logs as a valid value)
// The API may still return "instant-logs" for backwards compatibility, but we treat it as ""
if data.Kind.ValueString() == "instant-logs" {
data.Kind = types.StringValue("")
}

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

Expand Down
37 changes: 30 additions & 7 deletions internal/services/logpush_job/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func testSweepCloudflareLogpushJob(r string) error {
ctx := context.Background()
client, clientErr := acctest.SharedV1Client()
if clientErr != nil {
fmt.Printf("Failed to create Cloudflare client: %s\n", clientErr)
tflog.Error(ctx, fmt.Sprintf("Failed to create Cloudflare client: %s", clientErr))
return clientErr
}
Expand All @@ -46,27 +47,49 @@ func testSweepCloudflareLogpushJob(r string) error {
return errors.New("CLOUDFLARE_ACCOUNT_ID must be set")
}

jobs, err := client.ListLogpushJobs(ctx, cfold.AccountIdentifier(accountID), cfold.ListLogpushJobsParams{})
zoneID := os.Getenv("CLOUDFLARE_ZONE_ID")
if zoneID == "" {
return errors.New("CLOUDFLARE_ZONE_ID must be set")
}

err := cleanLogpushJobs(ctx, client, cfold.AccountIdentifier(accountID))
if err != nil {
return err
}
err = cleanLogpushJobs(ctx, client, cfold.ZoneIdentifier(zoneID))
if err != nil {
return err
}

tflog.Debug(ctx, "[DEBUG] Logpush Job sweep complete")

return nil
}

func cleanLogpushJobs(ctx context.Context, client *cfold.API, resourceID *cfold.ResourceContainer) error {
resourceType := resourceID.Type.String()

tflog.Debug(ctx, fmt.Sprintf("Checking %s level jobs...", resourceType))
jobs, err := client.ListLogpushJobs(ctx, resourceID, cfold.ListLogpushJobsParams{})
if err != nil {
tflog.Error(ctx, fmt.Sprintf("Failed to fetch Cloudflare Logpush Jobs: %s", err))
tflog.Error(ctx, fmt.Sprintf("Failed to fetch Cloudflare Logpush Jobs for %s: %s", resourceID.Identifier, err))
return err
}

if len(jobs) == 0 {
tflog.Debug(ctx, "[DEBUG] No Cloudflare Logpush Jobs to sweep")
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] No Cloudflare Logpush Jobs to sweep for %s", resourceType))
return nil
}

tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Found %d Cloudflare Logpush Jobs to sweep", len(jobs)))
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Found %d Cloudflare %s-level Logpush Jobs to sweep.", len(jobs), resourceType))

// Track deletion results
deleted := 0
failed := 0

for _, job := range jobs {
tflog.Info(ctx, fmt.Sprintf("Deleting Cloudflare Logpush Job ID: %d, Name: %s", job.ID, job.Name))

err := client.DeleteLogpushJob(ctx, cfold.AccountIdentifier(accountID), job.ID)
err := client.DeleteLogpushJob(ctx, resourceID, job.ID)
if err != nil {
tflog.Error(ctx, fmt.Sprintf("Failed to delete Logpush Job %d (%s): %v", job.ID, job.Name, err))
failed++
Expand All @@ -77,7 +100,7 @@ func testSweepCloudflareLogpushJob(r string) error {
}
}

tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Logpush Job sweep completed: %d deleted, %d failed", deleted, failed))
tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Logpush %s Job sweep completed: %d deleted, %d failed", resourceType, deleted, failed))
return nil
}

Expand Down
Loading