Skip to content
Open
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
7 changes: 4 additions & 3 deletions internal/controller/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"

"github.com/apache/apisix-ingress-controller/internal/controller/status"
cutils "github.com/apache/apisix-ingress-controller/internal/controller/utils"
"github.com/apache/apisix-ingress-controller/internal/provider"
)

Expand All @@ -47,7 +48,7 @@ func NewCondition(observedGeneration int64, status bool, message string) metav1.
Type: ConditionTypeAvailable,
Reason: reason,
Status: condition,
Message: message,
Message: cutils.TruncateConditionMessage(message),
ObservedGeneration: observedGeneration,
}
}
Expand Down Expand Up @@ -79,7 +80,7 @@ func NewPolicyCondition(observedGeneration int64, status bool, message string) m
Type: string(gatewayv1alpha2.PolicyConditionAccepted),
Reason: reason,
Status: conditionStatus,
Message: message,
Message: cutils.TruncateConditionMessage(message),
ObservedGeneration: observedGeneration,
LastTransitionTime: metav1.Now(),
}
Expand All @@ -90,7 +91,7 @@ func NewPolicyConflictCondition(observedGeneration int64, message string) metav1
Type: string(gatewayv1alpha2.PolicyConditionAccepted),
Reason: string(gatewayv1alpha2.PolicyReasonConflicted),
Status: metav1.ConditionFalse,
Message: message,
Message: cutils.TruncateConditionMessage(message),
ObservedGeneration: observedGeneration,
LastTransitionTime: metav1.Now(),
}
Expand Down
38 changes: 37 additions & 1 deletion internal/controller/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,48 @@
package utils

import (
"unicode/utf8"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
"github.com/apache/apisix-ingress-controller/internal/utils"
)

const (
// maxConditionMessageBytes is the hard limit Kubernetes enforces on a
// status condition Message (status.conditions[].message). Updates whose
// message exceeds this are rejected with "Too long: may not be more than
// 32768 bytes".
maxConditionMessageBytes = 32768

// conditionMessageTruncationMarker is appended to a message that had to be
// truncated to fit within maxConditionMessageBytes.
conditionMessageTruncationMarker = " ... (truncated)"
)

// TruncateConditionMessage caps a condition Message so a status update is never
// rejected for exceeding Kubernetes' 32768-byte limit. Truncation is rune-safe:
// it trims back to a UTF-8 rune boundary (never splitting a multi-byte rune) and
// appends conditionMessageTruncationMarker to signal that content was dropped.
func TruncateConditionMessage(msg string) string {
if len(msg) <= maxConditionMessageBytes {
return msg
}

budget := maxConditionMessageBytes - len(conditionMessageTruncationMarker)
truncated := msg[:budget]
// Back off any partial trailing rune left by the byte-wise cut.
for len(truncated) > 0 {
if r, size := utf8.DecodeLastRuneInString(truncated); r == utf8.RuneError && size <= 1 {
truncated = truncated[:len(truncated)-1]
continue
}
break
}
return truncated + conditionMessageTruncationMarker
}

func SetApisixCRDConditionWithGeneration(status *apiv2.ApisixStatus, generation int64, condition metav1.Condition) {
condition.ObservedGeneration = generation
SetApisixCRDCondition(status, condition)
Expand Down Expand Up @@ -51,7 +87,7 @@ func NewConditionTypeAccepted(reason apiv2.ApisixRouteConditionReason, status bo
ObservedGeneration: generation,
LastTransitionTime: metav1.Now(),
Reason: string(reason),
Message: msg,
Message: TruncateConditionMessage(msg),
}
return condition
}
Expand Down
80 changes: 80 additions & 0 deletions internal/controller/utils/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package utils

import (
"strings"
"testing"
"unicode/utf8"
)

func TestTruncateConditionMessage(t *testing.T) {
t.Run("message under limit is unchanged", func(t *testing.T) {
msg := strings.Repeat("a", maxConditionMessageBytes)
if got := TruncateConditionMessage(msg); got != msg {
t.Fatalf("expected message to be unchanged, got len=%d", len(got))
}
})

t.Run("ascii message over limit is capped and marked", func(t *testing.T) {
msg := strings.Repeat("a", maxConditionMessageBytes+100)
got := TruncateConditionMessage(msg)
if len(got) > maxConditionMessageBytes {
t.Fatalf("truncated message len=%d exceeds limit %d", len(got), maxConditionMessageBytes)
}
if !strings.HasSuffix(got, conditionMessageTruncationMarker) {
t.Fatalf("truncated message does not end with marker: %q", got[len(got)-len(conditionMessageTruncationMarker):])
}
if !utf8.ValidString(got) {
t.Fatalf("truncated message is not valid UTF-8")
}
})

// Build a message from 3-byte runes so the byte budget lands in the middle
// of a rune, proving the cut backs off to a rune boundary.
t.Run("multi-byte rune straddling the boundary is rune-safe", func(t *testing.T) {
const rune3 = "中" // 3 bytes
msg := strings.Repeat(rune3, maxConditionMessageBytes) // ~3x over the limit
got := TruncateConditionMessage(msg)

if len(got) > maxConditionMessageBytes {
t.Fatalf("truncated message len=%d exceeds limit %d", len(got), maxConditionMessageBytes)
}
if !utf8.ValidString(got) {
t.Fatalf("truncated message is not valid UTF-8 (a rune was split)")
}
if !strings.HasSuffix(got, conditionMessageTruncationMarker) {
t.Fatalf("truncated message does not end with marker")
}
// The content before the marker must consist only of whole 3-byte runes.
content := strings.TrimSuffix(got, conditionMessageTruncationMarker)
if strings.Trim(content, rune3) != "" {
t.Fatalf("truncated content contains a partial rune")
}
})
}

// TestNewConditionTypeAcceptedTruncates ensures the constructor routes its
// Message through the cap so no status update can exceed the Kubernetes limit.
func TestNewConditionTypeAcceptedTruncates(t *testing.T) {
huge := strings.Repeat("x", maxConditionMessageBytes*2)
cond := NewConditionTypeAccepted("SyncFailed", false, 1, huge)
if len(cond.Message) > maxConditionMessageBytes {
t.Fatalf("condition message len=%d exceeds limit %d", len(cond.Message), maxConditionMessageBytes)
}
}
Loading