Skip to content

Commit ee75c77

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 6746c31 commit ee75c77

3 files changed

Lines changed: 116 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("/"):

tests/pytests/unit/grains/test_metadata.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
instead of falling through to the ``=`` line-splitter, which previously
66
corrupted any user-data payload containing ``=`` characters
77
(e.g. cloud-init ``#cloud-config`` blocks with ``key=value`` lines).
8+
9+
Regression coverage for #65184: when ``salt.utils.http.query`` returns an
10+
error response (4xx/5xx with a body, e.g. AWS IMDS returning HTTP 400 for a
11+
bogus path produced by the legacy ``=``-splitter), the tornado backend
12+
populates ``body`` and ``status`` but does NOT set ``headers``.
13+
``salt.grains.metadata._search()`` previously indexed ``linedata["headers"]``
14+
unconditionally and crashed with ``KeyError: 'headers'``, causing the entire
15+
metadata grain to fail to load with::
16+
17+
[CRITICAL] Failed to load grains defined in grain file metadata.metadata
18+
...
19+
KeyError: 'headers'
820
"""
921

1022
import logging
@@ -176,3 +188,100 @@ def test_equals_lines_other_than_user_data_still_parse_via_splitter():
176188
sc = result["meta-data"]["iam"]["security-credentials"]
177189
assert "role-arn-suffix" in sc, sc
178190
assert "myrole-user-data=role-arn-suffix" not in sc, sc
191+
192+
193+
def test_search_handles_error_response_without_headers_65184():
194+
"""
195+
Regression for #65184: a recursive ``http.query`` call that returns an
196+
error-shaped response (``body`` present, ``headers`` absent — the shape
197+
produced by the tornado backend on HTTPError since 3006.3) must not
198+
crash ``_search()`` with ``KeyError: 'headers'``.
199+
200+
The reporter's traceback shows the crash happens on the recursive call
201+
triggered by a top-level metadata listing entry (the ``prefix == "latest/"``
202+
branch), where the recursive ``_search`` then calls ``http.query`` for
203+
``latest/dynamic/`` (or similar) and gets back an error response without a
204+
``headers`` key. Before the fix the indexing ``linedata["headers"]`` raised.
205+
After the fix the missing-headers case is treated like "no Content-Type
206+
information" and parsing proceeds.
207+
"""
208+
responses = {
209+
"http://169.254.169.254/latest/": {
210+
"body": "dynamic",
211+
"headers": {"Content-Type": "text/plain"},
212+
},
213+
# Recursive call: error-shape response. Body + status + error, NO
214+
# headers key. This is exactly what salt.utils.http.query returns on
215+
# tornado HTTPError since commit 43b7fb52842 (3006.3).
216+
"http://169.254.169.254/latest/dynamic/": {
217+
"body": "<html><body><h1>400 Bad request</h1></body></html>\n",
218+
"status": 400,
219+
"error": "HTTP 400: Bad request",
220+
},
221+
}
222+
223+
with patch(
224+
"salt.utils.http.query",
225+
create_autospec(
226+
http.query, autospec=True, side_effect=_make_mock_http(responses)
227+
),
228+
):
229+
# Must not raise KeyError. Whatever it returns for the bad leaf is
230+
# secondary; the contract is "do not crash the whole grain load".
231+
result = metadata.metadata()
232+
233+
assert isinstance(result, dict)
234+
assert "dynamic" in result
235+
236+
237+
def test_search_handles_missing_headers_on_initial_query_65184():
238+
"""
239+
Companion to the above: the very first call inside ``_search()`` can also
240+
produce a no-headers response (e.g. the metadata service returns 4xx for
241+
the top-level listing). The function must still return a dict instead of
242+
raising.
243+
"""
244+
responses = {
245+
"http://169.254.169.254/latest/": {
246+
"body": "some-error-body",
247+
"status": 400,
248+
"error": "HTTP 400: Bad request",
249+
},
250+
}
251+
252+
with patch(
253+
"salt.utils.http.query",
254+
create_autospec(
255+
http.query, autospec=True, side_effect=_make_mock_http(responses)
256+
),
257+
):
258+
result = metadata.metadata()
259+
260+
# Either an empty dict or a parsed body is acceptable; the contract is
261+
# "no KeyError".
262+
assert isinstance(result, (dict, str))
263+
264+
265+
def test_search_octet_stream_still_returns_body_verbatim():
266+
"""
267+
Sanity guard: the existing ``application/octet-stream`` short-circuit
268+
(return body verbatim) must keep working. The fix for #65184 must not
269+
regress that path.
270+
"""
271+
responses = {
272+
"http://169.254.169.254/latest/": {
273+
"body": "raw-octet-stream-payload",
274+
"headers": {"Content-Type": "application/octet-stream"},
275+
},
276+
}
277+
278+
with patch(
279+
"salt.utils.http.query",
280+
create_autospec(
281+
http.query, autospec=True, side_effect=_make_mock_http(responses)
282+
),
283+
):
284+
result = metadata.metadata()
285+
286+
# Body returned verbatim, not wrapped in a dict.
287+
assert result == "raw-octet-stream-payload"

0 commit comments

Comments
 (0)