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/cli.py
CHANGED
|
@@ -7,12 +7,13 @@ Provides easy access to Chef cookbook parsing and conversion tools.
|
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import NoReturn
|
|
10
|
+
from typing import NoReturn, TypedDict
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
13
|
|
|
14
14
|
from souschef import __version__
|
|
15
15
|
from souschef.converters.playbook import generate_playbook_from_recipe
|
|
16
|
+
from souschef.core.logging import configure_logging
|
|
16
17
|
from souschef.core.path_utils import _normalize_path
|
|
17
18
|
from souschef.profiling import (
|
|
18
19
|
generate_cookbook_performance_report,
|
|
@@ -36,11 +37,25 @@ from souschef.server import (
|
|
|
36
37
|
read_file,
|
|
37
38
|
)
|
|
38
39
|
|
|
40
|
+
|
|
41
|
+
class ConversionResults(TypedDict):
|
|
42
|
+
"""Type definition for cookbook conversion results."""
|
|
43
|
+
|
|
44
|
+
cookbook_name: str
|
|
45
|
+
recipes: dict[str, str]
|
|
46
|
+
templates: dict[str, str]
|
|
47
|
+
attributes: dict[str, str]
|
|
48
|
+
metadata: dict[str, str] | str
|
|
49
|
+
|
|
50
|
+
|
|
39
51
|
# CI/CD job description constants
|
|
40
52
|
CI_JOB_LINT = " • Lint (cookstyle/foodcritic)"
|
|
41
53
|
CI_JOB_UNIT_TESTS = " • Unit Tests (ChefSpec)"
|
|
42
54
|
CI_JOB_INTEGRATION_TESTS = " • Integration Tests (Test Kitchen)"
|
|
43
55
|
|
|
56
|
+
# File name constants
|
|
57
|
+
METADATA_FILENAME = "metadata.rb"
|
|
58
|
+
|
|
44
59
|
|
|
45
60
|
def _resolve_output_path(output: str | None, default_path: Path) -> Path:
|
|
46
61
|
"""Normalise and validate output paths for generated files."""
|
|
@@ -335,7 +350,7 @@ def cookbook(cookbook_path: str, output: str | None, dry_run: bool) -> None:
|
|
|
335
350
|
click.echo("=" * 50)
|
|
336
351
|
|
|
337
352
|
# Parse metadata
|
|
338
|
-
metadata_file = cookbook_dir /
|
|
353
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
339
354
|
if metadata_file.exists():
|
|
340
355
|
click.echo("\n📋 Metadata:")
|
|
341
356
|
click.echo("-" * 50)
|
|
@@ -372,9 +387,124 @@ def cookbook(cookbook_path: str, output: str | None, dry_run: bool) -> None:
|
|
|
372
387
|
for template_file in templates_dir.glob("*.erb"):
|
|
373
388
|
_display_template_summary(template_file)
|
|
374
389
|
|
|
390
|
+
# Convert and save if output directory specified
|
|
375
391
|
if output and not dry_run:
|
|
392
|
+
_save_cookbook_conversion(cookbook_dir, output)
|
|
393
|
+
elif output and dry_run:
|
|
376
394
|
click.echo(f"\n💾 Would save results to: {output}")
|
|
377
|
-
click.echo("(
|
|
395
|
+
click.echo("(Dry run - no files will be written)")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _save_cookbook_conversion(cookbook_dir: Path, output_path: str) -> None:
|
|
399
|
+
"""
|
|
400
|
+
Convert and save cookbook to Ansible format.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
cookbook_dir: Path to Chef cookbook directory
|
|
404
|
+
output_path: Path to output directory for Ansible files
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
output_dir = Path(output_path)
|
|
408
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
|
|
410
|
+
click.echo(f"\n💾 Saving conversion to: {output_dir}")
|
|
411
|
+
click.echo("=" * 50)
|
|
412
|
+
|
|
413
|
+
results: ConversionResults = {
|
|
414
|
+
"cookbook_name": cookbook_dir.name,
|
|
415
|
+
"recipes": {},
|
|
416
|
+
"templates": {},
|
|
417
|
+
"attributes": {},
|
|
418
|
+
"metadata": {},
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# Convert metadata
|
|
422
|
+
metadata_file = cookbook_dir / METADATA_FILENAME
|
|
423
|
+
if metadata_file.exists():
|
|
424
|
+
click.echo("Converting metadata...")
|
|
425
|
+
metadata_result = read_cookbook_metadata(str(metadata_file))
|
|
426
|
+
results["metadata"] = metadata_result
|
|
427
|
+
|
|
428
|
+
# Save metadata as README
|
|
429
|
+
readme_path = output_dir / "README.md"
|
|
430
|
+
with readme_path.open("w") as f:
|
|
431
|
+
f.write(f"# {cookbook_dir.name} - Converted from Chef\n\n")
|
|
432
|
+
f.write("## Metadata\n\n")
|
|
433
|
+
f.write(metadata_result)
|
|
434
|
+
click.echo(f" ✓ Saved metadata to {readme_path}")
|
|
435
|
+
|
|
436
|
+
# Convert recipes to playbooks
|
|
437
|
+
recipes_dir = cookbook_dir / "recipes"
|
|
438
|
+
playbooks_dir = output_dir / "playbooks"
|
|
439
|
+
if recipes_dir.exists():
|
|
440
|
+
playbooks_dir.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
click.echo("\nConverting recipes to playbooks...")
|
|
442
|
+
|
|
443
|
+
for recipe_file in recipes_dir.glob("*.rb"):
|
|
444
|
+
playbook_name = recipe_file.stem
|
|
445
|
+
playbook_content = generate_playbook_from_recipe(str(recipe_file))
|
|
446
|
+
|
|
447
|
+
playbook_path = playbooks_dir / f"{playbook_name}.yml"
|
|
448
|
+
with playbook_path.open("w") as f:
|
|
449
|
+
f.write(playbook_content)
|
|
450
|
+
|
|
451
|
+
results["recipes"][playbook_name] = str(playbook_path)
|
|
452
|
+
click.echo(f" ✓ Converted {recipe_file.name} → {playbook_path}")
|
|
453
|
+
|
|
454
|
+
# Convert templates
|
|
455
|
+
templates_dir = cookbook_dir / "templates" / "default"
|
|
456
|
+
output_templates_dir = output_dir / "templates"
|
|
457
|
+
if templates_dir.exists():
|
|
458
|
+
from souschef.converters.template import convert_template_file
|
|
459
|
+
|
|
460
|
+
output_templates_dir.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
click.echo("\nConverting ERB templates to Jinja2...")
|
|
462
|
+
|
|
463
|
+
for template_file in templates_dir.glob("*.erb"):
|
|
464
|
+
template_result = convert_template_file(str(template_file))
|
|
465
|
+
|
|
466
|
+
if template_result.get("success"):
|
|
467
|
+
jinja_name = template_file.stem + ".j2"
|
|
468
|
+
jinja_path = output_templates_dir / jinja_name
|
|
469
|
+
|
|
470
|
+
with jinja_path.open("w") as f:
|
|
471
|
+
f.write(template_result.get("jinja2_template", ""))
|
|
472
|
+
|
|
473
|
+
results["templates"][template_file.name] = str(jinja_path)
|
|
474
|
+
click.echo(f" ✓ Converted {template_file.name} → {jinja_path}")
|
|
475
|
+
else:
|
|
476
|
+
click.echo(f" ✗ Failed to convert {template_file.name}")
|
|
477
|
+
|
|
478
|
+
# Parse and save attributes
|
|
479
|
+
attributes_dir = cookbook_dir / "attributes"
|
|
480
|
+
if attributes_dir.exists():
|
|
481
|
+
vars_dir = output_dir / "vars"
|
|
482
|
+
vars_dir.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
click.echo("\nExtracting attributes...")
|
|
484
|
+
|
|
485
|
+
for attr_file in attributes_dir.glob("*.rb"):
|
|
486
|
+
attr_result = parse_attributes(str(attr_file))
|
|
487
|
+
|
|
488
|
+
# Save as YAML vars file
|
|
489
|
+
vars_name = attr_file.stem + ".yml"
|
|
490
|
+
vars_path = vars_dir / vars_name
|
|
491
|
+
|
|
492
|
+
with vars_path.open("w") as f:
|
|
493
|
+
f.write("# Converted from Chef attributes\n")
|
|
494
|
+
f.write(f"# Source: {attr_file.name}\n\n")
|
|
495
|
+
f.write(attr_result)
|
|
496
|
+
|
|
497
|
+
results["attributes"][attr_file.name] = str(vars_path)
|
|
498
|
+
click.echo(f" ✓ Extracted {attr_file.name} → {vars_path}")
|
|
499
|
+
|
|
500
|
+
# Save conversion summary
|
|
501
|
+
summary_path = output_dir / "conversion_summary.json"
|
|
502
|
+
with summary_path.open("w") as f:
|
|
503
|
+
json.dump(results, f, indent=2)
|
|
504
|
+
|
|
505
|
+
click.echo("\n✅ Conversion complete!")
|
|
506
|
+
click.echo(f"📁 Output directory: {output_dir}")
|
|
507
|
+
click.echo(f"📄 Summary: {summary_path}")
|
|
378
508
|
|
|
379
509
|
|
|
380
510
|
@cli.command()
|
|
@@ -1219,10 +1349,9 @@ def ui(port: int) -> None:
|
|
|
1219
1349
|
Opens a web-based interface for interactive Chef to Ansible migration planning,
|
|
1220
1350
|
cookbook analysis, and visualization.
|
|
1221
1351
|
"""
|
|
1222
|
-
|
|
1223
|
-
import subprocess
|
|
1224
|
-
import sys
|
|
1352
|
+
import subprocess
|
|
1225
1353
|
|
|
1354
|
+
try:
|
|
1226
1355
|
# Launch Streamlit app
|
|
1227
1356
|
cmd = [
|
|
1228
1357
|
sys.executable,
|
|
@@ -1248,8 +1377,138 @@ def ui(port: int) -> None:
|
|
|
1248
1377
|
sys.exit(1)
|
|
1249
1378
|
|
|
1250
1379
|
|
|
1380
|
+
@cli.command("validate-chef-server")
|
|
1381
|
+
@click.option("--server-url", prompt="Chef Server URL", help="Chef Server base URL")
|
|
1382
|
+
@click.option("--node-name", default="admin", help="Chef node name for authentication")
|
|
1383
|
+
def validate_chef_server(server_url: str, node_name: str) -> None:
|
|
1384
|
+
"""
|
|
1385
|
+
Validate Chef Server connectivity and configuration.
|
|
1386
|
+
|
|
1387
|
+
Tests the connection to the Chef Server REST API to ensure it's
|
|
1388
|
+
reachable and properly configured.
|
|
1389
|
+
"""
|
|
1390
|
+
click.echo("🔍 Validating Chef Server connection...")
|
|
1391
|
+
from souschef.ui.pages.chef_server_settings import _validate_chef_server_connection
|
|
1392
|
+
|
|
1393
|
+
success, message = _validate_chef_server_connection(server_url, node_name)
|
|
1394
|
+
|
|
1395
|
+
if success:
|
|
1396
|
+
click.echo(f"✅ {message}")
|
|
1397
|
+
else:
|
|
1398
|
+
click.echo(f"❌ {message}", err=True)
|
|
1399
|
+
sys.exit(1)
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _display_node_text(node: dict) -> None:
|
|
1403
|
+
"""Display a single node's information in text format."""
|
|
1404
|
+
click.echo(f"\n 📍 {node.get('name', 'unknown')}")
|
|
1405
|
+
click.echo(f" Environment: {node.get('environment', '_default')}")
|
|
1406
|
+
click.echo(f" Platform: {node.get('platform', 'unknown')}")
|
|
1407
|
+
if node.get("ipaddress"):
|
|
1408
|
+
click.echo(f" IP: {node['ipaddress']}")
|
|
1409
|
+
if node.get("fqdn"):
|
|
1410
|
+
click.echo(f" FQDN: {node['fqdn']}")
|
|
1411
|
+
if node.get("roles"):
|
|
1412
|
+
click.echo(f" Roles: {', '.join(node['roles'])}")
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _output_chef_nodes(nodes: list, output_json: bool) -> None:
|
|
1416
|
+
"""Output nodes in requested format."""
|
|
1417
|
+
if output_json:
|
|
1418
|
+
click.echo(json.dumps(nodes, indent=2))
|
|
1419
|
+
else:
|
|
1420
|
+
for node in nodes:
|
|
1421
|
+
_display_node_text(node)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
@cli.command("query-chef-nodes")
|
|
1425
|
+
@click.option("--search-query", default="*:*", help="Chef search query for nodes")
|
|
1426
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
1427
|
+
def query_chef_nodes(search_query: str, output_json: bool) -> None:
|
|
1428
|
+
"""
|
|
1429
|
+
Query Chef Server for nodes matching search criteria.
|
|
1430
|
+
|
|
1431
|
+
Retrieves nodes from Chef Server that match the provided search query,
|
|
1432
|
+
extracting role assignments, environment, platform, and IP address
|
|
1433
|
+
information for dynamic inventory generation.
|
|
1434
|
+
"""
|
|
1435
|
+
import os
|
|
1436
|
+
|
|
1437
|
+
if not os.environ.get("CHEF_SERVER_URL"):
|
|
1438
|
+
click.echo("❌ CHEF_SERVER_URL not set", err=True)
|
|
1439
|
+
sys.exit(1)
|
|
1440
|
+
|
|
1441
|
+
click.echo(f"🔎 Querying Chef Server for nodes matching: {search_query}")
|
|
1442
|
+
|
|
1443
|
+
from souschef.converters.playbook import get_chef_nodes
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
nodes = get_chef_nodes(search_query)
|
|
1447
|
+
|
|
1448
|
+
if not nodes:
|
|
1449
|
+
click.echo("ℹ️ No nodes found matching the search query")
|
|
1450
|
+
return
|
|
1451
|
+
|
|
1452
|
+
click.echo(f"Found {len(nodes)} nodes:")
|
|
1453
|
+
_output_chef_nodes(nodes, output_json)
|
|
1454
|
+
except Exception as e:
|
|
1455
|
+
click.echo(f"❌ Error querying Chef Server: {e}", err=True)
|
|
1456
|
+
sys.exit(1)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
@cli.command("convert-template-ai")
|
|
1460
|
+
@click.argument("erb_path", type=click.Path(exists=True))
|
|
1461
|
+
@click.option("--ai/--no-ai", default=True, help="Use AI enhancement")
|
|
1462
|
+
@click.option("--output", type=click.Path(), help="Output path for template")
|
|
1463
|
+
def convert_template_ai(erb_path: str, ai: bool, output: str | None) -> None:
|
|
1464
|
+
"""
|
|
1465
|
+
Convert an ERB template to Jinja2 with optional AI assistance.
|
|
1466
|
+
|
|
1467
|
+
Converts Chef ERB templates to Ansible Jinja2 format with optional
|
|
1468
|
+
AI-based validation and improvement for complex Ruby logic.
|
|
1469
|
+
"""
|
|
1470
|
+
click.echo(f"🔄 Converting template: {erb_path}")
|
|
1471
|
+
if ai:
|
|
1472
|
+
click.echo("✨ Using AI enhancement for complex conversions")
|
|
1473
|
+
else:
|
|
1474
|
+
click.echo("📝 Using rule-based conversion only")
|
|
1475
|
+
|
|
1476
|
+
from souschef.converters.template import convert_template_with_ai
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
result = convert_template_with_ai(erb_path, ai_service=None)
|
|
1480
|
+
|
|
1481
|
+
if result.get("success"):
|
|
1482
|
+
method = result.get("conversion_method", "unknown")
|
|
1483
|
+
click.echo(f"✅ Conversion successful ({method})")
|
|
1484
|
+
|
|
1485
|
+
if output:
|
|
1486
|
+
output_path = Path(output)
|
|
1487
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1488
|
+
output_path.write_text(result.get("jinja2_output", ""))
|
|
1489
|
+
click.echo(f"💾 Converted template saved to: {output}")
|
|
1490
|
+
else:
|
|
1491
|
+
click.echo("\nConverted Template:")
|
|
1492
|
+
click.echo("-" * 50)
|
|
1493
|
+
click.echo(result.get("jinja2_output", ""))
|
|
1494
|
+
click.echo("-" * 50)
|
|
1495
|
+
|
|
1496
|
+
if result.get("warnings"):
|
|
1497
|
+
click.echo("\n⚠️ Warnings:")
|
|
1498
|
+
for warning in result["warnings"]:
|
|
1499
|
+
click.echo(f" - {warning}")
|
|
1500
|
+
else:
|
|
1501
|
+
error_msg = result.get("error", "Unknown error")
|
|
1502
|
+
click.echo(f"❌ Conversion failed: {error_msg}", err=True)
|
|
1503
|
+
sys.exit(1)
|
|
1504
|
+
except Exception as e:
|
|
1505
|
+
click.echo(f"❌ Error converting template: {e}", err=True)
|
|
1506
|
+
sys.exit(1)
|
|
1507
|
+
|
|
1508
|
+
|
|
1251
1509
|
def main() -> NoReturn:
|
|
1252
1510
|
"""Run the CLI."""
|
|
1511
|
+
configure_logging()
|
|
1253
1512
|
cli()
|
|
1254
1513
|
sys.exit(0)
|
|
1255
1514
|
|