|
5 | 5 | instead of falling through to the ``=`` line-splitter, which previously |
6 | 6 | corrupted any user-data payload containing ``=`` characters |
7 | 7 | (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' |
8 | 20 | """ |
9 | 21 |
|
10 | 22 | import logging |
@@ -176,3 +188,100 @@ def test_equals_lines_other_than_user_data_still_parse_via_splitter(): |
176 | 188 | sc = result["meta-data"]["iam"]["security-credentials"] |
177 | 189 | assert "role-arn-suffix" in sc, sc |
178 | 190 | 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