Skip to content
Merged
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
150 changes: 92 additions & 58 deletions internal/api/enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,75 +23,109 @@ import "github.com/devhelmhq/terraform-provider-devhelm/internal/generated"

// AssertionTypes lists every wire-format assertion type. Used by the
// monitor resource's `assertions[*].type` validator.
//
// Sourced from each assertion SUBTYPE's discriminator-tag constant
// (e.g. `BodyContainsAssertionTypeBodyContains BodyContainsAssertionType
// = "body_contains"`) rather than from the parent
// `MonitorAssertionDto.assertionType` response enum. Under the
// spec-level Postel's-Law relaxation (`mini/runbooks/api-contract.md`
// § 3), response-DTO multi-value enums are dropped and the parent typed
// alias no longer exists. Subtype discriminator tags are single-value
// enums and survive — they're the canonical source for plan-time
// validation here. Constant names are pinned by
// `compatibility.always-prefix-enum-values: true` in
// `scripts/oapi-codegen.yaml` so unrelated enum churn cannot rename
// them.
var AssertionTypes = []string{
string(generated.MonitorAssertionDtoAssertionTypeBodyContains),
string(generated.MonitorAssertionDtoAssertionTypeDnsExpectedCname),
string(generated.MonitorAssertionDtoAssertionTypeDnsExpectedIps),
string(generated.MonitorAssertionDtoAssertionTypeDnsMaxAnswers),
string(generated.MonitorAssertionDtoAssertionTypeDnsMinAnswers),
string(generated.MonitorAssertionDtoAssertionTypeDnsRecordContains),
string(generated.MonitorAssertionDtoAssertionTypeDnsRecordEquals),
string(generated.MonitorAssertionDtoAssertionTypeDnsResolves),
string(generated.MonitorAssertionDtoAssertionTypeDnsResponseTime),
string(generated.MonitorAssertionDtoAssertionTypeDnsResponseTimeWarn),
string(generated.MonitorAssertionDtoAssertionTypeDnsTtlHigh),
string(generated.MonitorAssertionDtoAssertionTypeDnsTtlLow),
string(generated.MonitorAssertionDtoAssertionTypeDnsTxtContains),
string(generated.MonitorAssertionDtoAssertionTypeHeaderValue),
string(generated.MonitorAssertionDtoAssertionTypeHeartbeatIntervalDrift),
string(generated.MonitorAssertionDtoAssertionTypeHeartbeatMaxInterval),
string(generated.MonitorAssertionDtoAssertionTypeHeartbeatPayloadContains),
string(generated.MonitorAssertionDtoAssertionTypeHeartbeatReceived),
string(generated.MonitorAssertionDtoAssertionTypeIcmpPacketLoss),
string(generated.MonitorAssertionDtoAssertionTypeIcmpReachable),
string(generated.MonitorAssertionDtoAssertionTypeIcmpResponseTime),
string(generated.MonitorAssertionDtoAssertionTypeIcmpResponseTimeWarn),
string(generated.MonitorAssertionDtoAssertionTypeJsonPath),
string(generated.MonitorAssertionDtoAssertionTypeMcpConnects),
string(generated.MonitorAssertionDtoAssertionTypeMcpHasCapability),
string(generated.MonitorAssertionDtoAssertionTypeMcpMinTools),
string(generated.MonitorAssertionDtoAssertionTypeMcpProtocolVersion),
string(generated.MonitorAssertionDtoAssertionTypeMcpResponseTime),
string(generated.MonitorAssertionDtoAssertionTypeMcpResponseTimeWarn),
string(generated.MonitorAssertionDtoAssertionTypeMcpToolAvailable),
string(generated.MonitorAssertionDtoAssertionTypeMcpToolCountChanged),
string(generated.MonitorAssertionDtoAssertionTypeRedirectCount),
string(generated.MonitorAssertionDtoAssertionTypeRedirectTarget),
string(generated.MonitorAssertionDtoAssertionTypeRegexBody),
string(generated.MonitorAssertionDtoAssertionTypeResponseSize),
string(generated.MonitorAssertionDtoAssertionTypeResponseTime),
string(generated.MonitorAssertionDtoAssertionTypeResponseTimeWarn),
string(generated.MonitorAssertionDtoAssertionTypeSslExpiry),
string(generated.MonitorAssertionDtoAssertionTypeStatusCode),
string(generated.MonitorAssertionDtoAssertionTypeTcpConnects),
string(generated.MonitorAssertionDtoAssertionTypeTcpResponseTime),
string(generated.MonitorAssertionDtoAssertionTypeTcpResponseTimeWarn),
string(generated.BodyContainsAssertionTypeBodyContains),
string(generated.DnsExpectedCnameAssertionTypeDnsExpectedCname),
string(generated.DnsExpectedIpsAssertionTypeDnsExpectedIps),
string(generated.DnsMaxAnswersAssertionTypeDnsMaxAnswers),
string(generated.DnsMinAnswersAssertionTypeDnsMinAnswers),
string(generated.DnsRecordContainsAssertionTypeDnsRecordContains),
string(generated.DnsRecordEqualsAssertionTypeDnsRecordEquals),
string(generated.DnsResolvesAssertionTypeDnsResolves),
string(generated.DnsResponseTimeAssertionTypeDnsResponseTime),
string(generated.DnsResponseTimeWarnAssertionTypeDnsResponseTimeWarn),
string(generated.DnsTtlHighAssertionTypeDnsTtlHigh),
string(generated.DnsTtlLowAssertionTypeDnsTtlLow),
string(generated.DnsTxtContainsAssertionTypeDnsTxtContains),
string(generated.HeaderValueAssertionTypeHeaderValue),
string(generated.HeartbeatIntervalDriftAssertionTypeHeartbeatIntervalDrift),
string(generated.HeartbeatMaxIntervalAssertionTypeHeartbeatMaxInterval),
string(generated.HeartbeatPayloadContainsAssertionTypeHeartbeatPayloadContains),
string(generated.HeartbeatReceivedAssertionTypeHeartbeatReceived),
string(generated.IcmpPacketLossAssertionTypeIcmpPacketLoss),
string(generated.IcmpReachableAssertionTypeIcmpReachable),
string(generated.IcmpResponseTimeAssertionTypeIcmpResponseTime),
string(generated.IcmpResponseTimeWarnAssertionTypeIcmpResponseTimeWarn),
string(generated.JsonPathAssertionTypeJsonPath),
string(generated.McpConnectsAssertionTypeMcpConnects),
string(generated.McpHasCapabilityAssertionTypeMcpHasCapability),
string(generated.McpMinToolsAssertionTypeMcpMinTools),
string(generated.McpProtocolVersionAssertionTypeMcpProtocolVersion),
string(generated.McpResponseTimeAssertionTypeMcpResponseTime),
string(generated.McpResponseTimeWarnAssertionTypeMcpResponseTimeWarn),
string(generated.McpToolAvailableAssertionTypeMcpToolAvailable),
string(generated.McpToolCountChangedAssertionTypeMcpToolCountChanged),
string(generated.RedirectCountAssertionTypeRedirectCount),
string(generated.RedirectTargetAssertionTypeRedirectTarget),
string(generated.RegexBodyAssertionTypeRegexBody),
string(generated.ResponseSizeAssertionTypeResponseSize),
string(generated.ResponseTimeAssertionTypeResponseTime),
string(generated.ResponseTimeWarnAssertionTypeResponseTimeWarn),
string(generated.SslExpiryAssertionTypeSslExpiry),
string(generated.StatusCodeAssertionTypeStatusCode),
string(generated.TcpConnectsAssertionTypeTcpConnects),
string(generated.TcpResponseTimeAssertionTypeTcpResponseTime),
string(generated.TcpResponseTimeWarnAssertionTypeTcpResponseTimeWarn),
}

// AlertChannelTypes lists every wire-format alert channel kind. Used by
// the alert_channel resource's `channel_type` validator (and by anything
// else that needs to discriminate channels by wire type).
//
// Sourced from each alert-channel SUBTYPE's discriminator-tag constant
// (e.g. `EmailChannelConfigChannelTypeEmail`) rather than from the
// parent `AlertChannelDto.channelType` response enum, for the same
// reason as `AssertionTypes` above.
var AlertChannelTypes = []string{
string(generated.AlertChannelDtoChannelTypeEmail),
string(generated.AlertChannelDtoChannelTypeWebhook),
string(generated.AlertChannelDtoChannelTypeSlack),
string(generated.AlertChannelDtoChannelTypePagerduty),
string(generated.AlertChannelDtoChannelTypeOpsgenie),
string(generated.AlertChannelDtoChannelTypeTeams),
string(generated.AlertChannelDtoChannelTypeDiscord),
string(generated.EmailChannelConfigChannelTypeEmail),
string(generated.WebhookChannelConfigChannelTypeWebhook),
string(generated.SlackChannelConfigChannelTypeSlack),
string(generated.PagerDutyChannelConfigChannelTypePagerduty),
string(generated.OpsGenieChannelConfigChannelTypeOpsgenie),
string(generated.TeamsChannelConfigChannelTypeTeams),
string(generated.DiscordChannelConfigChannelTypeDiscord),
}

// AlertSensitivities lists every wire-format alert-sensitivity value for
// the dependency / service-subscription resources.
//
// `ServiceSubscriptionDto.alertSensitivity` is response-shaped, so under
// the spec-level Postel's-Law relaxation it has no typed alias. The
// request-side schema (`UpdateAlertSensitivityRequest.alertSensitivity`)
// uses an OpenAPI `pattern` instead of `enum`, so oapi-codegen also
// emits it as `string`. We therefore enumerate the allowed wire values
// here, and `TestEnumSliceCoverage` cross-checks them against the
// authoritative spec on each typegen run.
var AlertSensitivities = []string{
"ALL",
"INCIDENTS_ONLY",
"MAJOR_ONLY",
}

// MatchRuleTypes lists every wire-format notification-policy match-rule
// kind. Used by the notification_policy resource's
// `match_rule[*].type` validator.
var MatchRuleTypes = []string{
string(generated.ComponentNameIn),
string(generated.IncidentStatus),
string(generated.MonitorIdIn),
string(generated.MonitorTagIn),
string(generated.MonitorTypeIn),
string(generated.RegionIn),
string(generated.ResourceGroupIdIn),
string(generated.ServiceIdIn),
string(generated.SeverityGte),
string(generated.MatchRuleTypeComponentNameIn),
string(generated.MatchRuleTypeIncidentStatus),
string(generated.MatchRuleTypeMonitorIdIn),
string(generated.MatchRuleTypeMonitorTagIn),
string(generated.MatchRuleTypeMonitorTypeIn),
string(generated.MatchRuleTypeRegionIn),
string(generated.MatchRuleTypeResourceGroupIdIn),
string(generated.MatchRuleTypeServiceIdIn),
string(generated.MatchRuleTypeSeverityGte),
}
83 changes: 66 additions & 17 deletions internal/api/enums_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,19 @@ import (
type enumSliceCase struct {
// generatedTypeName is the name of the enum type as declared in
// `internal/generated/types.go` (e.g. `MonitorAssertionDtoAssertionType`).
// When `typeNameSuffix` is non-empty, this field is ignored and the
// assertion is sourced from the union of all enum types whose name
// ends in that suffix.
generatedTypeName string
// typeNameSuffix, when non-empty, makes the test gather literals
// from every enum type ending in this suffix (e.g. `AssertionType`
// covers `BodyContainsAssertionType`, `DnsExpectedCnameAssertionType`,
// …). Used for slices that aggregate discriminator-subtype tags
// after the parent response-DTO enum was dropped by the spec-level
// Postel's-Law relaxation (`mini/runbooks/api-contract.md` § 3).
typeNameSuffix string
// slice is the in-package slice we expect to be exhaustive for
// `generatedTypeName`.
// the case's source type(s).
slice []string
// description is surfaced in test failure output so a maintainer
// can immediately see which slice / which surface is affected.
Expand All @@ -66,14 +76,21 @@ type enumSliceCase struct {
// relevant resource Schema().
var enumSliceCoverage = []enumSliceCase{
{
generatedTypeName: "MonitorAssertionDtoAssertionType",
slice: AssertionTypes,
description: "monitor assertions[*].type validator",
// The parent `MonitorAssertionDto.assertionType` typed alias
// was dropped by spec-level Postel's-Law relaxation. The
// canonical source is now the union of every assertion
// subtype's discriminator constant.
typeNameSuffix: "AssertionType",
slice: AssertionTypes,
description: "monitor assertions[*].type validator",
},
{
generatedTypeName: "AlertChannelDtoChannelType",
slice: AlertChannelTypes,
description: "alert_channel.channel_type validator",
// Same rationale as `AssertionType` above — sourced from each
// alert-channel subtype's `*ChannelConfigChannelType`
// discriminator constant.
typeNameSuffix: "ChannelConfigChannelType",
slice: AlertChannelTypes,
description: "alert_channel.channel_type validator",
},
{
generatedTypeName: "MatchRuleType",
Expand All @@ -90,16 +107,42 @@ func TestEnumSliceCoverage(t *testing.T) {

for _, c := range enumSliceCoverage {
c := c
t.Run(c.generatedTypeName, func(t *testing.T) {
expected, ok := literals[c.generatedTypeName]
if !ok {
t.Fatalf(
"generated package has no `const ... %s = \"...\"` block. "+
"Either the spec stopped declaring this enum (drop the entry "+
"from `enumSliceCoverage`), or oapi-codegen output shape "+
"changed (update `parseGeneratedEnumLiterals`).",
c.generatedTypeName,
)
caseName := c.generatedTypeName
if caseName == "" {
caseName = "*" + c.typeNameSuffix
}
t.Run(caseName, func(t *testing.T) {
var expected []string
if c.typeNameSuffix != "" {
// Aggregate every type whose name ends in the given
// suffix — used for discriminator-subtype unions.
for typeName, vals := range literals {
if !endsWith(typeName, c.typeNameSuffix) {
continue
}
expected = append(expected, vals...)
}
if len(expected) == 0 {
t.Fatalf(
"generated package has no enum types ending in %q. "+
"Either the spec dropped the discriminated union "+
"(drop this case from `enumSliceCoverage`) or "+
"the suffix needs updating.",
c.typeNameSuffix,
)
}
} else {
vals, ok := literals[c.generatedTypeName]
if !ok {
t.Fatalf(
"generated package has no `const ... %s = \"...\"` block. "+
"Either the spec stopped declaring this enum (drop the entry "+
"from `enumSliceCoverage`), or oapi-codegen output shape "+
"changed (update `parseGeneratedEnumLiterals`).",
c.generatedTypeName,
)
}
expected = vals
}
actual := append([]string(nil), c.slice...)
sort.Strings(actual)
Expand Down Expand Up @@ -196,6 +239,12 @@ func parseGeneratedEnumLiterals() (map[string][]string, error) {
return out, nil
}

// endsWith is a tiny helper that mirrors `strings.HasSuffix` without
// pulling another import into this test file.
func endsWith(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

// diffStrings returns the elements present in `a` but not in `b`. Both
// inputs are pre-sorted by the caller.
func diffStrings(a, b []string) []string {
Expand Down
15 changes: 12 additions & 3 deletions internal/api/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,18 @@ var (
// The validator walks the struct and enforces two invariants:
// 1. Non-pointer fields must not be zero-valued (catches missing required fields
// that json.Unmarshal silently accepts).
// 2. Fields whose type implements Valid() bool must return true (catches enum
// values the provider doesn't recognize, e.g. after an API update adds a
// new variant).
// 2. Fields whose type implements Valid() bool must return true (catches
// known-bad values for the enums that survive spec-level relaxation —
// primarily single-value discriminator tags).
//
// Note on Postel's-Law tolerance (see `mini/runbooks/api-contract.md` § 3):
// multi-value enums on response-shaped DTOs are dropped from the spec
// before codegen, so those fields are emitted as plain `string` and
// trivially skip the `Valid()` check. That is the desired behaviour —
// adding a new wire-format value to e.g. `MonitorDto.type` must NOT
// break existing provider versions reading existing resources. The
// `Valid()` branch only fires for enums that intentionally remain
// strict (request DTOs and discriminator subtype tags).
//
// This is the Go equivalent of Zod safeParse (SDK-JS) and Pydantic model_validate
// (SDK-Python) — runtime response validation driven by the spec, with zero
Expand Down
Loading
Loading