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.
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 / "metadata.rb"
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("(Full conversion not yet implemented)")
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
- try:
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