feat(rules): add service enforcement rules (#1982)

* fix(controller): decode old object for delete requests

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix: preserve ca-bundles injected from external providers

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat(rules): add service enforcement rules

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-06-24 11:20:23 +02:00
committed by GitHub
parent 7669abf063
commit 755cef54bf
63 changed files with 10168 additions and 1435 deletions
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"testing"
api "github.com/projectcapsule/capsule/pkg/api/rules"
)
func TestEnforceBodiesFromNamespaceRules(t *testing.T) {
tests := []struct {
name string
input []*api.NamespaceRuleBodyNamespace
assert func(t *testing.T, got []*api.NamespaceRuleEnforceBody)
}{
{
name: "nil input returns nil",
input: nil,
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
},
},
{
name: "empty input returns nil",
input: []*api.NamespaceRuleBodyNamespace{},
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
},
},
{
name: "only nil bodies returns empty slice",
input: []*api.NamespaceRuleBodyNamespace{
nil,
nil,
},
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if got == nil {
t.Fatalf("expected non-nil empty slice, got nil")
}
if len(got) != 0 {
t.Fatalf("expected empty slice, got len=%d", len(got))
}
},
},
{
name: "bodies without enforce are skipped",
input: []*api.NamespaceRuleBodyNamespace{
{},
{
Enforce: nil,
},
},
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if got == nil {
t.Fatalf("expected non-nil empty slice, got nil")
}
if len(got) != 0 {
t.Fatalf("expected empty slice, got len=%d", len(got))
}
},
},
{
name: "returns enforce bodies in original order",
input: func() []*api.NamespaceRuleBodyNamespace {
first := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeAllow,
}
second := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeDeny,
}
third := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeAudit,
}
return []*api.NamespaceRuleBodyNamespace{
{
Enforce: first,
},
nil,
{
Enforce: second,
},
{},
{
Enforce: third,
},
}
}(),
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if len(got) != 3 {
t.Fatalf("expected 3 enforce bodies, got %d", len(got))
}
if got[0].Action != api.ActionTypeAllow {
t.Fatalf("expected first action %q, got %q", api.ActionTypeAllow, got[0].Action)
}
if got[1].Action != api.ActionTypeDeny {
t.Fatalf("expected second action %q, got %q", api.ActionTypeDeny, got[1].Action)
}
if got[2].Action != api.ActionTypeAudit {
t.Fatalf("expected third action %q, got %q", api.ActionTypeAudit, got[2].Action)
}
},
},
{
name: "returns original enforce pointers without deep copy",
input: func() []*api.NamespaceRuleBodyNamespace {
enforce := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeAllow,
}
return []*api.NamespaceRuleBodyNamespace{
{
Enforce: enforce,
},
}
}(),
assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) {
t.Helper()
if len(got) != 1 {
t.Fatalf("expected one enforce body, got %d", len(got))
}
if got[0].Action != api.ActionTypeAllow {
t.Fatalf("expected action %q, got %q", api.ActionTypeAllow, got[0].Action)
}
got[0].Action = api.ActionTypeDeny
if got[0].Action != api.ActionTypeDeny {
t.Fatalf("expected returned pointer to be mutable")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EnforceBodiesFromNamespaceRules(tt.input)
tt.assert(t, got)
})
}
}
func TestEnforceBodiesFromNamespaceRulesReturnsOriginalPointers(t *testing.T) {
first := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeAllow,
}
second := &api.NamespaceRuleEnforceBody{
Action: api.ActionTypeDeny,
}
got := EnforceBodiesFromNamespaceRules([]*api.NamespaceRuleBodyNamespace{
{
Enforce: first,
},
{
Enforce: second,
},
})
if len(got) != 2 {
t.Fatalf("expected 2 enforce bodies, got %d", len(got))
}
if got[0] != first {
t.Fatalf("expected first returned enforce body to be the original pointer")
}
if got[1] != second {
t.Fatalf("expected second returned enforce body to be the original pointer")
}
}
+151 -23
View File
@@ -5,6 +5,7 @@ package ruleengine
import (
"fmt"
"strings"
api "github.com/projectcapsule/capsule/pkg/api/rules"
)
@@ -17,15 +18,27 @@ type Value struct {
type Match struct {
Matched bool
MatchedValue any
// Detail is optional human-readable matcher context.
// Example: "10.0.171.239 is contained in 10.0.0.0/16".
Detail string
}
type Decision struct {
SetName string
EventReason string
Action api.ActionType
Value Value
SetName string
EventReason string
Action api.ActionType
Value Value
MatchedValue any
Message string
// MatchedRule is the human-readable rule description returned by Set.RuleDescription.
MatchedRule string
// MatchDetail is the human-readable detail returned by Match.Detail.
MatchDetail string
Message string
}
type DecisionError struct {
@@ -77,18 +90,25 @@ func (e *Evaluation) Append(other *Evaluation) {
}
}
type Set[R any, T any] struct {
Name string
type Set[R any, O any] struct {
Name string
EventReason string
Values func(T) []Value
Rules func(*api.NamespaceRuleEnforceBody) []R
Values func(O) []Value
Rules func(*api.NamespaceRuleEnforceBody) []R
Matches func(R, Value) (Match, error)
Message func(action api.ActionType, value Value, matchedValue any) string
// Message can fully override the default message.
// Prefer leaving this nil unless a rule requires very specific wording.
Message func(api.ActionType, Value, any) string
// RuleDescription returns a human-readable representation of one rule.
// It is used only for admission/audit messages.
RuleDescription func(R) string
// AllowedDescription optionally overrides the "Allowed values" label.
// Example: "Allowed CIDRs", "Allowed ranges", "Allowed hostnames".
AllowedDescription string
}
func EvaluateEnforce[R any, T any](
@@ -125,6 +145,7 @@ func EvaluateEnforce[R any, T any](
}
hasAllowRule := false
allowRules := make([]R, 0)
var lastDecision *Decision
@@ -144,6 +165,8 @@ func EvaluateEnforce[R any, T any](
case api.ActionTypeAllow:
hasAllowRule = true
allowRules = append(allowRules, items...)
case api.ActionTypeDeny, api.ActionTypeAudit:
// Supported actions.
@@ -165,13 +188,24 @@ func EvaluateEnforce[R any, T any](
continue
}
matchedRule := describeRule(set, item)
decision := &Decision{
SetName: set.Name,
EventReason: set.EventReason,
Action: action,
Value: value,
MatchedValue: match.MatchedValue,
Message: decisionMessage(set, action, value, match.MatchedValue),
MatchedRule: matchedRule,
MatchDetail: strings.TrimSpace(match.Detail),
Message: decisionMessage(
set,
action,
value,
match.MatchedValue,
matchedRule,
match.Detail,
),
}
switch action {
@@ -205,12 +239,7 @@ func EvaluateEnforce[R any, T any](
EventReason: set.EventReason,
Action: api.ActionTypeDeny,
Value: value,
Message: fmt.Sprintf(
"%s %q at %s is not allowed by namespace rule",
set.Name,
value.Value,
value.Path,
),
Message: allowMissMessage(set, value, allowRules),
}
return evaluation, nil
@@ -220,41 +249,123 @@ func EvaluateEnforce[R any, T any](
return evaluation, nil
}
const maxRuleDescriptions = 10
func describeRule[R any, O any](set Set[R, O], rule R) string {
if set.RuleDescription == nil {
return ""
}
return strings.TrimSpace(set.RuleDescription(rule))
}
func describeRules[R any, O any](set Set[R, O], rules []R) string {
if len(rules) == 0 || set.RuleDescription == nil {
return ""
}
limit := min(len(rules), maxRuleDescriptions)
parts := make([]string, 0, limit)
for i := range limit {
description := describeRule(set, rules[i])
if description == "" {
continue
}
parts = append(parts, description)
}
if len(parts) == 0 {
return ""
}
if len(rules) > maxRuleDescriptions {
parts = append(parts, fmt.Sprintf("and %d more", len(rules)-maxRuleDescriptions))
}
return strings.Join(parts, ", ")
}
func allowedLabel[R any, O any](set Set[R, O]) string {
if set.AllowedDescription != "" {
return set.AllowedDescription
}
return "Allowed values"
}
func allowMissMessage[R any, T any](
set Set[R, T],
value Value,
allowRules []R,
) string {
message := fmt.Sprintf(
"%s %q at %s is not allowed by namespace rule",
set.Name,
value.Value,
value.Path,
)
descriptions := describeRules(set, allowRules)
if descriptions == "" {
return message
}
return fmt.Sprintf(
"%s: value did not match any allowed rule. %s: %s",
message,
allowedLabel(set),
descriptions,
)
}
func decisionMessage[R any, T any](
set Set[R, T],
action api.ActionType,
value Value,
matchedValue any,
matchedRule string,
matchDetail string,
) string {
if set.Message != nil {
return set.Message(action, value, matchedValue)
}
matchDetail = strings.TrimSpace(matchDetail)
switch action {
case api.ActionTypeAudit:
return fmt.Sprintf(
message := fmt.Sprintf(
"%s %q at %s matched audit namespace rule",
set.Name,
value.Value,
value.Path,
)
return appendMatchContext(message, matchedRule, matchDetail, "matched audit rule")
case api.ActionTypeDeny:
return fmt.Sprintf(
message := fmt.Sprintf(
"%s %q at %s is denied by namespace rule",
set.Name,
value.Value,
value.Path,
)
return appendMatchContext(message, matchedRule, matchDetail, "matched denied rule")
case api.ActionTypeAllow:
return fmt.Sprintf(
message := fmt.Sprintf(
"%s %q at %s is allowed by namespace rule",
set.Name,
value.Value,
value.Path,
)
return appendMatchContext(message, matchedRule, matchDetail, "matched allowed rule")
default:
return fmt.Sprintf(
"%s %q at %s matched namespace rule action %q",
@@ -265,3 +376,20 @@ func decisionMessage[R any, T any](
)
}
}
func appendMatchContext(
message string,
matchedRule string,
matchDetail string,
rulePrefix string,
) string {
if matchDetail != "" {
return fmt.Sprintf("%s: %s", message, matchDetail)
}
if matchedRule != "" {
return fmt.Sprintf("%s: %s %s", message, rulePrefix, matchedRule)
}
return message
}
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"fmt"
"net"
"regexp"
"strings"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rules"
)
func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error {
for i, rule := range bodies {
if rule == nil || rule.Enforce == nil {
continue
}
if err := validateWorkloadRules(i, rule.Enforce.Workloads); err != nil {
return err
}
if err := validateServiceRules(i, rule.Enforce.Services); err != nil {
return err
}
}
return nil
}
func validateWorkloadRules(
ruleIndex int,
workloads rules.NamespaceRuleEnforceWorkloadsBody,
) error {
for j, registry := range workloads.Registries {
if err := validateExpression(
registry.Expression,
fmt.Sprintf("rules[%d].enforce.workloads.registries[%d].exp", ruleIndex, j),
); err != nil {
return err
}
}
for j, scheduler := range workloads.Schedulers {
if err := validateExpression(
scheduler.Expression,
fmt.Sprintf("rules[%d].enforce.workloads.schedulers[%d].exp", ruleIndex, j),
); err != nil {
return err
}
}
return nil
}
func validateServiceRules(
ruleIndex int,
services rules.NamespaceRuleEnforceServicesBody,
) error {
for j, serviceType := range services.Types {
if err := validateServiceType(serviceType); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.types[%d] %q is invalid: %w",
ruleIndex,
j,
serviceType,
err,
)
}
}
if services.LoadBalancers != nil {
for j, cidr := range services.LoadBalancers.CIDRs {
if err := validateCIDR(cidr); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.loadBalancers.cidrs[%d] %q is invalid: %w",
ruleIndex,
j,
cidr,
err,
)
}
}
}
if services.ExternalNames != nil {
for j, hostname := range services.ExternalNames.Hostnames {
if err := validateExpressionMatch(
hostname,
fmt.Sprintf("rules[%d].enforce.services.externalNames.hostnames[%d]", ruleIndex, j),
); err != nil {
return err
}
}
}
if services.NodePorts != nil {
for j, portRange := range services.NodePorts.Ports {
if err := validateNodePortRange(portRange); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.nodePorts.ports[%d] is invalid: %w",
ruleIndex,
j,
err,
)
}
}
}
return nil
}
func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error {
if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil {
return err
}
return nil
}
func validateExpression(expression string, fieldPath string) error {
if strings.TrimSpace(expression) == "" {
return nil
}
if _, err := regexp.Compile(expression); err != nil {
return fmt.Errorf("%s %q is invalid: %w", fieldPath, expression, err)
}
return nil
}
func validateServiceType(serviceType rules.ServiceType) error {
switch serviceType {
case rules.ServiceTypeClusterIP,
rules.ServiceTypeNodePort,
rules.ServiceTypeLoadBalancer,
rules.ServiceTypeExternalName:
return nil
default:
return fmt.Errorf("unsupported service type")
}
}
func validateCIDR(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("CIDR is empty")
}
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
return fmt.Errorf("must be a valid IP or CIDR")
}
return nil
}
if _, _, err := net.ParseCIDR(raw); err != nil {
return err
}
return nil
}
func validateNodePortRange(portRange rules.ServiceNodePortRange) error {
if portRange.From < 1 || portRange.From > 65535 {
return fmt.Errorf("from %d must be between 1 and 65535", portRange.From)
}
if portRange.To < 1 || portRange.To > 65535 {
return fmt.Errorf("to %d must be between 1 and 65535", portRange.To)
}
if portRange.From > portRange.To {
return fmt.Errorf("from %d must be lower than or equal to %d", portRange.From, portRange.To)
}
return nil
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"strings"
"testing"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rules"
)
func TestValidateRuleStatusBody(t *testing.T) {
tests := []struct {
name string
bodies []*rules.NamespaceRuleBodyNamespace
wantErr string
}{
{
name: "nil bodies are valid",
bodies: []*rules.NamespaceRuleBodyNamespace{
nil,
{},
{
Enforce: nil,
},
},
},
{
name: "valid workload and service rules",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Action: rules.ActionTypeAllow,
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionRegex: api.ExpressionRegex{
Expression: "harbor/.*",
},
},
},
{
ExpressionMatch: api.ExpressionMatch{
Exact: []string{
"harbor/platform/debian:latest",
},
},
},
},
Schedulers: []api.ExpressionMatch{
{
ExpressionRegex: api.ExpressionRegex{
Expression: "tenant-[a-z0-9-]+",
},
},
},
},
Services: rules.NamespaceRuleEnforceServicesBody{
Types: []rules.ServiceType{
rules.ServiceTypeClusterIP,
rules.ServiceTypeNodePort,
rules.ServiceTypeLoadBalancer,
rules.ServiceTypeExternalName,
},
LoadBalancers: &rules.ServiceLoadBalancerRule{
CIDRs: []string{
"10.0.0.2/32",
"10.0.1.0/24",
"2001:db8::/32",
"10.0.0.3",
},
},
ExternalNames: &rules.ServiceExternalNameRule{
Hostnames: []api.ExpressionMatch{
{
Exact: []string{
"internal.git.com",
},
},
{
ExpressionRegex: api.ExpressionRegex{
Expression: ".*\\.example\\.com",
},
},
{
ExpressionRegex: api.ExpressionRegex{
Expression: "trusted\\..*",
Negate: true,
},
},
},
},
NodePorts: &rules.ServiceNodePortRule{
Ports: []rules.ServiceNodePortRange{
{
From: 30000,
To: 32767,
},
{
From: 30500,
To: 30500,
},
},
},
},
},
},
},
},
{
name: "invalid workload registry regex",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionRegex: api.ExpressionRegex{
Expression: "[",
},
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.workloads.registries[0].exp "[" is invalid`,
},
{
name: "invalid workload scheduler regex",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Schedulers: []api.ExpressionMatch{
{
ExpressionRegex: api.ExpressionRegex{
Expression: "[",
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.workloads.schedulers[0].exp "[" is invalid`,
},
{
name: "invalid service type",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
Types: []rules.ServiceType{
rules.ServiceTypeClusterIP,
rules.ServiceType("InvalidType"),
},
},
},
},
},
wantErr: `rules[0].enforce.services.types[1] "InvalidType" is invalid`,
},
{
name: "invalid loadBalancer CIDR",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
LoadBalancers: &rules.ServiceLoadBalancerRule{
CIDRs: []string{
"10.0.0.0/33",
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "10.0.0.0/33" is invalid`,
},
{
name: "empty loadBalancer CIDR",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
LoadBalancers: &rules.ServiceLoadBalancerRule{
CIDRs: []string{
"",
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`,
},
{
name: "invalid externalName hostname regex",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
ExternalNames: &rules.ServiceExternalNameRule{
Hostnames: []api.ExpressionMatch{
{
ExpressionRegex: api.ExpressionRegex{
Expression: "[",
},
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.externalNames.hostnames[0].exp "[" is invalid`,
},
{
name: "nodePort from greater than to",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
NodePorts: &rules.ServiceNodePortRule{
Ports: []rules.ServiceNodePortRange{
{
From: 32767,
To: 30000,
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to 30000`,
},
{
name: "nodePort from below valid port range",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
NodePorts: &rules.ServiceNodePortRule{
Ports: []rules.ServiceNodePortRange{
{
From: 0,
To: 30000,
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 0 must be between 1 and 65535`,
},
{
name: "nodePort to above valid port range",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
NodePorts: &rules.ServiceNodePortRule{
Ports: []rules.ServiceNodePortRange{
{
From: 30000,
To: 70000,
},
},
},
},
},
},
},
wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: to 70000 must be between 1 and 65535`,
},
{
name: "single nodePort range is valid",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
NodePorts: &rules.ServiceNodePortRule{
Ports: []rules.ServiceNodePortRange{
{
From: 30500,
To: 30500,
},
},
},
},
},
},
},
},
{
name: "reports correct indexes across multiple rules",
bodies: []*rules.NamespaceRuleBodyNamespace{
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
Types: []rules.ServiceType{
rules.ServiceTypeClusterIP,
},
},
},
},
{
Enforce: &rules.NamespaceRuleEnforceBody{
Services: rules.NamespaceRuleEnforceServicesBody{
ExternalNames: &rules.ServiceExternalNameRule{
Hostnames: []api.ExpressionMatch{
{
ExpressionRegex: api.ExpressionRegex{
Expression: "valid\\..*",
},
},
{
ExpressionRegex: api.ExpressionRegex{
Expression: "[",
},
},
},
},
},
},
},
},
wantErr: `rules[1].enforce.services.externalNames.hostnames[1].exp "[" is invalid`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRuleStatusBody(tt.bodies)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
})
}
}