From fcd3be88d9fc7edb9ea06ac9580265ce118956a6 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Fri, 16 Jan 2026 17:02:23 -0500 Subject: [PATCH 1/4] Validate obj regions in obj bucket resource --- linode/objbucket/helpers.go | 18 ++++++++++++++++++ linode/objbucket/resource.go | 30 ++++++++++++++++++++++++++++++ linode/objbucket/resource_test.go | 19 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/linode/objbucket/helpers.go b/linode/objbucket/helpers.go index 6bee89ef1..599ea0860 100644 --- a/linode/objbucket/helpers.go +++ b/linode/objbucket/helpers.go @@ -2,6 +2,7 @@ package objbucket import ( "context" + "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/linode/linodego" @@ -23,3 +24,20 @@ func getS3Endpoint(ctx context.Context, bucket linodego.ObjectStorageBucket) (en } return endpoint } + +func validateRegion(ctx context.Context, region string, client *linodego.Client) (valid bool, suggestedRegions []string, err error) { + endpoints, err := client.ListObjectStorageEndpoints(ctx, nil) + if err != nil { + return false, nil, err + } + + for _, endpoint := range endpoints { + if endpoint.Region == region { + return true, nil, nil + } else if endpoint.S3Endpoint != nil && strings.Contains(*endpoint.S3Endpoint, region) { + suggestedRegions = append(suggestedRegions, endpoint.Region) + } + } + + return false, suggestedRegions, nil +} diff --git a/linode/objbucket/resource.go b/linode/objbucket/resource.go index 550d00ed2..6e1db9399 100644 --- a/linode/objbucket/resource.go +++ b/linode/objbucket/resource.go @@ -146,6 +146,21 @@ func createResource( tflog.Debug(ctx, "Create linode_object_storage_bucket") client := meta.(*helper.ProviderMeta).Client + if region, ok := d.GetOk("region"); ok { + valid, suggestedRegions, err := validateRegion(ctx, region.(string), &client) + if err != nil { + return diag.FromErr(err) + } + + if !valid { + errorMsg := fmt.Sprintf("Region '%s' is not valid for Object Storage.", region.(string)) + if len(suggestedRegions) > 0 { + errorMsg += fmt.Sprintf(" Suggested regions: %s", strings.Join(suggestedRegions, ", ")) + } + return diag.Errorf("%s", errorMsg) + } + } + label := d.Get("label").(string) acl := d.Get("acl").(string) @@ -202,6 +217,21 @@ func updateResource( tflog.Debug(ctx, "Update linode_object_storage_bucket") client := meta.(*helper.ProviderMeta).Client + if region, ok := d.GetOk("region"); ok { + valid, suggestedRegions, err := validateRegion(ctx, region.(string), &client) + if err != nil { + return diag.FromErr(err) + } + + if !valid { + errorMsg := fmt.Sprintf("Region '%s' is not valid for Object Storage.", region.(string)) + if len(suggestedRegions) > 0 { + errorMsg += fmt.Sprintf(" Suggested regions: %s", strings.Join(suggestedRegions, ", ")) + } + return diag.Errorf("%s", errorMsg) + } + } + if d.HasChanges("acl", "cors_enabled") { tflog.Debug(ctx, "'acl' changes detected, will update bucket access") if err := updateBucketAccess(ctx, d, client); err != nil { diff --git a/linode/objbucket/resource_test.go b/linode/objbucket/resource_test.go index 3ac05f9b1..e2a959ba0 100644 --- a/linode/objbucket/resource_test.go +++ b/linode/objbucket/resource_test.go @@ -720,6 +720,25 @@ func TestAccResourceBucket_forceDelete(t *testing.T) { }) } +func TestAccResourceBucket_invalid_region(t *testing.T) { + t.Parallel() + + label := "tf-acc-bucket-invalid-region-" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + invalidRegion := "us-mia-1" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkBucketDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, label, invalidRegion), + ExpectError: regexp.MustCompile(fmt.Sprintf("Region '%s' is not valid for Object Storage", invalidRegion)), + }, + }, + }) +} + func checkBucketExists(s *terraform.State) error { client := acceptance.TestAccSDKv2Provider.Meta().(*helper.ProviderMeta).Client From 99ef27262a00997f0dfa0dc38ddc377589302386 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Tue, 20 Jan 2026 13:34:36 -0500 Subject: [PATCH 2/4] Remove isCluster --- linode/helper/objects.go | 5 +++-- linode/obj/framework_models.go | 19 ++++++++++++++++--- linode/obj/framework_resource.go | 25 +++++++++++++++++++------ linode/obj/helpers.go | 22 ---------------------- linode/objbucket/resource.go | 6 +++--- 5 files changed, 41 insertions(+), 36 deletions(-) diff --git a/linode/helper/objects.go b/linode/helper/objects.go index f769889e9..7a56b19f4 100644 --- a/linode/helper/objects.go +++ b/linode/helper/objects.go @@ -18,10 +18,11 @@ import ( "github.com/linode/linodego" ) -func GetRegionOrCluster(d *schema.ResourceData) (regionOrCluster string) { +func GetRegionOrCluster(ctx context.Context, d *schema.ResourceData) (regionOrCluster string) { if region, ok := d.GetOk("region"); ok && region != "" { regionOrCluster = region.(string) } else { + tflog.Warn(ctx, "Cluster is deprecated for Linode Object Storage services, please consider switch to using region.") regionOrCluster = d.Get("cluster").(string) } return regionOrCluster @@ -79,7 +80,7 @@ func S3ConnectionFromData( func ComputeS3Endpoint(ctx context.Context, d *schema.ResourceData, meta interface{}) (string, error) { tflog.Debug(ctx, "Getting Object Storage bucket from resource data") - regionOrCluster := GetRegionOrCluster(d) + regionOrCluster := GetRegionOrCluster(ctx, d) bucketLabel := d.Get("label").(string) b, err := meta.(*ProviderMeta).Client.GetObjectStorageBucket(ctx, regionOrCluster, bucketLabel) diff --git a/linode/obj/framework_models.go b/linode/obj/framework_models.go index 429f50e51..ea547336b 100644 --- a/linode/obj/framework_models.go +++ b/linode/obj/framework_models.go @@ -100,7 +100,12 @@ func (data ResourceModel) GetObjectStorageKeys( } if config.ObjUseTempKeys.ValueBool() { - objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), data.GetRegionOrCluster(ctx), permissions, nil, diags) + clusterOrRegion := data.GetRegionOrCluster(ctx, diags) + if diags.HasError() { + return nil, nil + } + + objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), clusterOrRegion, permissions, nil, diags) if diags.HasError() { return nil, nil } @@ -129,7 +134,10 @@ func (plan *ResourceModel) ComputeEndpointIfUnknown(ctx context.Context, client } bucketName := plan.Bucket.ValueString() - regionOrCluster := plan.GetRegionOrCluster(ctx) + regionOrCluster := plan.GetRegionOrCluster(ctx, diags) + if diags.HasError() { + return + } bucket, err := client.GetObjectStorageBucket(ctx, regionOrCluster, bucketName) if err != nil { @@ -153,9 +161,14 @@ func (data *ResourceModel) GenerateObjectStorageObjectID(apply bool, preserveKno return id } -func (data ResourceModel) GetRegionOrCluster(ctx context.Context) string { +func (data ResourceModel) GetRegionOrCluster(ctx context.Context, diags *diag.Diagnostics) string { if !data.Region.IsNull() && !data.Region.IsUnknown() { return data.Region.ValueString() + } else { + diags.AddWarning( + "Cluster is Deprecated", + "Cluster is deprecated for Linode Object Storage services, please consider switch to using region.", + ) } return data.Cluster.ValueString() diff --git a/linode/obj/framework_resource.go b/linode/obj/framework_resource.go index 87b010ac8..6619a5432 100644 --- a/linode/obj/framework_resource.go +++ b/linode/obj/framework_resource.go @@ -50,7 +50,10 @@ func (r *Resource) Create( return } - ctx = populateLogAttributes(ctx, plan) + ctx = populateLogAttributes(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } plan.ComputeEndpointIfUnknown(ctx, client, &resp.Diagnostics) @@ -137,7 +140,10 @@ func (r *Resource) Read( return } - ctx = populateLogAttributes(ctx, state) + ctx = populateLogAttributes(ctx, state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } // TODO: cleanup when Crossplane fixes it if helper.FrameworkAttemptRemoveResourceForEmptyID(ctx, state.ID, resp) { @@ -189,7 +195,11 @@ func (r *Resource) Update( return } - ctx = populateLogAttributes(ctx, state) + ctx = populateLogAttributes(ctx, state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + client := r.Meta.Client config := r.Meta.Config @@ -250,7 +260,10 @@ func (r *Resource) Delete( return } - ctx = populateLogAttributes(ctx, state) + ctx = populateLogAttributes(ctx, state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } client := r.Meta.Client config := r.Meta.Config @@ -291,10 +304,10 @@ func (r *Resource) Delete( } } -func populateLogAttributes(ctx context.Context, model ResourceModel) context.Context { +func populateLogAttributes(ctx context.Context, model ResourceModel, diags *diag.Diagnostics) context.Context { return helper.SetLogFieldBulk(ctx, map[string]any{ "bucket": model.Bucket.ValueString(), - "region_or_cluster": model.GetRegionOrCluster(ctx), + "region_or_cluster": model.GetRegionOrCluster(ctx, diags), "object_key": model.Key.ValueString(), }) } diff --git a/linode/obj/helpers.go b/linode/obj/helpers.go index 72dca584b..48783da53 100644 --- a/linode/obj/helpers.go +++ b/linode/obj/helpers.go @@ -6,7 +6,6 @@ import ( "fmt" "hash/crc32" "io" - "regexp" "slices" "strings" "time" @@ -71,12 +70,6 @@ func getObjKeysFromProvider( return keys, keys.Ok() } -func isCluster(regionOrCluster string) bool { - pattern := `^[a-z]{2}-[a-z]+-[0-9]+$` - re := regexp.MustCompile(pattern) - return re.MatchString(regionOrCluster) -} - // fwCreateTempKeys creates temporary Object Storage Keys to use. // The temporary keys are scoped only to the target cluster and bucket with limited permissions. // Keys only exist for the duration of the apply time. @@ -94,14 +87,6 @@ func fwCreateTempKeys( Permissions: permissions, } - if isCluster(regionOrCluster) { - tflog.Warn(ctx, "Cluster is deprecated for Linode Object Storage service, please consider switch to using region.") - tempBucketAccess.Cluster = regionOrCluster - } else { - tflog.Info(ctx, fmt.Sprintf("%q Is Region", regionOrCluster)) - tempBucketAccess.Region = regionOrCluster - } - createOpts := linodego.ObjectStorageKeyCreateOptions{ Label: fmt.Sprintf("temp_%s_%v", bucketLabel, time.Now().Unix()), BucketAccess: &[]linodego.ObjectStorageKeyBucketAccess{tempBucketAccess}, @@ -165,13 +150,6 @@ func createTempKeys( Permissions: permissions, } - if isCluster(regionOrCluster) { - tflog.Warn(ctx, "Cluster is deprecated for Linode Object Storage service, please consider switch to using region.") - tempBucketAccess.Cluster = regionOrCluster - } else { - tempBucketAccess.Region = regionOrCluster - } - // Bucket key labels are a maximum of 50 characters - if the bucket name is // too long, then truncate it. // We use 16 characters for `temp__{timestamp}`, so the maximum length of a diff --git a/linode/objbucket/resource.go b/linode/objbucket/resource.go index 6e1db9399..2ae0132fb 100644 --- a/linode/objbucket/resource.go +++ b/linode/objbucket/resource.go @@ -256,7 +256,7 @@ func updateResource( }) config := meta.(*helper.ProviderMeta).Config - regionOrCluster := helper.GetRegionOrCluster(d) + regionOrCluster := helper.GetRegionOrCluster(ctx, d) bucketLabel := d.Get("label").(string) @@ -472,7 +472,7 @@ func updateBucketAccess( ctx context.Context, d *schema.ResourceData, client linodego.Client, ) error { tflog.Debug(ctx, "entering updateBucketAccess") - regionOrCluster := helper.GetRegionOrCluster(d) + regionOrCluster := helper.GetRegionOrCluster(ctx, d) label := d.Get("label").(string) updateOpts := linodego.ObjectStorageBucketUpdateAccessOptions{} @@ -496,7 +496,7 @@ func updateBucketCert( ctx context.Context, d *schema.ResourceData, client linodego.Client, ) error { tflog.Debug(ctx, "entering updateBucketCert") - regionOrCluster := helper.GetRegionOrCluster(d) + regionOrCluster := helper.GetRegionOrCluster(ctx, d) label := d.Get("label").(string) oldCert, newCert := d.GetChange("cert") hasOldCert := len(oldCert.([]any)) != 0 From 96313efcb3de97bf6ceffe9a7592e554ac5e28a4 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Wed, 28 Jan 2026 16:03:48 -0500 Subject: [PATCH 3/4] Refactor and add BucketAccessor in preparation of framework migration for bucket --- linode/obj/bucket_accessor.go | 80 +++++++++++++++++++ linode/obj/framework_models.go | 68 +++------------- linode/obj/framework_resource.go | 2 +- linode/obj/helpers.go | 2 +- .../objbucket/framework_datasource_schema.go | 26 +++--- linode/objbucket/framework_models.go | 15 ++++ 6 files changed, 123 insertions(+), 70 deletions(-) create mode 100644 linode/obj/bucket_accessor.go diff --git a/linode/obj/bucket_accessor.go b/linode/obj/bucket_accessor.go new file mode 100644 index 000000000..785f107b2 --- /dev/null +++ b/linode/obj/bucket_accessor.go @@ -0,0 +1,80 @@ +package obj + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +// BucketAccessor provides the minimal information needed to resolve Object Storage +// access keys for an operation. +// +// Implementations typically come from either a resource plan/state model or a +// data model that can supply: +// - explicit access keys on the resource, or +// - a bucket label + region/cluster so temporary keys can be created. +type BucketAccessor interface { + ObjectStorageKeys() ObjectKeys + BucketLabel() string + RegionOrCluster(context.Context, *diag.Diagnostics) string +} + +// GetObjectStorageKeys resolves Object Storage access keys used by OBJ resources. +// +// Resolution order: +// 1. Keys specified on the resource itself. +// 2. Keys specified in provider configuration (obj_access_key/obj_secret_key). +// 3. If enabled, temporary keys created via the Linode API (obj_use_temp_keys). +// +// When temporary keys are created, a non-nil teardown function is returned to +// delete them after the operation. +func GetObjectStorageKeys( + ctx context.Context, + data BucketAccessor, + client *linodego.Client, + config *helper.FrameworkProviderModel, + permissions string, + endpointType *linodego.ObjectStorageEndpointType, + diags *diag.Diagnostics, +) (*ObjectKeys, func()) { + result := data.ObjectStorageKeys() + if result.Ok() { + return &result, nil + } + + result.AccessKey = config.ObjAccessKey.ValueString() + result.SecretKey = config.ObjSecretKey.ValueString() + if result.Ok() { + return &result, nil + } + + if config.ObjUseTempKeys.ValueBool() { + clusterOrRegion := data.RegionOrCluster(ctx, diags) + if diags.HasError() { + return nil, nil + } + + objKey := fwCreateTempKeys(ctx, client, data.BucketLabel(), clusterOrRegion, permissions, endpointType, diags) + if diags.HasError() { + return nil, nil + } + + result.AccessKey = objKey.AccessKey + result.SecretKey = objKey.SecretKey + + teardownTempKeysCleanUp := func() { + cleanUpTempKeys(ctx, client, objKey.ID) + } + + return &result, teardownTempKeysCleanUp + } + + diags.AddError( + "Keys Not Found", + "`access_key` and `secret_key` are Required but not Configured", + ) + + return nil, nil +} diff --git a/linode/obj/framework_models.go b/linode/obj/framework_models.go index ea547336b..a46abadcf 100644 --- a/linode/obj/framework_models.go +++ b/linode/obj/framework_models.go @@ -75,66 +75,13 @@ func (data ResourceModel) getObjectBody(diags *diag.Diagnostics) (body *s3manage return s3manager.ReadSeekCloser(bytes.NewReader(contentBytes)) } -func (data ResourceModel) GetObjectStorageKeys( - ctx context.Context, - client *linodego.Client, - config *helper.FrameworkProviderModel, - permissions string, - endpointType *linodego.ObjectStorageEndpointType, - diags *diag.Diagnostics, -) (*ObjectKeys, func()) { - result := &ObjectKeys{} - - result.AccessKey = data.AccessKey.ValueString() - result.SecretKey = data.SecretKey.ValueString() - - if result.Ok() { - return result, nil - } - - result.AccessKey = config.ObjAccessKey.ValueString() - result.SecretKey = config.ObjSecretKey.ValueString() - - if result.Ok() { - return result, nil - } - - if config.ObjUseTempKeys.ValueBool() { - clusterOrRegion := data.GetRegionOrCluster(ctx, diags) - if diags.HasError() { - return nil, nil - } - - objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), clusterOrRegion, permissions, nil, diags) - if diags.HasError() { - return nil, nil - } - - result.AccessKey = objKey.AccessKey - result.SecretKey = objKey.SecretKey - - teardownTempKeysCleanUp := func() { - cleanUpTempKeys(ctx, client, objKey.ID) - } - - return result, teardownTempKeysCleanUp - } - - diags.AddError( - "Keys Not Found", - "`access_key` and `secret_key` are Required but not Configured", - ) - - return nil, nil -} - func (plan *ResourceModel) ComputeEndpointIfUnknown(ctx context.Context, client *linodego.Client, diags *diag.Diagnostics) { if !plan.Endpoint.IsUnknown() { return } bucketName := plan.Bucket.ValueString() - regionOrCluster := plan.GetRegionOrCluster(ctx, diags) + regionOrCluster := plan.RegionOrCluster(ctx, diags) if diags.HasError() { return } @@ -161,7 +108,7 @@ func (data *ResourceModel) GenerateObjectStorageObjectID(apply bool, preserveKno return id } -func (data ResourceModel) GetRegionOrCluster(ctx context.Context, diags *diag.Diagnostics) string { +func (data ResourceModel) RegionOrCluster(ctx context.Context, diags *diag.Diagnostics) string { if !data.Region.IsNull() && !data.Region.IsUnknown() { return data.Region.ValueString() } else { @@ -174,6 +121,17 @@ func (data ResourceModel) GetRegionOrCluster(ctx context.Context, diags *diag.Di return data.Cluster.ValueString() } +func (data ResourceModel) ObjectStorageKeys() ObjectKeys { + return ObjectKeys{ + AccessKey: data.AccessKey.ValueString(), + SecretKey: data.SecretKey.ValueString(), + } +} + +func (data ResourceModel) BucketLabel() string { + return data.Bucket.ValueString() +} + func (data *ResourceModel) FlattenObject( obj s3.HeadObjectOutput, preserveKnown bool, ) { diff --git a/linode/obj/framework_resource.go b/linode/obj/framework_resource.go index 6619a5432..fcffedb6e 100644 --- a/linode/obj/framework_resource.go +++ b/linode/obj/framework_resource.go @@ -307,7 +307,7 @@ func (r *Resource) Delete( func populateLogAttributes(ctx context.Context, model ResourceModel, diags *diag.Diagnostics) context.Context { return helper.SetLogFieldBulk(ctx, map[string]any{ "bucket": model.Bucket.ValueString(), - "region_or_cluster": model.GetRegionOrCluster(ctx, diags), + "region_or_cluster": model.RegionOrCluster(ctx, diags), "object_key": model.Key.ValueString(), }) } diff --git a/linode/obj/helpers.go b/linode/obj/helpers.go index 921ea558f..a29e2b7b8 100644 --- a/linode/obj/helpers.go +++ b/linode/obj/helpers.go @@ -39,7 +39,7 @@ func getS3ClientFromModel( endpointType *linodego.ObjectStorageEndpointType, diags *diag.Diagnostics, ) (*s3.Client, func()) { - keys, teardownKeys := data.GetObjectStorageKeys(ctx, client, config, permission, endpointType, diags) + keys, teardownKeys := GetObjectStorageKeys(ctx, data, client, config, permission, endpointType, diags) if diags.HasError() { return nil, teardownKeys } diff --git a/linode/objbucket/framework_datasource_schema.go b/linode/objbucket/framework_datasource_schema.go index 398abfb9f..5a59d23b6 100644 --- a/linode/objbucket/framework_datasource_schema.go +++ b/linode/objbucket/framework_datasource_schema.go @@ -10,6 +10,14 @@ import ( var frameworkDatasourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The id of this bucket.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "The name of this bucket.", + Required: true, + }, "cluster": schema.StringAttribute{ Description: "The ID of the Object Storage Cluster this bucket is in.", DeprecationMessage: "The cluster attribute has been deprecated, please consider " + @@ -41,24 +49,11 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The S3 endpoint URL of the bucket, based on the `endpoint_type` and `region`.", Computed: true, }, - "created": schema.StringAttribute{ - Description: "When this bucket was created.", - CustomType: timetypes.RFC3339Type{}, - Computed: true, - }, "hostname": schema.StringAttribute{ Description: "The hostname where this bucket can be accessed." + "This hostname can be accessed through a browser if the bucket is made public.", Computed: true, }, - "id": schema.StringAttribute{ - Description: "The id of this bucket.", - Computed: true, - }, - "label": schema.StringAttribute{ - Description: "The name of this bucket.", - Required: true, - }, "objects": schema.Int64Attribute{ Description: "The number of objects stored in this bucket.", Computed: true, @@ -67,5 +62,10 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The size of the bucket in bytes.", Computed: true, }, + "created": schema.StringAttribute{ + Description: "When this bucket was created.", + CustomType: timetypes.RFC3339Type{}, + Computed: true, + }, }, } diff --git a/linode/objbucket/framework_models.go b/linode/objbucket/framework_models.go index 9486a5525..7c0f4a463 100644 --- a/linode/objbucket/framework_models.go +++ b/linode/objbucket/framework_models.go @@ -21,6 +21,21 @@ type BaseModel struct { Created timetypes.RFC3339 `tfsdk:"created"` } +func (data BaseModel) RegionOrCluster() (regionOrCluster string) { + if data.Region.ValueString() != "" { + regionOrCluster = data.Region.ValueString() + } else { + regionOrCluster = data.Cluster.ValueString() + } + return +} + +func (data BaseModel) BucketLabel() (label string) { + // Label is a required attribute, so there is no need + // to check whether it is null or unknown. + return data.Label.ValueString() +} + type DataSourceModel struct { BaseModel } From 873a320faadd54e4405c9f5ddb378fb253c312ff Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Sat, 28 Feb 2026 00:15:36 -0500 Subject: [PATCH 4/4] Fix --- linode/obj/helpers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/linode/obj/helpers.go b/linode/obj/helpers.go index f18183284..f6e6f0cee 100644 --- a/linode/obj/helpers.go +++ b/linode/obj/helpers.go @@ -6,6 +6,7 @@ import ( "fmt" "hash/crc32" "io" + "regexp" "slices" "strings" "time"