mcp-souschef 3.2.0__py3-none-any.whl → 3.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,14 +39,15 @@ from souschef.core.path_utils import (
39
39
  safe_glob,
40
40
  safe_read_text,
41
41
  )
42
+ from souschef.core.url_validation import validate_user_provided_url
42
43
  from souschef.parsers.attributes import parse_attributes
43
44
  from souschef.parsers.recipe import parse_recipe
44
45
 
45
46
  # Optional AI provider imports
46
47
  try:
47
- import requests # type: ignore[import-untyped]
48
+ import requests
48
49
  except ImportError:
49
- requests = None
50
+ requests = None # type: ignore[assignment]
50
51
 
51
52
  try:
52
53
  from ibm_watsonx_ai import APIClient # type: ignore[import-not-found]
@@ -244,18 +245,36 @@ def _initialize_ai_client(
244
245
  if APIClient is None:
245
246
  return f"{ERROR_PREFIX} ibm_watsonx_ai library not available"
246
247
 
248
+ try:
249
+ validated_url = validate_user_provided_url(
250
+ base_url,
251
+ default_url="https://us-south.ml.cloud.ibm.com",
252
+ )
253
+ except ValueError as exc:
254
+ return f"{ERROR_PREFIX} Invalid Watsonx base URL: {exc}"
255
+
247
256
  return APIClient(
248
257
  api_key=api_key,
249
258
  project_id=project_id,
250
- url=base_url or "https://us-south.ml.cloud.ibm.com",
259
+ url=validated_url,
251
260
  )
252
261
  elif ai_provider.lower() == "lightspeed":
253
262
  if requests is None:
254
263
  return f"{ERROR_PREFIX} requests library not available"
255
264
 
265
+ try:
266
+ validated_url = validate_user_provided_url(
267
+ base_url,
268
+ default_url="https://api.redhat.com",
269
+ allowed_hosts={"api.redhat.com"},
270
+ strip_path=True,
271
+ )
272
+ except ValueError as exc:
273
+ return f"{ERROR_PREFIX} Invalid Lightspeed base URL: {exc}"
274
+
256
275
  return {
257
276
  "api_key": api_key,
258
- "base_url": base_url or "https://api.redhat.com",
277
+ "base_url": validated_url,
259
278
  }
260
279
  elif ai_provider.lower() == "github_copilot":
261
280
  return (
@@ -269,98 +288,227 @@ def _initialize_ai_client(
269
288
  return f"{ERROR_PREFIX} Unsupported AI provider: {ai_provider}"
270
289
 
271
290
 
272
- def _call_ai_api(
291
+ def _call_anthropic_api(
273
292
  client: Any,
274
- ai_provider: str,
275
293
  prompt: str,
276
294
  model: str,
277
295
  temperature: float,
278
296
  max_tokens: int,
297
+ response_format: dict[str, Any] | None = None,
279
298
  ) -> str:
280
- """Call the appropriate AI API based on provider."""
281
- if ai_provider.lower() == "anthropic":
299
+ """Call Anthropic API with optional structured output via tool calling."""
300
+ if response_format and response_format.get("type") == "json_object":
301
+ # Use tool calling for structured JSON responses
282
302
  response = client.messages.create(
283
303
  model=model,
284
304
  max_tokens=max_tokens,
285
305
  temperature=temperature,
286
306
  messages=[{"role": "user", "content": prompt}],
307
+ tools=[
308
+ {
309
+ "name": "format_response",
310
+ "description": "Format the response as structured JSON",
311
+ "input_schema": {
312
+ "type": "object",
313
+ "properties": {
314
+ "response": {
315
+ "type": "string",
316
+ "description": "The formatted response",
317
+ }
318
+ },
319
+ "required": ["response"],
320
+ },
321
+ }
322
+ ],
287
323
  )
324
+ # Extract from tool use or fallback to text
325
+ for block in response.content:
326
+ if hasattr(block, "type") and block.type == "tool_use":
327
+ return str(block.input.get("response", ""))
328
+ # Fallback to text content
288
329
  return str(response.content[0].text)
289
- elif ai_provider.lower() == "watson":
290
- response = client.generate_text(
291
- model_id=model,
292
- input=prompt,
293
- parameters={
294
- "max_new_tokens": max_tokens,
295
- "temperature": temperature,
296
- "min_new_tokens": 1,
297
- },
330
+ else:
331
+ # Standard text response
332
+ response = client.messages.create(
333
+ model=model,
334
+ max_tokens=max_tokens,
335
+ temperature=temperature,
336
+ messages=[{"role": "user", "content": prompt}],
298
337
  )
299
- return str(response["results"][0]["generated_text"])
300
- elif ai_provider.lower() == "lightspeed":
301
- if requests is None:
302
- return f"{ERROR_PREFIX} requests library not available"
338
+ return str(response.content[0].text)
303
339
 
304
- headers = {
305
- "Authorization": f"Bearer {client['api_key']}",
306
- "Content-Type": "application/json",
307
- }
308
- payload = {
309
- "model": model,
310
- "prompt": prompt,
311
- "max_tokens": max_tokens,
340
+
341
+ def _call_watson_api(
342
+ client: Any,
343
+ prompt: str,
344
+ model: str,
345
+ temperature: float,
346
+ max_tokens: int,
347
+ ) -> str:
348
+ """Call IBM Watsonx API."""
349
+ response = client.generate_text(
350
+ model_id=model,
351
+ input=prompt,
352
+ parameters={
353
+ "max_new_tokens": max_tokens,
312
354
  "temperature": temperature,
313
- }
314
- response = requests.post(
315
- f"{client['base_url']}/v1/completions",
316
- headers=headers,
317
- json=payload,
318
- timeout=60,
355
+ "min_new_tokens": 1,
356
+ },
357
+ )
358
+ return str(response["results"][0]["generated_text"])
359
+
360
+
361
+ def _call_lightspeed_api(
362
+ client: dict[str, str],
363
+ prompt: str,
364
+ model: str,
365
+ temperature: float,
366
+ max_tokens: int,
367
+ response_format: dict[str, Any] | None = None,
368
+ ) -> str:
369
+ """Call Red Hat Lightspeed API."""
370
+ if requests is None:
371
+ return f"{ERROR_PREFIX} requests library not available"
372
+
373
+ headers = {
374
+ "Authorization": f"Bearer {client['api_key']}",
375
+ "Content-Type": "application/json",
376
+ }
377
+ payload = {
378
+ "model": model,
379
+ "prompt": prompt,
380
+ "max_tokens": max_tokens,
381
+ "temperature": temperature,
382
+ }
383
+ if response_format:
384
+ payload["response_format"] = response_format
385
+
386
+ response = requests.post(
387
+ f"{client['base_url']}/v1/completions",
388
+ headers=headers,
389
+ json=payload,
390
+ timeout=60,
391
+ )
392
+ if response.status_code == 200:
393
+ return str(response.json()["choices"][0]["text"])
394
+ else:
395
+ return (
396
+ f"{ERROR_PREFIX} Red Hat Lightspeed API error: "
397
+ f"{response.status_code} - {response.text}"
319
398
  )
320
- if response.status_code == 200:
321
- return str(response.json()["choices"][0]["text"])
322
- else:
323
- return (
324
- f"{ERROR_PREFIX} Red Hat Lightspeed API error: "
325
- f"{response.status_code} - {response.text}"
326
- )
327
- elif ai_provider.lower() == "github_copilot":
328
- if requests is None:
329
- return f"{ERROR_PREFIX} requests library not available"
330
399
 
331
- headers = {
332
- "Authorization": f"Bearer {client['api_key']}",
333
- "Content-Type": "application/json",
334
- "User-Agent": "SousChef/1.0",
335
- }
336
- payload = {
337
- "model": model,
338
- "messages": [{"role": "user", "content": prompt}],
339
- "max_tokens": max_tokens,
340
- "temperature": temperature,
341
- }
342
- # GitHub Copilot uses OpenAI-compatible chat completions endpoint
343
- response = requests.post(
344
- f"{client['base_url']}/copilot/chat/completions",
345
- headers=headers,
346
- json=payload,
347
- timeout=60,
400
+
401
+ def _call_github_copilot_api(
402
+ client: dict[str, str],
403
+ prompt: str,
404
+ model: str,
405
+ temperature: float,
406
+ max_tokens: int,
407
+ response_format: dict[str, Any] | None = None,
408
+ ) -> str:
409
+ """Call GitHub Copilot API."""
410
+ if requests is None:
411
+ return f"{ERROR_PREFIX} requests library not available"
412
+
413
+ headers = {
414
+ "Authorization": f"Bearer {client['api_key']}",
415
+ "Content-Type": "application/json",
416
+ "User-Agent": "SousChef/1.0",
417
+ }
418
+ payload = {
419
+ "model": model,
420
+ "messages": [{"role": "user", "content": prompt}],
421
+ "max_tokens": max_tokens,
422
+ "temperature": temperature,
423
+ }
424
+ if response_format:
425
+ payload["response_format"] = response_format
426
+
427
+ # GitHub Copilot uses OpenAI-compatible chat completions endpoint
428
+ response = requests.post(
429
+ f"{client['base_url']}/copilot/chat/completions",
430
+ headers=headers,
431
+ json=payload,
432
+ timeout=60,
433
+ )
434
+ if response.status_code == 200:
435
+ return str(response.json()["choices"][0]["message"]["content"])
436
+ else:
437
+ return (
438
+ f"{ERROR_PREFIX} GitHub Copilot API error: "
439
+ f"{response.status_code} - {response.text}"
440
+ )
441
+
442
+
443
+ def _call_openai_api(
444
+ client: Any,
445
+ prompt: str,
446
+ model: str,
447
+ temperature: float,
448
+ max_tokens: int,
449
+ response_format: dict[str, Any] | None = None,
450
+ ) -> str:
451
+ """Call OpenAI API."""
452
+ kwargs = {
453
+ "model": model,
454
+ "max_tokens": max_tokens,
455
+ "temperature": temperature,
456
+ "messages": [{"role": "user", "content": prompt}],
457
+ }
458
+ if response_format:
459
+ kwargs["response_format"] = response_format
460
+
461
+ response = client.chat.completions.create(**kwargs)
462
+ return str(response.choices[0].message.content)
463
+
464
+
465
+ def _call_ai_api(
466
+ client: Any,
467
+ ai_provider: str,
468
+ prompt: str,
469
+ model: str,
470
+ temperature: float,
471
+ max_tokens: int,
472
+ response_format: dict[str, Any] | None = None,
473
+ ) -> str:
474
+ """
475
+ Call the appropriate AI API based on provider.
476
+
477
+ Args:
478
+ client: Initialized AI client.
479
+ ai_provider: AI provider name.
480
+ prompt: Prompt text.
481
+ model: Model identifier.
482
+ temperature: Sampling temperature.
483
+ max_tokens: Maximum tokens in response.
484
+ response_format: Optional response format specification for structured
485
+ outputs. For OpenAI: {"type": "json_object"}. For Anthropic: Use
486
+ tool calling instead.
487
+
488
+ Returns:
489
+ AI-generated response text.
490
+
491
+ """
492
+ provider = ai_provider.lower()
493
+
494
+ if provider == "anthropic":
495
+ return _call_anthropic_api(
496
+ client, prompt, model, temperature, max_tokens, response_format
497
+ )
498
+ elif provider == "watson":
499
+ return _call_watson_api(client, prompt, model, temperature, max_tokens)
500
+ elif provider == "lightspeed":
501
+ return _call_lightspeed_api(
502
+ client, prompt, model, temperature, max_tokens, response_format
503
+ )
504
+ elif provider == "github_copilot":
505
+ return _call_github_copilot_api(
506
+ client, prompt, model, temperature, max_tokens, response_format
348
507
  )
349
- if response.status_code == 200:
350
- return str(response.json()["choices"][0]["message"]["content"])
351
- else:
352
- return (
353
- f"{ERROR_PREFIX} GitHub Copilot API error: "
354
- f"{response.status_code} - {response.text}"
355
- )
356
508
  else: # OpenAI
357
- response = client.chat.completions.create(
358
- model=model,
359
- max_tokens=max_tokens,
360
- temperature=temperature,
361
- messages=[{"role": "user", "content": prompt}],
509
+ return _call_openai_api(
510
+ client, prompt, model, temperature, max_tokens, response_format
362
511
  )
363
- return str(response.choices[0].message.content)
364
512
 
365
513
 
366
514
  def _create_ai_conversion_prompt(
@@ -523,6 +671,10 @@ def _build_conversion_requirements_parts() -> list[str]:
523
671
  "",
524
672
  "7. **Conditionals**: Convert Chef guards (only_if/not_if) to Ansible when",
525
673
  " conditions.",
674
+ " - For file or directory checks, add a stat task with register,",
675
+ " then use a boolean when expression like 'stat_result.stat.exists'.",
676
+ " - Do NOT put module names or task mappings under when.",
677
+ " - Keep when expressions as valid YAML scalars (strings or lists).",
526
678
  "",
527
679
  "8. **Notifications**: Convert Chef notifications to Ansible handlers",
528
680
  " where appropriate.",
@@ -618,7 +770,7 @@ def _build_output_format_parts() -> list[str]:
618
770
 
619
771
 
620
772
  def _clean_ai_playbook_response(ai_response: str) -> str:
621
- """Clean and validate the AI-generated playbook response."""
773
+ """Clean the AI-generated playbook response."""
622
774
  if not ai_response or not ai_response.strip():
623
775
  return f"{ERROR_PREFIX} AI returned empty response"
624
776
 
@@ -630,15 +782,19 @@ def _clean_ai_playbook_response(ai_response: str) -> str:
630
782
  if not cleaned.startswith("---") and not cleaned.startswith("- name:"):
631
783
  return f"{ERROR_PREFIX} AI response does not appear to be valid YAML playbook"
632
784
 
633
- # Try to parse as YAML to validate structure
785
+ return cleaned
786
+
787
+
788
+ def _validate_playbook_yaml(playbook_content: str) -> str | None:
789
+ """Validate YAML syntax and return an error message if invalid."""
634
790
  try:
635
791
  import yaml
636
792
 
637
- yaml.safe_load(cleaned)
638
- except Exception as e:
639
- return f"{ERROR_PREFIX} AI generated invalid YAML: {e}"
793
+ yaml.safe_load(playbook_content)
794
+ except Exception as exc:
795
+ return str(exc)
640
796
 
641
- return cleaned
797
+ return None
642
798
 
643
799
 
644
800
  def _validate_and_fix_playbook(
@@ -653,7 +809,13 @@ def _validate_and_fix_playbook(
653
809
  if playbook_content.startswith(ERROR_PREFIX):
654
810
  return playbook_content
655
811
 
656
- validation_error = _run_ansible_lint(playbook_content)
812
+ yaml_error = _validate_playbook_yaml(playbook_content)
813
+ validation_error: str | None
814
+ if yaml_error:
815
+ validation_error = f"YAML parse error: {yaml_error}"
816
+ else:
817
+ validation_error = _run_ansible_lint(playbook_content)
818
+
657
819
  if not validation_error:
658
820
  return playbook_content
659
821
 
@@ -687,6 +849,10 @@ Just the YAML content.
687
849
  # rather than returning an error string
688
850
  return playbook_content
689
851
 
852
+ fixed_yaml_error = _validate_playbook_yaml(cleaned_response)
853
+ if fixed_yaml_error:
854
+ return f"{ERROR_PREFIX} AI generated invalid YAML: {fixed_yaml_error}"
855
+
690
856
  return cleaned_response
691
857
  except Exception:
692
858
  # If fix fails, return original with warning (or original error)
@@ -1080,7 +1246,18 @@ def _generate_ansible_inventory_from_search(
1080
1246
 
1081
1247
  def _generate_inventory_script_content(queries_data: list[dict[str, str]]) -> str:
1082
1248
  """Generate Python dynamic inventory script content."""
1083
- script_template = '''#!/usr/bin/env python3
1249
+ # Convert queries_data to JSON string for embedding
1250
+ queries_json = json.dumps( # nosonar
1251
+ {
1252
+ item.get("group_name", f"group_{i}"): (
1253
+ item.get("search_query") or item.get("query", "")
1254
+ )
1255
+ for i, item in enumerate(queries_data)
1256
+ },
1257
+ indent=4,
1258
+ )
1259
+
1260
+ script_template = f'''#!/usr/bin/env python3
1084
1261
  """Dynamic Ansible Inventory Script.
1085
1262
 
1086
1263
  Generated from Chef search queries by SousChef
@@ -1089,96 +1266,118 @@ This script converts Chef search queries to Ansible inventory groups.
1089
1266
  Requires: python-requests (for Chef server API)
1090
1267
  """
1091
1268
  import json
1269
+ import os
1092
1270
  import sys
1093
1271
  import argparse
1272
+ import ipaddress
1273
+ from urllib.parse import urlparse, urlunparse
1094
1274
  from typing import Dict, List, Any
1095
1275
 
1096
- # Chef server configuration
1097
- CHEF_SERVER_URL = "https://your-chef-server"
1098
- CLIENT_NAME = "your-client-name"
1099
- CLIENT_KEY_PATH = "/path/to/client.pem"
1100
-
1101
1276
  # Search query to group mappings
1102
- SEARCH_QUERIES = {search_queries_json}
1277
+ SEARCH_QUERIES = {queries_json}
1103
1278
 
1279
+ def validate_chef_server_url(server_url: str) -> str:
1280
+ """Validate Chef Server URL to avoid unsafe requests."""
1281
+ url_value = str(server_url).strip()
1282
+ if not url_value:
1283
+ raise ValueError("Chef Server URL is required")
1104
1284
 
1105
- def get_chef_nodes(search_query: str) -> List[Dict[str, Any]]:
1106
- """Query Chef server for nodes matching search criteria.
1285
+ if "://" not in url_value:
1286
+ url_value = f"https://{{url_value}}"
1107
1287
 
1108
- Args:
1109
- search_query: Chef search query string
1288
+ parsed = urlparse(url_value)
1289
+ if parsed.scheme.lower() != "https":
1290
+ raise ValueError("Chef Server URL must use HTTPS")
1110
1291
 
1111
- Returns:
1112
- List of node objects from Chef server
1113
- """
1114
- # TODO: Implement Chef server API client
1115
- # This is a placeholder - implement Chef server communication
1116
- # using python-chef library or direct API calls
1292
+ if not parsed.hostname:
1293
+ raise ValueError("Chef Server URL must include a hostname")
1117
1294
 
1118
- # Example structure of what this should return:
1119
- return [
1120
- {
1121
- "name": "web01.example.com",
1122
- "roles": ["web"],
1123
- "environment": "production",
1124
- "platform": "ubuntu",
1125
- "ipaddress": "10.0.1.10"
1126
- }
1127
- ]
1295
+ hostname = parsed.hostname.lower()
1296
+ local_suffixes = (".localhost", ".local", ".localdomain", ".internal")
1297
+ if hostname == "localhost" or hostname.endswith(local_suffixes):
1298
+ raise ValueError("Chef Server URL must use a public hostname")
1128
1299
 
1300
+ try:
1301
+ ip_address = ipaddress.ip_address(hostname)
1302
+ except ValueError:
1303
+ ip_address = None
1304
+
1305
+ if ip_address and (
1306
+ ip_address.is_private
1307
+ or ip_address.is_loopback
1308
+ or ip_address.is_link_local
1309
+ or ip_address.is_reserved
1310
+ or ip_address.is_multicast
1311
+ or ip_address.is_unspecified
1312
+ ):
1313
+ raise ValueError("Chef Server URL must use a public hostname")
1129
1314
 
1130
- def build_inventory() -> Dict[str, Any]:
1131
- """Build Ansible inventory from Chef searches.
1315
+ cleaned = parsed._replace(params="", query="", fragment="")
1316
+ return urlunparse(cleaned).rstrip("/")
1132
1317
 
1133
- Returns:
1134
- Ansible inventory dictionary
1135
- """
1136
- inventory = {
1137
- "_meta": {
1138
- "hostvars": {}
1139
- }
1140
- }
1318
+ def get_chef_nodes(search_query: str) -> List[Dict[str, Any]]:
1319
+ """Query Chef server for nodes matching search criteria."""
1320
+ import requests
1321
+
1322
+ chef_server_url = os.environ.get("CHEF_SERVER_URL", "").rstrip("/")
1323
+ if not chef_server_url:
1324
+ return []
1325
+
1326
+ try:
1327
+ chef_server_url = validate_chef_server_url(chef_server_url)
1328
+ except ValueError:
1329
+ return []
1330
+
1331
+ try:
1332
+ search_url = f"{{chef_server_url}}/search/node?q={{search_query}}"
1333
+ response = requests.get(search_url, timeout=10)
1334
+ response.raise_for_status()
1335
+ search_result = response.json()
1336
+ nodes_data = []
1337
+
1338
+ for row in search_result.get("rows", []):
1339
+ node_obj = {{
1340
+ "name": row.get("name", "unknown"),
1341
+ "roles": row.get("run_list", []),
1342
+ "environment": row.get("chef_environment", "_default"),
1343
+ "platform": row.get("platform", "unknown"),
1344
+ "ipaddress": row.get("ipaddress", ""),
1345
+ "fqdn": row.get("fqdn", ""),
1346
+ }}
1347
+ nodes_data.append(node_obj)
1348
+ return nodes_data
1349
+ except Exception:
1350
+ return []
1351
+
1352
+ def build_inventory() -> Dict[str, Any]:
1353
+ """Build Ansible inventory from Chef searches."""
1354
+ inventory = {{"_meta": {{"hostvars": {{}}}}}}
1141
1355
 
1142
1356
  for group_name, search_query in SEARCH_QUERIES.items():
1143
- inventory[group_name] = {
1357
+ inventory[group_name] = {{
1144
1358
  "hosts": [],
1145
- "vars": {
1146
- "chef_search_query": search_query
1147
- }
1148
- }
1149
-
1359
+ "vars": {{"chef_search_query": search_query}},
1360
+ }}
1150
1361
  try:
1151
1362
  nodes = get_chef_nodes(search_query)
1152
-
1153
1363
  for node in nodes:
1154
1364
  hostname = node.get("name", node.get("fqdn", "unknown"))
1155
1365
  inventory[group_name]["hosts"].append(hostname)
1156
-
1157
- # Add host variables
1158
- inventory["_meta"]["hostvars"][hostname] = {
1366
+ inventory["_meta"]["hostvars"][hostname] = {{
1159
1367
  "chef_roles": node.get("roles", []),
1160
1368
  "chef_environment": node.get("environment", ""),
1161
1369
  "chef_platform": node.get("platform", ""),
1162
1370
  "ansible_host": node.get("ipaddress", hostname)
1163
- }
1164
-
1165
- except Exception as e:
1166
- print(
1167
- f"Error querying Chef server for group {group_name}: {e}",
1168
- file=sys.stderr,
1169
- )
1371
+ }}
1372
+ except Exception:
1373
+ pass
1170
1374
 
1171
1375
  return inventory
1172
1376
 
1173
-
1174
1377
  def main():
1175
1378
  """Main entry point for dynamic inventory script."""
1176
- parser = argparse.ArgumentParser(
1177
- description="Dynamic Ansible Inventory from Chef"
1178
- )
1179
- parser.add_argument(
1180
- "--list", action="store_true", help="List all groups and hosts"
1181
- )
1379
+ parser = argparse.ArgumentParser(description="Dynamic Ansible Inventory from Chef")
1380
+ parser.add_argument("--list", action="store_true", help="List all groups")
1182
1381
  parser.add_argument("--host", help="Get variables for specific host")
1183
1382
 
1184
1383
  args = parser.parse_args()
@@ -1187,26 +1386,84 @@ def main():
1187
1386
  inventory = build_inventory()
1188
1387
  print(json.dumps(inventory, indent=2))
1189
1388
  elif args.host:
1190
- # Return empty dict for host-specific queries
1191
- # All host vars are included in _meta/hostvars
1192
- print(json.dumps({}))
1389
+ print(json.dumps({{}}))
1193
1390
  else:
1194
1391
  parser.print_help()
1195
1392
 
1196
-
1197
1393
  if __name__ == "__main__":
1198
1394
  main()
1199
1395
  '''
1200
- # Convert queries_data to JSON string for embedding
1201
- queries_json = json.dumps( # nosonar
1202
- {
1203
- item.get("group_name", f"group_{i}"): item.get("search_query", "")
1204
- for i, item in enumerate(queries_data)
1205
- },
1206
- indent=4,
1207
- )
1396
+ return script_template
1397
+
1398
+
1399
+ def get_chef_nodes(search_query: str) -> list[dict[str, Any]]:
1400
+ """
1401
+ Query Chef server for nodes matching search criteria.
1402
+
1403
+ Communicates with Chef server API to search for nodes.
1404
+ Falls back to empty list if Chef server is unavailable.
1208
1405
 
1209
- return script_template.replace("{search_queries_json}", queries_json)
1406
+ Args:
1407
+ search_query: Chef search query string
1408
+
1409
+ Returns:
1410
+ List of node objects from Chef server
1411
+
1412
+ """
1413
+ if not requests:
1414
+ return []
1415
+
1416
+ chef_server_url = os.environ.get("CHEF_SERVER_URL", "").rstrip("/")
1417
+
1418
+ if not chef_server_url:
1419
+ # Chef server not configured - return empty list
1420
+ return []
1421
+
1422
+ try:
1423
+ chef_server_url = validate_user_provided_url(chef_server_url)
1424
+ except ValueError:
1425
+ return []
1426
+
1427
+ try:
1428
+ # Using Chef Server REST API search endpoint
1429
+ # Search endpoint: GET /search/node?q=<query>
1430
+ search_url = f"{chef_server_url}/search/node?q={search_query}"
1431
+
1432
+ # Note: Proper authentication requires Chef API signing
1433
+ # For unauthenticated access, this may work on open Chef servers
1434
+ # For production, use python-chef library for proper authentication
1435
+ response = requests.get(search_url, timeout=10)
1436
+ response.raise_for_status()
1437
+
1438
+ search_result = response.json()
1439
+ nodes_data = []
1440
+
1441
+ for row in search_result.get("rows", []):
1442
+ node_obj = {
1443
+ "name": row.get("name", "unknown"),
1444
+ "roles": row.get("run_list", []),
1445
+ "environment": row.get("chef_environment", "_default"),
1446
+ "platform": row.get("platform", "unknown"),
1447
+ "ipaddress": row.get("ipaddress", ""),
1448
+ "fqdn": row.get("fqdn", ""),
1449
+ "automatic": row.get("automatic", {}),
1450
+ }
1451
+ nodes_data.append(node_obj)
1452
+
1453
+ return nodes_data
1454
+
1455
+ except requests.exceptions.Timeout:
1456
+ # Chef server not responding within timeout
1457
+ return []
1458
+ except requests.exceptions.ConnectionError:
1459
+ # Cannot reach Chef server
1460
+ return []
1461
+ except requests.exceptions.HTTPError:
1462
+ # HTTP error (404, 403, 500, etc.)
1463
+ return []
1464
+ except Exception:
1465
+ # Fallback for any other errors
1466
+ return []
1210
1467
 
1211
1468
 
1212
1469
  # Search pattern extraction