diff --git a/pagerdutyplugin/provider_test.go b/pagerdutyplugin/provider_test.go index ea839e2b2..f562e13bb 100644 --- a/pagerdutyplugin/provider_test.go +++ b/pagerdutyplugin/provider_test.go @@ -84,3 +84,17 @@ func testAccTimeNow() time.Time { } return util.TimeNowInLoc(name) } + +func testAccPreCheckPagerDutyAbility(t *testing.T, ability string) { + if v := os.Getenv("PAGERDUTY_TOKEN"); v == "" { + t.Fatal("PAGERDUTY_TOKEN must be set for acceptance tests") + } + if v := os.Getenv("PAGERDUTY_USER_TOKEN"); v == "" { + t.Fatal("PAGERDUTY_USER_TOKEN must be set for acceptance tests") + } + + ctx := context.Background() + if err := testAccProvider.client.TestAbilityWithContext(ctx, ability); err != nil { + t.Skipf("Missing ability: %s. Skipping test", ability) + } +} diff --git a/util/build.go b/util/build.go new file mode 100644 index 000000000..80bec4b65 --- /dev/null +++ b/util/build.go @@ -0,0 +1,23 @@ +package util + +import ( + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func StringToUintPointer(p path.Path, s types.String, diags *diag.Diagnostics) *uint { + if s.IsNull() || s.IsUnknown() || s.ValueString() == "" || s.ValueString() == "null" { + return nil + } + if val, err := strconv.Atoi(s.ValueString()); err == nil { + uintvalue := uint(val) + return &uintvalue + } else { + diags.AddError(fmt.Sprintf("Value for %q is not a valid number", p), err.Error()) + } + return nil +} diff --git a/util/enumtypes/int64.go b/util/enumtypes/int64.go new file mode 100644 index 000000000..0aab6bf94 --- /dev/null +++ b/util/enumtypes/int64.go @@ -0,0 +1,105 @@ +package enumtypes + +import ( + "context" + "fmt" + "math/big" + "slices" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type Int64Value struct { + basetypes.Int64Value + EnumType Int64Type +} + +func NewInt64Null(t Int64Type) Int64Value { + return Int64Value{Int64Value: basetypes.NewInt64Null(), EnumType: t} +} + +func NewInt64Value(v int64, t Int64Type) Int64Value { + return Int64Value{Int64Value: basetypes.NewInt64Value(v), EnumType: t} +} + +func (s Int64Value) Type(_ context.Context) attr.Type { + return s.EnumType +} + +type Int64Type struct { + basetypes.Int64Type + OneOf []int64 +} + +func (t Int64Type) Int64() string { + return "enumtypes.Int64Type" +} + +func (t Int64Type) Equal(o attr.Type) bool { + if t2, ok := o.(Int64Type); ok { + return slices.Equal(t.OneOf, t2.OneOf) + } + return t.Int64Type.Equal(o) +} + +func (t Int64Type) Validate(ctx context.Context, in tftypes.Value, path path.Path) (diags diag.Diagnostics) { + if in.Type() == nil { + return + } + + if !in.Type().Is(tftypes.Number) { + err := fmt.Errorf("expected Int64 value, received %T with value: %v", in, in) + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return diags + } + + if !in.IsKnown() || in.IsNull() { + return diags + } + + var valueFloat big.Float + if err := in.As(&valueFloat); err != nil { + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return + } + valueInt64, _ := valueFloat.Int64() + + found := false + for _, v := range t.OneOf { + if v == valueInt64 { + found = true + break + } + } + + if !found { + diags.AddAttributeError( + path, + "Invalid Int64 Value", + fmt.Sprintf( + "A string value was provided that is not valid.\n"+ + "Given Value: %v\n"+ + "Expecting One Of: %v", + valueInt64, + t.OneOf, + ), + ) + return + } + + return +} diff --git a/util/enumtypes/string.go b/util/enumtypes/string.go new file mode 100644 index 000000000..1d3a522c0 --- /dev/null +++ b/util/enumtypes/string.go @@ -0,0 +1,100 @@ +package enumtypes + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type StringValue struct { + basetypes.StringValue + EnumType StringType +} + +func NewStringNull(t StringType) StringValue { + return StringValue{StringValue: basetypes.NewStringNull(), EnumType: t} +} + +func NewStringValue(v string, t StringType) StringValue { + return StringValue{StringValue: basetypes.NewStringValue(v), EnumType: t} +} + +func (s StringValue) Type(_ context.Context) attr.Type { + return s.EnumType +} + +type StringType struct { + basetypes.StringType + OneOf []string +} + +func (t StringType) String() string { + return "enumtypes.StringType" +} + +func (t StringType) Equal(o attr.Type) bool { + if t2, ok := o.(StringType); ok { + return slices.Equal(t.OneOf, t2.OneOf) + } + return t.StringType.Equal(o) +} + +func (t StringType) Validate(ctx context.Context, in tftypes.Value, path path.Path) (diags diag.Diagnostics) { + if in.Type() == nil { + return + } + + if !in.Type().Is(tftypes.String) { + err := fmt.Errorf("expected String value, received %T with value: %v", in, in) + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return diags + } + + if !in.IsKnown() || in.IsNull() { + return diags + } + + var valueString string + if err := in.As(&valueString); err != nil { + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return + } + + found := false + for _, v := range t.OneOf { + if v == valueString { + found = true + break + } + } + + if !found { + diags.AddAttributeError( + path, + "Invalid String Value", + "A string value was provided that is not valid.\n"+ + "Given Value: "+valueString+"\n"+ + "Expecting One Of: "+strings.Join(t.OneOf, ", "), + ) + return + } + + return +} diff --git a/util/rangetypes/int64.go b/util/rangetypes/int64.go new file mode 100644 index 000000000..c23467fb1 --- /dev/null +++ b/util/rangetypes/int64.go @@ -0,0 +1,91 @@ +package rangetypes + +import ( + "context" + "fmt" + "math/big" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type Int64Value struct { + basetypes.Int64Value + RangeType Int64Type +} + +func NewInt64Null(t Int64Type) Int64Value { + return Int64Value{Int64Value: basetypes.NewInt64Null(), RangeType: t} +} + +func NewInt64Value(v int64, t Int64Type) Int64Value { + return Int64Value{Int64Value: basetypes.NewInt64Value(v), RangeType: t} +} + +func (s Int64Value) Type(_ context.Context) attr.Type { + return s.RangeType +} + +type Int64Type struct { + basetypes.Int64Type + Start int64 + End int64 +} + +func (t Int64Type) String() string { + return "rangetypes.Int64Type" +} + +func (t Int64Type) Equal(o attr.Type) bool { + if t2, ok := o.(Int64Type); ok { + return t.Start == t2.Start && t.End == t2.End + } + return t.Int64Type.Equal(o) +} + +func (t Int64Type) addTypeValidationError(err error, path path.Path, diags *diag.Diagnostics) { + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) +} + +func (t Int64Type) Validate(ctx context.Context, in tftypes.Value, path path.Path) (diags diag.Diagnostics) { + if in.Type() == nil { + return + } + + if !in.Type().Is(tftypes.Number) { + err := fmt.Errorf("expected Int64 value, received %T with value: %v", in, in) + t.addTypeValidationError(err, path, &diags) + return + } + + if !in.IsKnown() || in.IsNull() { + return + } + + var valueFloat big.Float + if err := in.As(&valueFloat); err != nil { + t.addTypeValidationError(err, path, &diags) + return + } + valueInt64, _ := valueFloat.Int64() + + if valueInt64 < t.Start || valueInt64 > int64(t.End) { + diags.AddAttributeError( + path, + "Invalid Int64 Value", + fmt.Sprintf("A value was provided that is not inside valid range (%v, %v).\n"+ + "Given Value: %v", t.Start, t.End, valueInt64), + ) + return + } + + return +} diff --git a/util/string_describer.go b/util/string_describer.go new file mode 100644 index 000000000..9528a0d6a --- /dev/null +++ b/util/string_describer.go @@ -0,0 +1,13 @@ +package util + +import "context" + +type StringDescriber struct{ Value string } + +func (d StringDescriber) MarkdownDescription(context.Context) string { + return d.Value +} + +func (d StringDescriber) Description(ctx context.Context) string { + return d.MarkdownDescription(ctx) +} diff --git a/util/tztypes/string.go b/util/tztypes/string.go new file mode 100644 index 000000000..315d9edaf --- /dev/null +++ b/util/tztypes/string.go @@ -0,0 +1,90 @@ +package tztypes + +import ( + "context" + "fmt" + + "github.com/PagerDuty/terraform-provider-pagerduty/util" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type StringValue struct { + basetypes.StringValue +} + +func NewStringNull() StringValue { + return StringValue{StringValue: basetypes.NewStringNull()} +} + +func NewStringValue(v string) StringValue { + return StringValue{StringValue: basetypes.NewStringValue(v)} +} + +func (s StringValue) Type(_ context.Context) attr.Type { + return StringType{} +} + +type StringType struct { + basetypes.StringType +} + +func (t StringType) String() string { + return "tztypes.StringType" +} + +func (t StringType) Equal(o attr.Type) bool { + _, ok := o.(StringType) + if ok { + return true + } + + return t.StringType.Equal(o) +} + +func (t StringType) Validate(ctx context.Context, in tftypes.Value, path path.Path) (diags diag.Diagnostics) { + if in.Type() == nil { + return + } + + if !in.Type().Is(tftypes.String) { + err := fmt.Errorf("expected String value, received %T with value: %v", in, in) + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return diags + } + + if !in.IsKnown() || in.IsNull() { + return diags + } + + var valueString string + if err := in.As(&valueString); err != nil { + diags.AddAttributeError( + path, + "Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return + } + + if !util.IsValidTZ(valueString) { + diags.AddAttributeError( + path, + "Invalid String Value", + "A string value was provided that is not a valid timezone.\n"+ + "Given Value: "+valueString, + ) + return + } + + return +} diff --git a/util/util.go b/util/util.go index 72e91798d..9fa34c6fa 100644 --- a/util/util.go +++ b/util/util.go @@ -292,16 +292,16 @@ func ResourcePagerDutyParseColonCompoundID(id string) (string, string, error) { return parts[0], parts[1], nil } +func IsValidTZ(v string) bool { + foundAt := sort.SearchStrings(validTZ, v) + return foundAt < len(validTZ) && validTZ[foundAt] == v +} + func ValidateTZValueDiagFunc(v interface{}, p cty.Path) diag.Diagnostics { var diags diag.Diagnostics value := v.(string) - valid := false - - foundAt := sort.SearchStrings(validTZ, value) - if foundAt < len(validTZ) && validTZ[foundAt] == value { - valid = true - } + valid := IsValidTZ(value) if !valid { diags = append(diags, diag.Diagnostic{ diff --git a/util/validate/alternatives_for_path.go b/util/validate/alternatives_for_path.go new file mode 100644 index 000000000..599a7990a --- /dev/null +++ b/util/validate/alternatives_for_path.go @@ -0,0 +1,28 @@ +package validate + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func AlternativesForPath(p path.Path, alt []attr.Value) *alternativesForPathValidator { + return &alternativesForPathValidator{Path: p, Alternatives: alt} +} + +type alternativesForPathValidator struct { + Path path.Path + Alternatives []attr.Value +} + +var _ validator.String = (*alternativesForPathValidator)(nil) + +func (v *alternativesForPathValidator) Description(_ context.Context) string { return "" } +func (v *alternativesForPathValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v *alternativesForPathValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { +} diff --git a/util/validate/is_allowed_string.go b/util/validate/is_allowed_string.go new file mode 100644 index 000000000..4424544ec --- /dev/null +++ b/util/validate/is_allowed_string.go @@ -0,0 +1,43 @@ +package validate + +import ( + "context" + "strings" + "unicode" + + "github.com/PagerDuty/terraform-provider-pagerduty/util" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type validateIsAllowedString struct { + validateFn func(s string) bool + util.StringDescriber +} + +func (v validateIsAllowedString) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if ok := v.validateFn(req.ConfigValue.ValueString()); !ok { + resp.Diagnostics.AddError(v.Value, "") + } +} + +func IsAllowedString(mode util.StringContentValidationMode) validator.String { + switch mode { + case util.NoNonPrintableChars: + return validateIsAllowedString{ + func(s string) bool { + for _, char := range s { + if !unicode.IsPrint(char) { + return false + } + } + return s != "" && !strings.HasSuffix(s, " ") + }, + util.StringDescriber{Value: "Name can not be blank, nor contain non-printable characters. Trailing white spaces are not allowed either."}, + } + default: + return validateIsAllowedString{ + func(s string) bool { return false }, + util.StringDescriber{Value: "Invalid mode while using func IsAllowedStringValidator(mode StringContentValidationMode)"}, + } + } +} diff --git a/util/validate/require.go b/util/validate/require.go new file mode 100644 index 000000000..903bef2b1 --- /dev/null +++ b/util/validate/require.go @@ -0,0 +1,53 @@ +package validate + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// Require checks a path is not null. +func Require(p path.Path) resource.ConfigValidator { + return &requirePath{Path: p} +} + +type requirePath struct { + path.Path +} + +func (v *requirePath) Description(ctx context.Context) string { + return "Forces item to be present if its parent is present" +} + +func (v *requirePath) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v *requirePath) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var parent attr.Value + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.Path.ParentPath(), &parent)...) + if resp.Diagnostics.HasError() { + return + } + if parent.IsNull() { + return + } + + var src attr.Value + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.Path, &src)...) + if resp.Diagnostics.HasError() { + return + } + + if src.IsNull() { + resp.Diagnostics.AddAttributeError( + v.Path, + fmt.Sprintf("Required %s", v.Path), + fmt.Sprintf("Field %s must have an explicit value", v.Path), + ) + return + } +} diff --git a/util/validate/require_a_if_b_equal.go b/util/validate/require_a_if_b_equal.go new file mode 100644 index 000000000..df03c7d55 --- /dev/null +++ b/util/validate/require_a_if_b_equal.go @@ -0,0 +1,57 @@ +package validate + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// RequireAIfBEqual checks path `a` is not null when path `b` is equal to `expected`. +func RequireAIfBEqual(a, b path.Path, expected attr.Value) resource.ConfigValidator { + return &requireIfEqual{ + dst: a, + src: b, + expected: expected, + } +} + +type requireIfEqual struct { + dst path.Path + src path.Path + expected attr.Value +} + +func (v *requireIfEqual) Description(ctx context.Context) string { return "" } +func (v *requireIfEqual) MarkdownDescription(ctx context.Context) string { return "" } + +func (v *requireIfEqual) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var src attr.Value + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.src, &src)...) + if resp.Diagnostics.HasError() { + return + } + + if src.IsNull() || src.IsUnknown() { + return + } + + if src.Equal(v.expected) { + var dst attr.Value + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.dst, &dst)...) + if resp.Diagnostics.HasError() { + return + } + + if dst.IsNull() || dst.IsUnknown() { + resp.Diagnostics.AddAttributeError( + v.dst, + fmt.Sprintf("Required %s", v.dst), + fmt.Sprintf("When the value of %s equals %s, field %s must have an explicit value", v.src, v.expected, v.dst), + ) + return + } + } +} diff --git a/util/validate/require_list_size.go b/util/validate/require_list_size.go new file mode 100644 index 000000000..c500c8386 --- /dev/null +++ b/util/validate/require_list_size.go @@ -0,0 +1,39 @@ +package validate + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// RequireList checks path `p` is a list at least with size 1. +func RequireList(p path.Path) resource.ConfigValidator { + return &requireListSize{Path: p} +} + +type requireListSize struct { + path.Path +} + +func (v *requireListSize) Description(ctx context.Context) string { return "" } +func (v *requireListSize) MarkdownDescription(ctx context.Context) string { return "" } + +func (v *requireListSize) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var src attr.Value + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.Path, &src)...) + if resp.Diagnostics.HasError() { + return + } + + if src.IsNull() || src.IsUnknown() { + return + } + + size := 1 + if size < 1 { + resp.Diagnostics.AddAttributeError(v.Path, "Required to be a list with items", "") + return + } +} diff --git a/util/validate/timezone.go b/util/validate/timezone.go new file mode 100644 index 000000000..53d5ab358 --- /dev/null +++ b/util/validate/timezone.go @@ -0,0 +1,33 @@ +package validate + +import ( + "context" + "fmt" + "time" + + "github.com/PagerDuty/terraform-provider-pagerduty/util" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type timezoneValidator struct { + util.StringDescriber +} + +func Timezone() validator.String { + return &timezoneValidator{ + util.StringDescriber{Value: "checks time zone is supported by the machine's tzdata"}, + } +} + +func (v timezoneValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() { + return + } + value := req.ConfigValue.ValueString() + _, err := time.LoadLocation(value) + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, fmt.Sprintf("Timezone %q is invalid", value), err.Error(), + ) + } +}