Skip to content

Commit dfd9ed0

Browse files
committed
fix(autoinstrumentation): skip unresolvable namespace rules instead of aborting
Evaluate policies strictly in order (first match wins). When a pod's namespace is absent from the workloadmeta store, a rule that reads namespace labels cannot be evaluated; ignore only that rule and keep evaluating the rest, so an unresolvable namespace rule never prevents an otherwise-matching rule from injecting. This replaces the legacy "abort all matching" fail-safe, which declined injection entirely as soon as it reached a rule whose namespace could not be resolved.
1 parent adfa114 commit dfd9ed0

4 files changed

Lines changed: 77 additions & 46 deletions

File tree

pkg/clusteragent/admission/mutate/autoinstrumentation/policy_matcher.go

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,50 +38,55 @@ func newPolicyMatcher(ps []policies.Policy, wmeta workloadmeta.Component) *polic
3838
// Match returns the outcome of the first policy that matches the pod, mirroring
3939
// the "first match wins" semantics of the target mutator.
4040
func (m *policyMatcher) Match(pod *corev1.Pod) (policies.Outcome, bool) {
41-
if m == nil || pod == nil {
42-
return policies.Outcome{}, false
43-
}
44-
facts, err := m.factsForPod(pod)
45-
if err != nil {
46-
log.Debugf("policy matcher: could not build facts for pod in %q: %v", pod.Namespace, err)
41+
idx := m.matchIndex(pod)
42+
if idx < 0 {
4743
return policies.Outcome{}, false
4844
}
49-
return policies.Decide(m.policies, facts)
45+
return m.policies[idx].Outcome, true
5046
}
5147

5248
// matchIndex returns the index of the first policy that matches the pod, or -1
53-
// if none match. It returns an error when a required fact (e.g. namespace
54-
// labels) could not be resolved, so the caller can abort matching rather than
55-
// risk an inaccurate decision.
56-
func (m *policyMatcher) matchIndex(pod *corev1.Pod) (int, error) {
49+
// if none match. Policies are evaluated in order (first match wins).
50+
//
51+
// A policy that reads namespace labels which could not be resolved (e.g. the
52+
// pod's namespace is absent from the workloadmeta store) is skipped rather than
53+
// fatal: it cannot be evaluated, so it is ignored, and the remaining policies
54+
// are still evaluated in order. This guarantees that an unrelated unresolvable
55+
// namespace rule never prevents an otherwise-matching rule from injecting.
56+
func (m *policyMatcher) matchIndex(pod *corev1.Pod) int {
5757
if m == nil || pod == nil {
58-
return -1, nil
59-
}
60-
facts, err := m.factsForPod(pod)
61-
if err != nil {
62-
return -1, err
58+
return -1
6359
}
60+
facts, namespaceLabelsResolved := m.factsForPod(pod)
6461
for i := range m.policies {
62+
if !namespaceLabelsResolved && nodeUsesNamespaceLabels(m.policies[i].Rules) {
63+
// Cannot evaluate this rule without namespace labels; ignore it.
64+
continue
65+
}
6566
if policies.Evaluate(m.policies[i].Rules, facts) == policies.ResultTrue {
66-
return i, nil
67+
return i
6768
}
6869
}
69-
return -1, nil
70+
return -1
7071
}
7172

72-
func (m *policyMatcher) factsForPod(pod *corev1.Pod) (policies.Facts, error) {
73+
// factsForPod builds the evaluation facts for a pod. The boolean reports whether
74+
// namespace labels were resolved: when a policy needs them but they cannot be
75+
// fetched, it is false and namespace-label rules are skipped by matchIndex.
76+
func (m *policyMatcher) factsForPod(pod *corev1.Pod) (policies.Facts, bool) {
7377
facts := policies.Facts{
7478
NamespaceName: pod.Namespace,
7579
PodLabels: pod.Labels,
7680
}
7781
if m.needsNamespaceLabels && m.wmeta != nil {
7882
nsLabels, err := getNamespaceLabels(m.wmeta, pod.Namespace)
7983
if err != nil {
80-
return facts, err
84+
log.Debugf("policy matcher: namespace labels unavailable for namespace %q, namespace-label rules will be skipped: %v", pod.Namespace, err)
85+
return facts, false
8186
}
8287
facts.NamespaceLabels = nsLabels
8388
}
84-
return facts, nil
89+
return facts, true
8590
}
8691

8792
// usesNamespaceLabels reports whether any policy rule reads a namespace label,

pkg/clusteragent/admission/mutate/autoinstrumentation/target_matching_change_test.go

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,23 @@ package autoinstrumentation
99

1010
import "testing"
1111

12-
// TestMatching_NamespaceMissingFromStore documents an intentional behavior change
13-
// introduced by the policy-engine matcher.
12+
// TestMatching_UnresolvableNamespaceRuleIsSkipped documents an intentional
13+
// behavior change introduced by the policy-engine matcher.
1414
//
15-
// When a pod's namespace is absent from the workloadmeta store and at least one
16-
// target reads namespace labels, the matcher cannot resolve the facts it needs
17-
// and declines to inject (fail-safe: no injection on an unresolved namespace).
15+
// When a pod's namespace is absent from the workloadmeta store, a rule that
16+
// reads namespace labels cannot be evaluated. The matcher now ignores only that
17+
// rule and keeps evaluating the remaining rules in order, so an unresolvable
18+
// namespace rule never blocks an otherwise-matching rule from injecting.
1819
//
19-
// The legacy label-selector matcher was order-dependent here: a pod-only target
20-
// placed before the namespace-label target would match without ever resolving
21-
// namespace labels, so the same pod would be injected. The policy matcher
22-
// resolves namespace labels once, up front, which removes that order dependence
23-
// at the cost of not matching the earlier pod-only target in this case. This is
24-
// considered an improvement (consistent, order-independent fail-safe) and is
25-
// pinned here so the behavior is explicit and intentional rather than accidental.
26-
func TestMatching_NamespaceMissingFromStore(t *testing.T) {
27-
const cfg = `
20+
// The legacy label-selector matcher instead aborted all matching as soon as it
21+
// reached a rule whose namespace could not be resolved (returning "no match").
22+
// It only injected in this situation by accident of ordering: if a matching
23+
// pod-only rule happened to come first, it short-circuited before the
24+
// unresolvable rule was reached. These cases pin the new, order-independent
25+
// behavior; the "rule first" case is the one that changes versus the legacy
26+
// matcher (it would previously have aborted).
27+
func TestMatching_UnresolvableNamespaceRuleIsSkipped(t *testing.T) {
28+
const podRuleFirst = `
2829
apm_config:
2930
instrumentation:
3031
enabled: true
@@ -42,9 +43,30 @@ apm_config:
4243
ddTraceVersions:
4344
python: "default"
4445
`
45-
// No namespace is registered in the store, so namespace-label resolution
46-
// fails for the "ghost" namespace and matching is aborted.
47-
runMatchCases(t, cfg, []matchCase{
48-
{name: "unresolved namespace declines injection even for an earlier pod-only target", ns: "ghost", podLabels: map[string]string{"app": "web"}, want: ""},
46+
const nsRuleFirst = `
47+
apm_config:
48+
instrumentation:
49+
enabled: true
50+
targets:
51+
- name: "ns-label"
52+
namespaceSelector:
53+
matchLabels:
54+
instrument: "true"
55+
ddTraceVersions:
56+
python: "default"
57+
- name: "pod-only"
58+
podSelector:
59+
matchLabels:
60+
app: "web"
61+
ddTraceVersions:
62+
java: "default"
63+
`
64+
// No namespace is registered in the store, so the namespace-label rule
65+
// cannot be evaluated for the "ghost" namespace and is skipped.
66+
runMatchCases(t, podRuleFirst, []matchCase{
67+
{name: "pod-only rule before the unresolvable rule still matches", ns: "ghost", podLabels: map[string]string{"app": "web"}, want: "pod-only"},
68+
})
69+
runMatchCases(t, nsRuleFirst, []matchCase{
70+
{name: "unresolvable rule first does not block the pod-only rule", ns: "ghost", podLabels: map[string]string{"app": "web"}, want: "pod-only"},
4971
})
5072
}

pkg/clusteragent/admission/mutate/autoinstrumentation/target_mutator.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,7 @@ func (m *TargetMutator) getMatchingTarget(pod *corev1.Pod) *targetInternal {
406406

407407
// The matcher and targets are aligned by index, so the first matching
408408
// policy resolves directly to its injection config (first match wins).
409-
idx, err := m.matcher.matchIndex(pod)
410-
if err != nil {
411-
log.Errorf("error encountered matching targets, aborting all together to avoid inaccurate match: %v", err)
412-
return nil
413-
}
409+
idx := m.matcher.matchIndex(pod)
414410
if idx < 0 || idx >= len(m.targets) {
415411
return nil
416412
}

pkg/clusteragent/admission/mutate/autoinstrumentation/target_mutator_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -664,15 +664,23 @@ func TestGetTargetLibraries(t *testing.T) {
664664
},
665665
expected: nil,
666666
},
667-
"missing namespace in store gets no tracers": {
667+
// When the namespace is absent from the store, the namespace-label rule
668+
// ("Enabled Prod Namespaces") cannot be evaluated and is skipped, so the
669+
// pod falls through to the selector-less "Default" target rather than
670+
// aborting all matching.
671+
"missing namespace in store falls through to the default target": {
668672
configPath: "testdata/filter.yaml",
669673
in: &corev1.Pod{
670674
ObjectMeta: metav1.ObjectMeta{
671675
Namespace: "foo",
672676
Labels: map[string]string{},
673677
},
674678
},
675-
expected: nil,
679+
expected: &targetInternal{
680+
libVersions: []libInfo{
681+
defaultLibInfoWithVersion(js, "v5"),
682+
},
683+
},
676684
},
677685
"unset tracer versions applies all tracers": {
678686
configPath: "testdata/filter.yaml",

0 commit comments

Comments
 (0)