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.
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/METADATA +159 -30
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/RECORD +19 -14
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/WHEEL +1 -1
- souschef/assessment.py +81 -25
- souschef/cli.py +265 -6
- souschef/converters/playbook.py +413 -156
- souschef/converters/template.py +122 -5
- souschef/core/ai_schemas.py +81 -0
- souschef/core/http_client.py +394 -0
- souschef/core/logging.py +344 -0
- souschef/core/metrics.py +73 -6
- souschef/core/url_validation.py +230 -0
- souschef/server.py +130 -0
- souschef/ui/app.py +20 -6
- souschef/ui/pages/ai_settings.py +151 -30
- souschef/ui/pages/chef_server_settings.py +300 -0
- souschef/ui/pages/cookbook_analysis.py +66 -10
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/licenses/LICENSE +0 -0
souschef/converters/playbook.py
CHANGED
|
@@ -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
|
|
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=
|
|
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":
|
|
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
|
|
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
|
|
281
|
-
if
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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[
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
358
|
-
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
|
|
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
|
-
|
|
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(
|
|
638
|
-
except Exception as
|
|
639
|
-
return
|
|
793
|
+
yaml.safe_load(playbook_content)
|
|
794
|
+
except Exception as exc:
|
|
795
|
+
return str(exc)
|
|
640
796
|
|
|
641
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
1106
|
-
|
|
1285
|
+
if "://" not in url_value:
|
|
1286
|
+
url_value = f"https://{{url_value}}"
|
|
1107
1287
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1288
|
+
parsed = urlparse(url_value)
|
|
1289
|
+
if parsed.scheme.lower() != "https":
|
|
1290
|
+
raise ValueError("Chef Server URL must use HTTPS")
|
|
1110
1291
|
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1131
|
-
""
|
|
1315
|
+
cleaned = parsed._replace(params="", query="", fragment="")
|
|
1316
|
+
return urlunparse(cleaned).rstrip("/")
|
|
1132
1317
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
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
|