From fd56c1b37b770521be948193c1f85c26e05befd9 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 11 Dec 2025 10:27:14 +0100 Subject: [PATCH 1/3] Specify initial rule set in firewall spec --- api/v2/types_firewall.go | 43 ++++++++++ api/v2/zz_generated.deepcopy.go | 84 +++++++++++++++++++ ...ll.metal-stack.io_firewalldeployments.yaml | 69 +++++++++++++++ .../firewall.metal-stack.io_firewalls.yaml | 65 ++++++++++++++ .../firewall.metal-stack.io_firewallsets.yaml | 69 +++++++++++++++ controllers/firewall/reconcile.go | 48 ++++++++--- 6 files changed, 366 insertions(+), 12 deletions(-) diff --git a/api/v2/types_firewall.go b/api/v2/types_firewall.go index 34b18e8..373107e 100644 --- a/api/v2/types_firewall.go +++ b/api/v2/types_firewall.go @@ -74,6 +74,9 @@ type FirewallSpec struct { // EgressRules contains egress rules configured for this firewall. EgressRules []EgressRuleSNAT `json:"egressRules,omitempty"` + // InitialRuleSet is the initial firewall ruleset applied before the firewall-controller starts running. + InitialRuleSet *InitialRuleSet `json:"initialRuleSet,omitempty"` + // Interval on which rule reconciliation by the firewall-controller should happen. Interval string `json:"interval,omitempty"` // DryRun if set to true, firewall rules are not applied. For devel-purposes only. @@ -122,6 +125,46 @@ type FirewallTemplateSpec struct { Spec FirewallSpec `json:"spec,omitempty"` } +// InitialRuleSet is the initial rule set deployed on the firewall. +type InitialRuleSet struct { + // Egress rules to be deployed initially on the firewall. + Egress []EgressRule `json:"egress,omitempty"` + // Ingress rules to be deployed initially on the firewall. + Ingress []IngressRule `json:"ingress,omitempty"` +} + +// NetworkProtocol represents the kind of network protocol. +type NetworkProtocol string + +const ( + // NetworkProtocolTCP represents tcp connections. + NetworkProtocolTCP = "TCP" + // NetworkProtocolUDP represents udp connections. + NetworkProtocolUDP = "UDP" +) + +type EgressRule struct { + // Comment provides a human readable description of this rule. + Comment string `json:"comment,omitempty"` + // Ports contains all affected network ports. + Ports []int32 `json:"ports"` + // Protocol constraints the protocol this rule applies to. + Protocol NetworkProtocol `json:"protocol"` + // To source address cidrs this rule applies to. + To []string `json:"to"` +} + +type IngressRule struct { + // Comment provides a human readable description of this rule. + Comment string `json:"comment,omitempty"` + // Ports contains all affected network ports. + Ports []int32 `json:"ports"` + // Protocol constraints the protocol this rule applies to. + Protocol NetworkProtocol `json:"protocol"` + // From source address cidrs this rule applies to. + From []string `json:"from"` +} + // EgressRuleSNAT holds a Source-NAT rule type EgressRuleSNAT struct { // NetworkID is the network for which the egress rule will be configured. diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go index b9d9399..802ad3b 100644 --- a/api/v2/zz_generated.deepcopy.go +++ b/api/v2/zz_generated.deepcopy.go @@ -161,6 +161,31 @@ func (in DeviceStatsByDevice) DeepCopy() DeviceStatsByDevice { return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EgressRule) DeepCopyInto(out *EgressRule) { + *out = *in + if in.Ports != nil { + in, out := &in.Ports, &out.Ports + *out = make([]int32, len(*in)) + copy(*out, *in) + } + if in.To != nil { + in, out := &in.To, &out.To + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EgressRule. +func (in *EgressRule) DeepCopy() *EgressRule { + if in == nil { + return nil + } + out := new(EgressRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EgressRuleSNAT) DeepCopyInto(out *EgressRuleSNAT) { *out = *in @@ -633,6 +658,11 @@ func (in *FirewallSpec) DeepCopyInto(out *FirewallSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.InitialRuleSet != nil { + in, out := &in.InitialRuleSet, &out.InitialRuleSet + *out = new(InitialRuleSet) + (*in).DeepCopyInto(*out) + } if in.DNSPort != nil { in, out := &in.DNSPort, &out.DNSPort *out = new(uint) @@ -780,6 +810,60 @@ func (in IDSStatsByDevice) DeepCopy() IDSStatsByDevice { return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressRule) DeepCopyInto(out *IngressRule) { + *out = *in + if in.Ports != nil { + in, out := &in.Ports, &out.Ports + *out = make([]int32, len(*in)) + copy(*out, *in) + } + if in.From != nil { + in, out := &in.From, &out.From + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRule. +func (in *IngressRule) DeepCopy() *IngressRule { + if in == nil { + return nil + } + out := new(IngressRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InitialRuleSet) DeepCopyInto(out *InitialRuleSet) { + *out = *in + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]EgressRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Ingress != nil { + in, out := &in.Ingress, &out.Ingress + *out = make([]IngressRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InitialRuleSet. +func (in *InitialRuleSet) DeepCopy() *InitialRuleSet { + if in == nil { + return nil + } + out := new(InitialRuleSet) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InterfaceStat) DeepCopyInto(out *InterfaceStat) { *out = *in diff --git a/config/crds/firewall.metal-stack.io_firewalldeployments.yaml b/config/crds/firewall.metal-stack.io_firewalldeployments.yaml index 3100ea4..70fa79b 100644 --- a/config/crds/firewall.metal-stack.io_firewalldeployments.yaml +++ b/config/crds/firewall.metal-stack.io_firewalldeployments.yaml @@ -180,6 +180,75 @@ spec: Image is the os image of the firewall. An update on this field requires the recreation of the physical firewall and can therefore lead to traffic interruption for the cluster. type: string + initialRuleSet: + description: InitialRuleSet is the initial firewall ruleset + applied before the firewall-controller starts running. + properties: + egress: + description: Egress rules to be deployed initially on + the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + ports: + description: Ports contains all affected network + ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this + rule applies to. + type: string + to: + description: To target addresses this rule applies + to. May contain IPs or dns names. + items: + type: string + type: array + required: + - ports + - protocol + - to + type: object + type: array + ingress: + description: Ingress rules to be deployed initially on + the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + from: + description: From source addresses this rule applies + to. May contain IPs or dns names. + items: + type: string + type: array + ports: + description: Ports contains all affected network + ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this + rule applies to. + type: string + required: + - from + - ports + - protocol + type: object + type: array + type: object internalPrefixes: description: |- InternalPrefixes specify prefixes which are considered local to the partition or all regions. This is used for the traffic counters. diff --git a/config/crds/firewall.metal-stack.io_firewalls.yaml b/config/crds/firewall.metal-stack.io_firewalls.yaml index c82118b..0d32283 100644 --- a/config/crds/firewall.metal-stack.io_firewalls.yaml +++ b/config/crds/firewall.metal-stack.io_firewalls.yaml @@ -135,6 +135,71 @@ spec: Image is the os image of the firewall. An update on this field requires the recreation of the physical firewall and can therefore lead to traffic interruption for the cluster. type: string + initialRuleSet: + description: InitialRuleSet is the initial firewall ruleset applied + before the firewall-controller starts running. + properties: + egress: + description: Egress rules to be deployed initially on the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + ports: + description: Ports contains all affected network ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this rule + applies to. + type: string + to: + description: To target addresses this rule applies to. May + contain IPs or dns names. + items: + type: string + type: array + required: + - ports + - protocol + - to + type: object + type: array + ingress: + description: Ingress rules to be deployed initially on the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + from: + description: From source addresses this rule applies to. + May contain IPs or dns names. + items: + type: string + type: array + ports: + description: Ports contains all affected network ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this rule + applies to. + type: string + required: + - from + - ports + - protocol + type: object + type: array + type: object internalPrefixes: description: |- InternalPrefixes specify prefixes which are considered local to the partition or all regions. This is used for the traffic counters. diff --git a/config/crds/firewall.metal-stack.io_firewallsets.yaml b/config/crds/firewall.metal-stack.io_firewallsets.yaml index ae2878a..bbc7c44 100644 --- a/config/crds/firewall.metal-stack.io_firewallsets.yaml +++ b/config/crds/firewall.metal-stack.io_firewallsets.yaml @@ -172,6 +172,75 @@ spec: Image is the os image of the firewall. An update on this field requires the recreation of the physical firewall and can therefore lead to traffic interruption for the cluster. type: string + initialRuleSet: + description: InitialRuleSet is the initial firewall ruleset + applied before the firewall-controller starts running. + properties: + egress: + description: Egress rules to be deployed initially on + the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + ports: + description: Ports contains all affected network + ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this + rule applies to. + type: string + to: + description: To target addresses this rule applies + to. May contain IPs or dns names. + items: + type: string + type: array + required: + - ports + - protocol + - to + type: object + type: array + ingress: + description: Ingress rules to be deployed initially on + the firewall. + items: + properties: + comment: + description: Comment provides a human readable description + of this rule. + type: string + from: + description: From source addresses this rule applies + to. May contain IPs or dns names. + items: + type: string + type: array + ports: + description: Ports contains all affected network + ports. + items: + format: int32 + type: integer + type: array + protocol: + description: Protocol constraints the protocol this + rule applies to. + type: string + required: + - from + - ports + - protocol + type: object + type: array + type: object internalPrefixes: description: |- InternalPrefixes specify prefixes which are considered local to the partition or all regions. This is used for the traffic counters. diff --git a/controllers/firewall/reconcile.go b/controllers/firewall/reconcile.go index 89c07bc..d3ba472 100644 --- a/controllers/firewall/reconcile.go +++ b/controllers/firewall/reconcile.go @@ -46,7 +46,7 @@ func (c *controller) Reconcile(r *controllers.Ctx[*v2.Firewall]) error { return err } - // requeueing in order to continue checking progression + // requeuing in order to continue checking progression return controllers.RequeueAfter(10*time.Second, "firewall creation is progressing") case 1: f = fws[0] @@ -142,18 +142,42 @@ func (c *controller) createFirewall(r *controllers.Ctx[*v2.Firewall]) (*models.V tags = append(tags, v2.FirewallSetTag(ref.Name)) } + var rules *models.V1FirewallRules + if r.Target.Spec.InitialRuleSet != nil { + rules = &models.V1FirewallRules{} + + for _, rule := range r.Target.Spec.InitialRuleSet.Egress { + rules.Egress = append(rules.Egress, &models.V1FirewallEgressRule{ + Comment: rule.Comment, + Ports: rule.Ports, + Protocol: string(rule.Protocol), + To: rule.To, + }) + } + + for _, rule := range r.Target.Spec.InitialRuleSet.Ingress { + rules.Ingress = append(rules.Ingress, &models.V1FirewallIngressRule{ + Comment: rule.Comment, + From: rule.From, + Ports: rule.Ports, + Protocol: string(rule.Protocol), + }) + } + } + createRequest := &models.V1FirewallCreateRequest{ - Description: "created by firewall-controller-manager", - Name: r.Target.Name, - Hostname: r.Target.Name, - Sizeid: &r.Target.Spec.Size, - Projectid: &r.Target.Spec.Project, - Partitionid: &r.Target.Spec.Partition, - Imageid: &r.Target.Spec.Image, - SSHPubKeys: r.Target.Spec.SSHPublicKeys, - Networks: networks, - UserData: r.Target.Spec.Userdata, - Tags: tags, + Description: "created by firewall-controller-manager", + Name: r.Target.Name, + Hostname: r.Target.Name, + Sizeid: &r.Target.Spec.Size, + Projectid: &r.Target.Spec.Project, + Partitionid: &r.Target.Spec.Partition, + Imageid: &r.Target.Spec.Image, + SSHPubKeys: r.Target.Spec.SSHPublicKeys, + Networks: networks, + UserData: r.Target.Spec.Userdata, + Tags: tags, + FirewallRules: rules, } resp, err := c.c.GetMetal().Firewall().AllocateFirewall(firewall.NewAllocateFirewallParams().WithBody(createRequest).WithContext(r.Ctx), nil) From 8f860f2f5e1dddf976ff8f0bd3104b3183755161 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 13 Jan 2026 13:34:04 +0100 Subject: [PATCH 2/3] Add basic validation for initial rule set --- api/v2/validation/firewall.go | 33 +++++++++++++++++ api/v2/validation/firewall_test.go | 58 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/api/v2/validation/firewall.go b/api/v2/validation/firewall.go index 4ac05fc..1de03c8 100644 --- a/api/v2/validation/firewall.go +++ b/api/v2/validation/firewall.go @@ -109,6 +109,39 @@ func (*firewallValidator) validateSpec(f *v2.FirewallSpec, fldPath *field.Path) } } + if f.InitialRuleSet != nil { + for _, rule := range f.InitialRuleSet.Egress { + if rule.Protocol != v2.NetworkProtocolTCP && rule.Protocol != v2.NetworkProtocolUDP { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule"), rule.Protocol, fmt.Sprintf("protocol not supported: %v", rule.Protocol))) + } + for _, port := range rule.Ports { + if port < 0 || port > 65535 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule").Child("ports"), port, fmt.Sprintf("port is out of range: %v", port))) + } + } + for _, cidr := range rule.To { + if _, err := netip.ParsePrefix(cidr); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule").Child("to"), cidr, fmt.Sprintf("invalid cidr: %v", err))) + } + } + } + for _, rule := range f.InitialRuleSet.Ingress { + if rule.Protocol != v2.NetworkProtocolTCP && rule.Protocol != v2.NetworkProtocolUDP { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule"), rule.Protocol, fmt.Sprintf("protocol not supported: %v", rule.Protocol))) + } + for _, port := range rule.Ports { + if port < 0 || port > 65535 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule").Child("ports"), port, fmt.Sprintf("port is out of range: %v", port))) + } + } + for _, cidr := range rule.From { + if _, err := netip.ParsePrefix(cidr); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule").Child("from"), cidr, fmt.Sprintf("invalid cidr: %v", err))) + } + } + } + } + return allErrs } diff --git a/api/v2/validation/firewall_test.go b/api/v2/validation/firewall_test.go index a5aaf6d..cab2e36 100644 --- a/api/v2/validation/firewall_test.go +++ b/api/v2/validation/firewall_test.go @@ -114,6 +114,64 @@ func Test_firewallValidator_ValidateCreate(t *testing.T) { }, }, }, + + { + name: "invalid egress protocol in initial rule set", + mutateFn: func(f *v2.Firewall) *v2.Firewall { + f.Spec.InitialRuleSet = &v2.InitialRuleSet{ + Egress: []v2.EgressRule{ + { + Protocol: v2.NetworkProtocol("foo"), + }, + }, + } + return f + }, + wantErr: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: ` "firewall-123" is invalid: spec.initialRuleSet.egress.rule: Invalid value: "foo": protocol not supported: foo`, + }, + }, + }, + { + name: "invalid egress port range in initial rule set", + mutateFn: func(f *v2.Firewall) *v2.Firewall { + f.Spec.InitialRuleSet = &v2.InitialRuleSet{ + Egress: []v2.EgressRule{ + { + Protocol: v2.NetworkProtocolTCP, + Ports: []int32{65536}, + }, + }, + } + return f + }, + wantErr: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: ` "firewall-123" is invalid: spec.initialRuleSet.egress.rule.ports: Invalid value: 65536: port is out of range: 65536`, + }, + }, + }, + { + name: "invalid egress cidr in initial rule set", + mutateFn: func(f *v2.Firewall) *v2.Firewall { + f.Spec.InitialRuleSet = &v2.InitialRuleSet{ + Egress: []v2.EgressRule{ + { + Protocol: v2.NetworkProtocolTCP, + Ports: []int32{1234}, + To: []string{"1.2.3.4", "3.4.5.6/32"}, + }, + }, + } + return f + }, + wantErr: &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: ` "firewall-123" is invalid: spec.initialRuleSet.egress.rule.to: Invalid value: "1.2.3.4": invalid cidr: netip.ParsePrefix("1.2.3.4"): no '/'`, + }, + }, + }, } for _, tt := range tests { tt := tt From 479a72754e01bb9bdcc84697a757b531ea1068d5 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 15 Jan 2026 09:14:31 +0100 Subject: [PATCH 3/3] Dedicated validation functions --- api/v2/validation/firewall.go | 76 ++++++++++++++++++++---------- api/v2/validation/firewall_test.go | 2 +- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/api/v2/validation/firewall.go b/api/v2/validation/firewall.go index 1de03c8..7d3e60c 100644 --- a/api/v2/validation/firewall.go +++ b/api/v2/validation/firewall.go @@ -110,35 +110,21 @@ func (*firewallValidator) validateSpec(f *v2.FirewallSpec, fldPath *field.Path) } if f.InitialRuleSet != nil { + basePath := fldPath.Child("initialRuleSet") + for _, rule := range f.InitialRuleSet.Egress { - if rule.Protocol != v2.NetworkProtocolTCP && rule.Protocol != v2.NetworkProtocolUDP { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule"), rule.Protocol, fmt.Sprintf("protocol not supported: %v", rule.Protocol))) - } - for _, port := range rule.Ports { - if port < 0 || port > 65535 { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule").Child("ports"), port, fmt.Sprintf("port is out of range: %v", port))) - } - } - for _, cidr := range rule.To { - if _, err := netip.ParsePrefix(cidr); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("egress").Child("rule").Child("to"), cidr, fmt.Sprintf("invalid cidr: %v", err))) - } - } + egressPath := basePath.Child("egress").Child("rule") + + allErrs = append(allErrs, validateProtocol(rule.Protocol, egressPath.Child("protocol"))...) + allErrs = append(allErrs, validatePorts(rule.Ports, egressPath.Child("ports"))...) + allErrs = append(allErrs, validateCIDRs(rule.To, egressPath.Child("to"))...) } for _, rule := range f.InitialRuleSet.Ingress { - if rule.Protocol != v2.NetworkProtocolTCP && rule.Protocol != v2.NetworkProtocolUDP { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule"), rule.Protocol, fmt.Sprintf("protocol not supported: %v", rule.Protocol))) - } - for _, port := range rule.Ports { - if port < 0 || port > 65535 { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule").Child("ports"), port, fmt.Sprintf("port is out of range: %v", port))) - } - } - for _, cidr := range rule.From { - if _, err := netip.ParsePrefix(cidr); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("initialRuleSet").Child("ingress").Child("rule").Child("from"), cidr, fmt.Sprintf("invalid cidr: %v", err))) - } - } + ingressPath := basePath.Child("ingress").Child("rule") + + allErrs = append(allErrs, validateProtocol(rule.Protocol, ingressPath.Child("protocol"))...) + allErrs = append(allErrs, validatePorts(rule.Ports, ingressPath.Child("ports"))...) + allErrs = append(allErrs, validateCIDRs(rule.From, ingressPath.Child("from"))...) } } @@ -195,3 +181,41 @@ func validateDistance(distance v2.FirewallDistance, fldPath *field.Path) field.E return allErrs } + +func validateProtocol(protocol v2.NetworkProtocol, rulesPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if protocol != v2.NetworkProtocolTCP && protocol != v2.NetworkProtocolUDP { + allErrs = append(allErrs, field.Invalid(rulesPath, protocol, fmt.Sprintf("protocol not supported: %v", protocol))) + } + + return allErrs +} + +func validatePorts(ports []int32, rulesPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + const ( + minPort = 0 + maxPort = 65535 + ) + + for _, port := range ports { + if port < minPort || port > maxPort { + allErrs = append(allErrs, field.Invalid(rulesPath, port, fmt.Sprintf("port is out of range: %v", port))) + } + } + + return allErrs +} + +func validateCIDRs(cidrs []string, rulesPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + for _, cidr := range cidrs { + if _, err := netip.ParsePrefix(cidr); err != nil { + allErrs = append(allErrs, field.Invalid(rulesPath, cidr, fmt.Sprintf("invalid cidr: %v", err))) + } + } + + return allErrs +} diff --git a/api/v2/validation/firewall_test.go b/api/v2/validation/firewall_test.go index cab2e36..5830cac 100644 --- a/api/v2/validation/firewall_test.go +++ b/api/v2/validation/firewall_test.go @@ -129,7 +129,7 @@ func Test_firewallValidator_ValidateCreate(t *testing.T) { }, wantErr: &apierrors.StatusError{ ErrStatus: metav1.Status{ - Message: ` "firewall-123" is invalid: spec.initialRuleSet.egress.rule: Invalid value: "foo": protocol not supported: foo`, + Message: ` "firewall-123" is invalid: spec.initialRuleSet.egress.rule.protocol: Invalid value: "foo": protocol not supported: foo`, }, }, },