Skip to content

Commit 6a90671

Browse files
committed
Fix metadata grain KeyError on http.query error responses
Since 3006.3 salt.utils.http.query (tornado backend) returns ``body`` on HTTPError but does not populate ``headers``. salt.grains.metadata._search() indexed ``linedata["headers"]`` unconditionally, so any recursive lookup that hit a 4xx/5xx (e.g. IMDS rejecting a sub-path produced by the legacy ``=``-splitter on an EC2 user-data line) crashed the whole grain load with:: [CRITICAL] Failed to load grains defined in grain file metadata.metadata ... KeyError: 'headers' Guard the headers indexing the same way ``body`` is already guarded: when ``headers`` is absent, treat it as "no Content-Type information" and fall through to the existing parsing path. Add three pytest regressions covering the missing-headers case (both on the initial query and on recursion) and a sanity guard for the existing ``application/octet-stream`` short-circuit. Fixes #65184
1 parent 1550160 commit 6a90671

3 files changed

Lines changed: 151 additions & 4 deletions

File tree

changelog/65184.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the EC2/cloud metadata grain crashing with ``KeyError: 'headers'`` when ``salt.utils.http.query`` returns an error response (4xx/5xx with a body, e.g. when the IMDS rejects a recursive sub-path lookup). Since 3006.3 the tornado backend has populated ``body`` on HTTPError without also populating ``headers``; the grain now treats the missing ``headers`` key as "no Content-Type information" instead of letting the lookup blow up the whole grain load.

salt/grains/metadata.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ def _search(prefix="latest/"):
4949
if "body" not in linedata:
5050
return ret
5151
body = salt.utils.stringutils.to_unicode(linedata["body"])
52-
if (
53-
linedata["headers"].get("Content-Type", "text/plain")
54-
== "application/octet-stream"
55-
):
52+
# Since 3006.3, salt.utils.http.query (tornado backend) returns ``body``
53+
# on HTTPError but does not populate ``headers``. Treat a missing
54+
# ``headers`` key as "no Content-Type information" rather than letting
55+
# KeyError propagate and break the whole grain load (#65184).
56+
response_headers = linedata.get("headers") or {}
57+
if response_headers.get("Content-Type", "text/plain") == "application/octet-stream":
5658
return body
5759
for line in body.split("\n"):
5860
if line.endswith("/"):
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
Unit tests for salt.grains.metadata
3+
4+
Regression coverage for #65184: when ``salt.utils.http.query`` returns an
5+
error response (4xx/5xx with a body, e.g. AWS IMDS returning HTTP 400 for a
6+
bogus path produced by the legacy ``=``-splitter), the tornado backend
7+
populates ``body`` and ``status`` but does NOT set ``headers``.
8+
``salt.grains.metadata._search()`` previously indexed ``linedata["headers"]``
9+
unconditionally and crashed with ``KeyError: 'headers'``, causing the entire
10+
metadata grain to fail to load with::
11+
12+
[CRITICAL] Failed to load grains defined in grain file metadata.metadata
13+
...
14+
KeyError: 'headers'
15+
"""
16+
17+
import logging
18+
19+
import pytest
20+
21+
import salt.grains.metadata as metadata
22+
import salt.utils.http as http
23+
from tests.support.mock import create_autospec, patch
24+
25+
log = logging.getLogger(__name__)
26+
27+
28+
@pytest.fixture
29+
def configure_loader_modules():
30+
return {metadata: {"__opts__": {"metadata_server_grains": "True"}}}
31+
32+
33+
def _make_mock_http(responses):
34+
"""
35+
Build a mock for salt.utils.http.query that returns canned responses
36+
keyed by URL. Unknown URLs return an empty body dict so the recursion
37+
terminates cleanly.
38+
"""
39+
40+
def mock_http(url="", headers=False, header_list=None, **_kwargs):
41+
if url in responses:
42+
return responses[url]
43+
# Default: empty body, plain text. Mirrors what the metadata
44+
# service does for absent leaves and lets recursion bottom out.
45+
return {"body": "", "headers": {"Content-Type": "text/plain"}}
46+
47+
return mock_http
48+
49+
50+
def test_search_handles_error_response_without_headers_65184():
51+
"""
52+
Regression for #65184: a recursive ``http.query`` call that returns an
53+
error-shaped response (``body`` present, ``headers`` absent — the shape
54+
produced by the tornado backend on HTTPError since 3006.3) must not
55+
crash ``_search()`` with ``KeyError: 'headers'``.
56+
57+
The reporter's traceback shows the crash happens on the recursive call
58+
triggered by a top-level metadata listing entry (the ``prefix == "latest/"``
59+
branch), where the recursive ``_search`` then calls ``http.query`` for
60+
``latest/dynamic/`` (or similar) and gets back an error response without a
61+
``headers`` key. Before the fix the indexing ``linedata["headers"]`` raised.
62+
After the fix the missing-headers case is treated like "no Content-Type
63+
information" and parsing proceeds.
64+
"""
65+
responses = {
66+
"http://169.254.169.254/latest/": {
67+
"body": "dynamic",
68+
"headers": {"Content-Type": "text/plain"},
69+
},
70+
# Recursive call: error-shape response. Body + status + error, NO
71+
# headers key. This is exactly what salt.utils.http.query returns on
72+
# tornado HTTPError since commit 43b7fb52842 (3006.3).
73+
"http://169.254.169.254/latest/dynamic/": {
74+
"body": "<html><body><h1>400 Bad request</h1></body></html>\n",
75+
"status": 400,
76+
"error": "HTTP 400: Bad request",
77+
},
78+
}
79+
80+
with patch(
81+
"salt.utils.http.query",
82+
create_autospec(
83+
http.query, autospec=True, side_effect=_make_mock_http(responses)
84+
),
85+
):
86+
# Must not raise KeyError. Whatever it returns for the bad leaf is
87+
# secondary; the contract is "do not crash the whole grain load".
88+
result = metadata.metadata()
89+
90+
assert isinstance(result, dict)
91+
assert "dynamic" in result
92+
93+
94+
def test_search_handles_missing_headers_on_initial_query_65184():
95+
"""
96+
Companion to the above: the very first call inside ``_search()`` can also
97+
produce a no-headers response (e.g. the metadata service returns 4xx for
98+
the top-level listing). The function must still return a dict instead of
99+
raising.
100+
"""
101+
responses = {
102+
"http://169.254.169.254/latest/": {
103+
"body": "some-error-body",
104+
"status": 400,
105+
"error": "HTTP 400: Bad request",
106+
},
107+
}
108+
109+
with patch(
110+
"salt.utils.http.query",
111+
create_autospec(
112+
http.query, autospec=True, side_effect=_make_mock_http(responses)
113+
),
114+
):
115+
result = metadata.metadata()
116+
117+
# Either an empty dict or a parsed body is acceptable; the contract is
118+
# "no KeyError".
119+
assert isinstance(result, (dict, str))
120+
121+
122+
def test_search_octet_stream_still_returns_body_verbatim():
123+
"""
124+
Sanity guard: the existing ``application/octet-stream`` short-circuit
125+
(return body verbatim) must keep working. The fix for #65184 must not
126+
regress that path.
127+
"""
128+
responses = {
129+
"http://169.254.169.254/latest/": {
130+
"body": "raw-octet-stream-payload",
131+
"headers": {"Content-Type": "application/octet-stream"},
132+
},
133+
}
134+
135+
with patch(
136+
"salt.utils.http.query",
137+
create_autospec(
138+
http.query, autospec=True, side_effect=_make_mock_http(responses)
139+
),
140+
):
141+
result = metadata.metadata()
142+
143+
# Body returned verbatim, not wrapped in a dict.
144+
assert result == "raw-octet-stream-payload"

0 commit comments

Comments
 (0)