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
25 changes: 25 additions & 0 deletions pkg/templates/livenessport/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ func (s *MissingLivenessPort) TestDeploymentWith() {
},
expected: nil,
},
{
name: "PortExposedViaArgs",
container: v1.Container{
Name: "manager",
Args: []string{
"--metrics-addr=0.0.0.0:8080",
"--health-probe-addr=:8081",
},
Ports: []v1.ContainerPort{
{
Name: "metrics",
ContainerPort: 8080,
Protocol: v1.ProtocolTCP,
},
},
LivenessProbe: &v1.Probe{
ProbeHandler: v1.ProbeHandler{
HTTPGet: &v1.HTTPGetAction{
Port: intstr.FromInt(8081),
},
},
},
},
expected: nil,
},
{
name: "MatchinPortStr",
container: v1.Container{
Expand Down
66 changes: 63 additions & 3 deletions pkg/templates/util/check_probe_port.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package util

import (
"fmt"
"strconv"
"strings"

"golang.stackrox.io/kube-linter/pkg/diagnostic"
v1 "k8s.io/api/core/v1"
Expand All @@ -25,27 +27,85 @@ func CheckProbePort(container *v1.Container, probe *v1.Probe) []diagnostic.Diagn
}

if httpProbe := probe.HTTPGet; httpProbe != nil {
if _, ok := ports[httpProbe.Port]; !ok {
if _, ok := ports[httpProbe.Port]; !ok && !probePortInArgs(container, httpProbe.Port) {
return []diagnostic.Diagnostic{{
Message: fmt.Sprintf("container %q does not expose port %s for the HTTPGet", container.Name, httpProbe.Port.String()),
}}
}
}

if tcpProbe := probe.TCPSocket; tcpProbe != nil {
if _, ok := ports[tcpProbe.Port]; !ok {
if _, ok := ports[tcpProbe.Port]; !ok && !probePortInArgs(container, tcpProbe.Port) {
return []diagnostic.Diagnostic{{
Message: fmt.Sprintf("container %q does not expose port %s for the TCPSocket", container.Name, tcpProbe.Port.String()),
}}
}
}

if grpcProbe := probe.GRPC; grpcProbe != nil {
if _, ok := ports[intstr.FromInt32(grpcProbe.Port)]; !ok {
if _, ok := ports[intstr.FromInt32(grpcProbe.Port)]; !ok && !probePortInArgs(container, intstr.FromInt32(grpcProbe.Port)) {
return []diagnostic.Diagnostic{{
Message: fmt.Sprintf("container %q does not expose port %d for the GRPC check", container.Name, grpcProbe.Port),
}}
}
}
return nil
}

// probePortInArgs reports whether the probe port is wired up through the
// container args or command rather than declared as a containerPort. A
// containerPort entry is informational only: a process can listen on any port
// regardless of whether it is declared. Charts such as opentelemetry-operator
// pass the health-probe address purely through a flag (for example
// "--health-probe-addr=:8081"), so the declared ports do not include it and the
// probe-port checks would otherwise report a false positive (see issue #1086).
//
// Only a numeric probe port can be matched this way; a named port (string)
// still has to resolve against a declared containerPort, so it is left to the
// caller's normal lookup.
func probePortInArgs(container *v1.Container, port intstr.IntOrString) bool {
if port.Type != intstr.Int {
return false
}
portNum := port.IntValue()
if portNum <= 0 {
return false
}
needle := strconv.Itoa(portNum)
for _, arg := range container.Args {
if argContainsPort(arg, needle) {
return true
}
}
for _, cmd := range container.Command {
if argContainsPort(cmd, needle) {
return true
}
}
return false
}

// argContainsPort reports whether needle (the port number rendered as a string)
// appears in arg as a standalone integer token, so that searching for "8081"
// does not match a larger number such as "18081" or "80818" (or a port that
// happens to be a substring of an image tag or version).
func argContainsPort(arg, needle string) bool {
for from := 0; ; {
idx := strings.Index(arg[from:], needle)
if idx < 0 {
return false
}
start := from + idx
end := start + len(needle)
beforeOK := start == 0 || !isDigit(arg[start-1])
afterOK := end == len(arg) || !isDigit(arg[end])
if beforeOK && afterOK {
return true
}
from = start + 1
}
}

func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}
125 changes: 125 additions & 0 deletions pkg/templates/util/check_probe_port_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package util

import (
"testing"

"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)

func TestCheckProbePort(t *testing.T) {
httpProbe := func(port intstr.IntOrString) *v1.Probe {
return &v1.Probe{ProbeHandler: v1.ProbeHandler{HTTPGet: &v1.HTTPGetAction{Port: port}}}
}

testCases := []struct {
name string
container v1.Container
probe *v1.Probe
wantDiag bool
}{
{
name: "nil probe is ignored",
container: v1.Container{Name: "c"},
probe: nil,
wantDiag: false,
},
{
name: "declared container port matches",
container: v1.Container{
Name: "c",
Ports: []v1.ContainerPort{{ContainerPort: 8080}},
},
probe: httpProbe(intstr.FromInt(8080)),
wantDiag: false,
},
{
name: "undeclared port not present anywhere is flagged",
container: v1.Container{
Name: "c",
Ports: []v1.ContainerPort{{ContainerPort: 8080}},
},
probe: httpProbe(intstr.FromInt(8081)),
wantDiag: true,
},
{
name: "port exposed via args is accepted (issue #1086)",
container: v1.Container{
Name: "manager",
Args: []string{"--metrics-addr=0.0.0.0:8080", "--health-probe-addr=:8081"},
Ports: []v1.ContainerPort{{Name: "metrics", ContainerPort: 8080, Protocol: v1.ProtocolTCP}},
},
probe: httpProbe(intstr.FromInt(8081)),
wantDiag: false,
},
{
name: "port exposed via command is accepted",
container: v1.Container{
Name: "manager",
Command: []string{"/manager", "--health-probe-addr=:8081"},
},
probe: httpProbe(intstr.FromInt(8081)),
wantDiag: false,
},
{
name: "port that is only a substring of a larger number is still flagged",
container: v1.Container{
Name: "manager",
Args: []string{"--metrics-addr=0.0.0.0:18081"},
},
probe: httpProbe(intstr.FromInt(8081)),
wantDiag: true,
},
{
name: "trailing-digit superset of the port is still flagged",
container: v1.Container{
Name: "manager",
Args: []string{"--addr=:80818"},
},
probe: httpProbe(intstr.FromInt(8081)),
wantDiag: true,
},
{
name: "named string probe port is not matched against args",
container: v1.Container{
Name: "manager",
Args: []string{"--health-probe-name=healthz"},
},
probe: httpProbe(intstr.FromString("healthz")),
wantDiag: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := tc.container
diags := CheckProbePort(&c, tc.probe)
if tc.wantDiag {
assert.NotEmpty(t, diags, "expected a diagnostic")
} else {
assert.Empty(t, diags, "expected no diagnostic")
}
})
}
}

func TestArgContainsPort(t *testing.T) {
cases := []struct {
arg string
needle string
want bool
}{
{"--health-probe-addr=:8081", "8081", true},
{"--addr=0.0.0.0:8081", "8081", true},
{"8081", "8081", true},
{"--addr=:18081", "8081", false},
{"--addr=:80818", "8081", false},
{"--addr=:808", "8081", false},
{"--flag", "8081", false},
{"--addr=:8081 --other=:8081", "8081", true},
}
for _, c := range cases {
assert.Equalf(t, c.want, argContainsPort(c.arg, c.needle), "argContainsPort(%q, %q)", c.arg, c.needle)
}
}
12 changes: 12 additions & 0 deletions tests/checks/liveness-port.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ spec:
httpGet:
path: "/"
port: 8080
- name: dont-fire-deployment-port-in-args
args:
- --metrics-addr=0.0.0.0:8080
- --health-probe-addr=:8081
ports:
- containerPort: 8080
name: metrics
protocol: TCP
livenessProbe:
httpGet:
path: "/healthz"
port: 8081
- name: fire-deployment-name
livenessProbe:
httpGet:
Expand Down
Loading