From 22fda8d4960bfa7d2a5a34a83a94143160b6790d Mon Sep 17 00:00:00 2001 From: howlcipher Date: Thu, 13 Aug 2026 16:19:02 -0400 Subject: [PATCH] feat(core): land productization work (report, ai-test, xdg db, doctor status) --- .github/workflows/ci.yml | 21 +++ src/devops_learn/cli/commands/ai_test.py | 44 ++++++ src/devops_learn/cli/commands/init.py | 24 ++- src/devops_learn/cli/commands/report.py | 138 ++++++++++++++++++ src/devops_learn/cli/main.py | 98 +++++++++++-- src/devops_learn/config/settings.py | 2 + .../learning/persistence/connection.py | 4 +- src/devops_learn/policy/__init__.py | 0 src/devops_learn/policy/security/__init__.py | 0 .../policy}/security/devsecops.rego | 0 src/devops_learn/templates/__init__.py | 0 .../templates}/docker/Dockerfile.reference | 0 .../github_actions/ci.yml.reference | 0 .../kubernetes/deployment.yaml.reference | 0 .../environment_variable_config_example.py | 0 .../templates/terraform/__init__.py | 0 .../templates}/terraform/main.tf.reference | 0 src/devops_learn/tools/terraform_tool.py | 8 +- src/devops_learn/workflows/doctor_flow.py | 32 ++++ src/devops_learn/workflows/security_flow.py | 14 +- tests/workflows/test_deploy_flow.py | 3 +- tests/workflows/test_doctor_flow.py | 3 + 22 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 src/devops_learn/cli/commands/ai_test.py create mode 100644 src/devops_learn/cli/commands/report.py create mode 100644 src/devops_learn/policy/__init__.py create mode 100644 src/devops_learn/policy/security/__init__.py rename {policy => src/devops_learn/policy}/security/devsecops.rego (100%) create mode 100644 src/devops_learn/templates/__init__.py rename {templates => src/devops_learn/templates}/docker/Dockerfile.reference (100%) rename {templates => src/devops_learn/templates}/github_actions/ci.yml.reference (100%) rename {templates => src/devops_learn/templates}/kubernetes/deployment.yaml.reference (100%) rename {templates => src/devops_learn/templates}/python/environment_variable_config_example.py (100%) create mode 100644 src/devops_learn/templates/terraform/__init__.py rename {templates => src/devops_learn/templates}/terraform/main.tf.reference (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cd4fd5..a2423a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,24 @@ jobs: name: security-report path: artifacts/security/security-report.json if-no-files-found: warn + + packaging: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Build packages + run: | + pip install build + python -m build + - name: Test installation + run: | + python -m venv test-venv + test-venv/bin/pip install dist/*.whl + - name: Smoke test CLI + run: | + test-venv/bin/devops-learn doctor + test-venv/bin/devops-learn explain "Terraform state" + diff --git a/src/devops_learn/cli/commands/ai_test.py b/src/devops_learn/cli/commands/ai_test.py new file mode 100644 index 0000000..6f41526 --- /dev/null +++ b/src/devops_learn/cli/commands/ai_test.py @@ -0,0 +1,44 @@ +"""`devops-learn ai-test`: real AI provider smoke test.""" + +from __future__ import annotations +import argparse +from devops_learn.bootstrap import Platform +from devops_learn.cli.terminal_ui import TerminalUi +from devops_learn.domain.enums import ExplanationDepth + + +def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: + parser = subparsers.add_parser( + "ai-test", help="Smoke test the live AI provider configuration" + ) + parser.set_defaults(handler=run) + + +def run(args: argparse.Namespace, platform: Platform) -> None: + ui = TerminalUi() + ui.present("DEVOPS LEARN // AI SMOKE TEST") + ui.present("") + + provider_name = platform.llm.__class__.__name__ + + if provider_name == "MockLLMProvider": + ui.present("FAIL: Current provider is MockLLMProvider.") + ui.present("To test the live AI, configure DEVOPS_LEARN_AI_PROVIDER=anthropic") + ui.present("and ANTHROPIC_API_KEY.") + return + + ui.present(f"Provider: {provider_name}") + ui.present("Sending a small explanation request...") + + try: + explanation = platform.llm.explain_topic( + "HTTP Health Check", + depth=ExplanationDepth.BRIEF, + ) + ui.present("") + ui.present("SUCCESS: AI explanation received.") + ui.present(f"Title: {explanation.title}") + ui.present(f"Body: {explanation.body}") + except Exception as e: + ui.present("") + ui.present(f"FAIL: {str(e)}") diff --git a/src/devops_learn/cli/commands/init.py b/src/devops_learn/cli/commands/init.py index 0c79f10..02a1787 100644 --- a/src/devops_learn/cli/commands/init.py +++ b/src/devops_learn/cli/commands/init.py @@ -42,7 +42,7 @@ def run(args: argparse.Namespace, platform: Platform) -> None: profile = platform.learner_profile_service.load() if args.non_interactive: - _show_summary(ui, assessment, profile, EnvironmentKind.DEV, CostPriority.BALANCED) + _show_summary(platform, ui, assessment, profile, EnvironmentKind.DEV, CostPriority.BALANCED) return cloud = _ask_cloud(ui) @@ -52,7 +52,7 @@ def run(args: argparse.Namespace, platform: Platform) -> None: profile = _update_profile(profile, focus, ui) platform.learner_profile_service.save(profile) - _show_summary(ui, assessment, profile, environment, cost_priority, cloud=cloud) + _show_summary(platform, ui, assessment, profile, environment, cost_priority, cloud=cloud) def _render_assessment(assessment: ProjectAssessment) -> str: @@ -165,6 +165,7 @@ def _ask_proficiency(ui: TerminalUi, area: CompetencyArea) -> ProficiencyLevel: def _show_summary( + platform: Platform, ui: TerminalUi, assessment: ProjectAssessment, profile: LearnerProfile, @@ -186,8 +187,19 @@ def _show_summary( else: lines.append("Learning focus: none set") lines.append("") - lines.append( - "Run 'devops-learn analyze --mode collaborative --depth learning' " - "to start the workflow." - ) + + from devops_learn.workflows.doctor_flow import collect_doctor_report + report = collect_doctor_report(platform) + + if not report.local_workflow_ready: + lines.append( + "Next Step: Local workflow not ready. " + "Run 'devops-learn doctor' to fix dependencies." + ) + else: + lines.append( + f"Next Step: Run 'devops-learn analyze {assessment.root_path} " + "--mode collaborative --depth learning' to start the workflow." + ) + ui.present("\n".join(lines)) diff --git a/src/devops_learn/cli/commands/report.py b/src/devops_learn/cli/commands/report.py new file mode 100644 index 0000000..7c19e36 --- /dev/null +++ b/src/devops_learn/cli/commands/report.py @@ -0,0 +1,138 @@ +"""`devops-learn report`: evidence report generation.""" + +from __future__ import annotations + +import argparse +import json + +from devops_learn.bootstrap import Platform +from devops_learn.domain.enums import AuditEventType + + +def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: + parser = subparsers.add_parser( + "report", help="Generate an engineering evidence report" + ) + parser.add_argument( + "--latest", action="store_true", help="Report on the latest session" + ) + parser.add_argument( + "--session", type=int, help="Session ID to report (defaults to latest)" + ) + parser.add_argument( + "--format", + choices=["markdown", "json"], + default="markdown", + help="Output format", + ) + parser.add_argument("--output", help="Output file path (default: stdout)") + parser.set_defaults(handler=run) + + +def run(args: argparse.Namespace, platform: Platform) -> None: + session = platform.session_service.latest() + if not session: + print("No sessions found.") + return + assert session.id is not None + + events = platform.audit_service.history(session.id) + experiences = platform.experience_tracker.summary(session.id) + + event_types = {e.event_type for e in events} + + sections: dict[str, list[str]] = { + "REAL PASS": [], + "REAL FAIL": [], + "SIMULATED": [], + "NOT EXECUTED": [], + } + + if session.simulation_mode: + sections["SIMULATED"].extend( + ["Python tests", "Docker build", "Terraform validate", "Security scan"] + ) + sections["NOT EXECUTED"].append("Azure deployment") + else: + if any( + e.event_type == AuditEventType.TOOL_INVOKED + and isinstance(e.payload, dict) + and e.payload.get("tool") == "python" + for e in events + ): + sections["REAL PASS"].append("Python tests") + else: + sections["NOT EXECUTED"].append("Python tests") + + if any( + e.event_type == AuditEventType.TOOL_INVOKED + and isinstance(e.payload, dict) + and e.payload.get("tool") == "docker" + for e in events + ): + sections["REAL PASS"].append("Docker build") + else: + sections["NOT EXECUTED"].append("Docker build") + + if AuditEventType.TERRAFORM_PLAN_COMPLETED in event_types: + sections["REAL PASS"].append("Terraform validate") + else: + sections["NOT EXECUTED"].append("Terraform validate") + + if AuditEventType.SECURITY_SCAN_COMPLETED in event_types: + sections["REAL PASS"].append("Trivy security scan") + else: + sections["NOT EXECUTED"].append("Trivy security scan") + + if AuditEventType.DEPLOYMENT_SUCCEEDED in event_types: + sections["REAL PASS"].append("Azure deployment") + elif AuditEventType.DEPLOYMENT_FAILED in event_types: + sections["REAL FAIL"].append("Azure deployment") + else: + sections["NOT EXECUTED"].append("Azure deployment") + + if args.format == "json": + data = { + "project": session.project_root, + "session_id": session.id, + "started_at": str(session.started_at), + "execution": sections, + "learning_evidence": { + k: [e.item for e in v] for k, v in experiences.items() + }, + } + out = json.dumps(data, indent=2) + else: + lines = [ + "DEVOPS LEARN // EVIDENCE REPORT", + "", + "PROJECT", + session.project_root, + "", + "SESSION", + str(session.started_at), + "", + "EXECUTION", + "", + ] + + for state in ["REAL PASS", "REAL FAIL", "SIMULATED", "NOT EXECUTED"]: + for item in sections[state]: + lines.extend([state, item, ""]) + + lines.append("LEARNING EVIDENCE") + if not experiences: + lines.append("No evidence recorded in this session.") + else: + for concept, entries in experiences.items(): + for entry in entries: + lines.append(f"{concept:<19} {entry.state.value}") + + out = "\n".join(lines) + + if args.output: + with open(args.output, "w") as f: + f.write(out + "\n") + print(f"Report written to {args.output}") + else: + print(out) diff --git a/src/devops_learn/cli/main.py b/src/devops_learn/cli/main.py index f3ba43b..893eff0 100644 --- a/src/devops_learn/cli/main.py +++ b/src/devops_learn/cli/main.py @@ -17,14 +17,20 @@ init, local, profile, + report, review, security, terraform, + ai_test, ) from devops_learn.config.settings import load_settings from devops_learn.domain.enums import ExecutionMode from devops_learn.learning.persistence.connection import connect -from devops_learn.tools.approval import ApprovalGate, CliApprovalGate, ThresholdApprovalGate +from devops_learn.tools.approval import ( + ApprovalGate, + CliApprovalGate, + ThresholdApprovalGate, +) from devops_learn.tools.base import RiskLevel, Tool from devops_learn.tools.docker_tool import RealDockerTool, SimulatedDockerTool from devops_learn.tools.doctor_tool import EnvironmentDoctorTool @@ -51,6 +57,8 @@ deploy, destroy, doctor, + report, + ai_test, ) @@ -68,18 +76,87 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _show_first_run_screen() -> None: + import os + import sys + from devops_learn.config.settings import load_settings + from devops_learn.bootstrap import build_platform + from devops_learn.learning.persistence.connection import connect + from devops_learn.workflows.doctor_flow import collect_doctor_report + from devops_learn.tools.doctor_tool import EnvironmentDoctorTool + from devops_learn.tools.security_scanner_tool import SecurityScannerTool + from devops_learn.tools.policy_tool import PolicyTool + + def _c(text: str, code: str) -> str: + if ( + not sys.stdout.isatty() + or os.environ.get("NO_COLOR") + or os.environ.get("TERM") == "dumb" + ): + return text + return f"\033[{code}m{text}\033[0m" + + print(_c("DEVOPS LEARN", "1")) + print("\nAI-assisted DevOps mastery through real engineering.\n") + + settings = load_settings() + conn = connect(settings.db_path) + try: + platform = build_platform( + conn, + llm_provider=None, + approval_gate=CliApprovalGate(), + tools={ + "doctor": EnvironmentDoctorTool(), + "security_scanner": SecurityScannerTool(), + "security_policy": PolicyTool(), + }, + ) + report = collect_doctor_report(platform) + + print(_c("READY", "1")) + print( + f"{'Local environment':<23} {'YES' if report.local_workflow_ready else 'NO'}" + ) + print( + f"{'Security tooling':<23} {'YES' if report.security_workflow_ready else 'NO'}" + ) + print(f"{'Terraform':<23} {'YES' if report.terraform_planning_ready else 'NO'}") + print(f"{'Azure':<23} {'YES' if report.azure_deployment_ready else 'NO'}") + profile = platform.learner_profile_service.load() + has_profile = bool(profile.proficiencies or profile.learning_focus) + print(f"{'Learner profile':<23} {'YES' if has_profile else 'NO'}") + print("\n" + _c("NEXT", "1")) + + if not report.local_workflow_ready or not report.security_workflow_ready: + print("devops-learn doctor") + else: + print("devops-learn init ") + finally: + conn.close() + + def main(argv: Sequence[str] | None = None) -> None: + import sys + + effective_argv = sys.argv[1:] if argv is None else argv + if not effective_argv: + _show_first_run_screen() + return + parser = build_parser() - args = parser.parse_args(argv) + args = parser.parse_args(effective_argv) settings = load_settings() conn = connect(settings.db_path) try: - llm_provider = ( - AnthropicProvider(api_key=settings.anthropic_api_key) - if settings.anthropic_api_key - else None - ) + if settings.ai_provider == "anthropic" or ( + settings.ai_provider == "auto" and settings.anthropic_api_key + ): + llm_provider = AnthropicProvider(api_key=settings.anthropic_api_key) + else: + llm_provider = None + approval_gate = _approval_gate_for_args(args) tools = _tools_for_args(args) platform = build_platform( @@ -99,14 +176,17 @@ def _approval_gate_for_args(args: argparse.Namespace) -> ApprovalGate: def _tools_for_args(args: argparse.Namespace) -> dict[str, Tool] | None: - if args.command == "doctor": + if args.command in {"doctor", "init"}: return { "doctor": EnvironmentDoctorTool(), "security_scanner": SecurityScannerTool(), "security_policy": PolicyTool(), } if args.command == "security": - return {"security_scanner": SecurityScannerTool(), "security_policy": PolicyTool()} + return { + "security_scanner": SecurityScannerTool(), + "security_policy": PolicyTool(), + } if args.command == "terraform": # This command only ever invokes the "terraform" tool -- keep the rest # simulated rather than provisioning real python/docker tools it never uses. diff --git a/src/devops_learn/config/settings.py b/src/devops_learn/config/settings.py index bcdcda9..c4d51ca 100644 --- a/src/devops_learn/config/settings.py +++ b/src/devops_learn/config/settings.py @@ -13,6 +13,7 @@ class Settings: db_path: Path anthropic_api_key: str | None + ai_provider: str def load_settings() -> Settings: @@ -21,4 +22,5 @@ def load_settings() -> Settings: return Settings( db_path=db_path, anthropic_api_key=os.environ.get("ANTHROPIC_API_KEY"), + ai_provider=os.environ.get("DEVOPS_LEARN_AI_PROVIDER", "auto").lower(), ) diff --git a/src/devops_learn/learning/persistence/connection.py b/src/devops_learn/learning/persistence/connection.py index 05722c5..a0296b2 100644 --- a/src/devops_learn/learning/persistence/connection.py +++ b/src/devops_learn/learning/persistence/connection.py @@ -7,7 +7,9 @@ def default_db_path() -> Path: - return Path.home() / ".devops_learn" / "learning.db" + import os + data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share")) + return Path(data_home) / "devops-learn" / "learning.db" def connect(db_path: Path) -> sqlite3.Connection: diff --git a/src/devops_learn/policy/__init__.py b/src/devops_learn/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/devops_learn/policy/security/__init__.py b/src/devops_learn/policy/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/policy/security/devsecops.rego b/src/devops_learn/policy/security/devsecops.rego similarity index 100% rename from policy/security/devsecops.rego rename to src/devops_learn/policy/security/devsecops.rego diff --git a/src/devops_learn/templates/__init__.py b/src/devops_learn/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/templates/docker/Dockerfile.reference b/src/devops_learn/templates/docker/Dockerfile.reference similarity index 100% rename from templates/docker/Dockerfile.reference rename to src/devops_learn/templates/docker/Dockerfile.reference diff --git a/templates/github_actions/ci.yml.reference b/src/devops_learn/templates/github_actions/ci.yml.reference similarity index 100% rename from templates/github_actions/ci.yml.reference rename to src/devops_learn/templates/github_actions/ci.yml.reference diff --git a/templates/kubernetes/deployment.yaml.reference b/src/devops_learn/templates/kubernetes/deployment.yaml.reference similarity index 100% rename from templates/kubernetes/deployment.yaml.reference rename to src/devops_learn/templates/kubernetes/deployment.yaml.reference diff --git a/templates/python/environment_variable_config_example.py b/src/devops_learn/templates/python/environment_variable_config_example.py similarity index 100% rename from templates/python/environment_variable_config_example.py rename to src/devops_learn/templates/python/environment_variable_config_example.py diff --git a/src/devops_learn/templates/terraform/__init__.py b/src/devops_learn/templates/terraform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/templates/terraform/main.tf.reference b/src/devops_learn/templates/terraform/main.tf.reference similarity index 100% rename from templates/terraform/main.tf.reference rename to src/devops_learn/templates/terraform/main.tf.reference diff --git a/src/devops_learn/tools/terraform_tool.py b/src/devops_learn/tools/terraform_tool.py index c72226a..3624371 100644 --- a/src/devops_learn/tools/terraform_tool.py +++ b/src/devops_learn/tools/terraform_tool.py @@ -74,9 +74,11 @@ _FALLBACK_RESOURCE_COUNT = 3 -def _reference_config_path() -> Path: - # src/devops_learn/tools/terraform_tool.py -> repo root is 3 parents up. - return Path(__file__).resolve().parents[3] / "templates" / "terraform" / "main.tf.reference" +def _reference_config_path() -> Any: + import importlib.resources + return importlib.resources.files("devops_learn.templates.terraform").joinpath( + "main.tf.reference" + ) def _count_declared_resources() -> int: diff --git a/src/devops_learn/workflows/doctor_flow.py b/src/devops_learn/workflows/doctor_flow.py index ce3c0db..523a257 100644 --- a/src/devops_learn/workflows/doctor_flow.py +++ b/src/devops_learn/workflows/doctor_flow.py @@ -23,6 +23,9 @@ class DoctorReport: security_workflow_ready: bool terraform_planning_ready: bool azure_deployment_ready: bool + ai_provider: str + ai_mode: str + db_writable: bool _CHECKS = ( @@ -43,6 +46,25 @@ def _available(details: Mapping[str, Any], name: str) -> bool: def collect_doctor_report(platform: Platform) -> DoctorReport: + import os + from devops_learn.config.settings import load_settings + settings = load_settings() + db_writable = False + try: + db_path = settings.db_path + db_path.parent.mkdir(parents=True, exist_ok=True) + if not db_path.exists(): + db_path.touch() + db_writable = os.access(db_path, os.W_OK) + except Exception: + pass + + ai_provider = "Mock" + ai_mode = "OFFLINE / DETERMINISTIC" + if platform.llm.__class__.__name__ == "AnthropicProvider": + ai_provider = "Anthropic" + ai_mode = "LIVE EXPLANATION" + environment = platform.tool_service.invoke("doctor", "check", {}).details.get("checks", {}) if not isinstance(environment, Mapping): environment = {} @@ -72,6 +94,9 @@ def collect_doctor_report(platform: Platform) -> DoctorReport: security_workflow_ready=ready["git"] and ready["trivy"] and ready["conftest"], terraform_planning_ready=ready["terraform"] and ready["azure_cli"] and ready["azure_auth"], azure_deployment_ready=all(ready.values()), + ai_provider=ai_provider, + ai_mode=ai_mode, + db_writable=db_writable, ) @@ -89,6 +114,13 @@ def render_doctor_report(report: DoctorReport) -> str: f"Security workflow: {'YES' if report.security_workflow_ready else 'NO'}", f"Terraform planning: {'YES' if report.terraform_planning_ready else 'NO'}", f"Azure deployment: {'YES' if report.azure_deployment_ready else 'NO'}", + "", + "STORAGE", + f"Database writable: {'YES' if report.db_writable else 'NO'}", + "", + "AI", + f"Provider: {report.ai_provider}", + f"Mode: {report.ai_mode}", ) ) actions: list[str] = [] diff --git a/src/devops_learn/workflows/security_flow.py b/src/devops_learn/workflows/security_flow.py index bc211b2..598dce6 100644 --- a/src/devops_learn/workflows/security_flow.py +++ b/src/devops_learn/workflows/security_flow.py @@ -173,13 +173,15 @@ def run_security_scan( ) as temporary: json.dump(policy_input(findings), temporary) policy_input_path = Path(temporary.name) + import importlib.resources try: - policy_dir = Path(__file__).resolve().parents[3] / "policy" / "security" - policy_result = platform.tool_service.invoke( - "security_policy", - "evaluate", - {"input_path": str(policy_input_path), "policy_path": str(policy_dir)}, - ) + policy_files = importlib.resources.files("devops_learn.policy").joinpath("security") + with importlib.resources.as_file(policy_files) as policy_dir: + policy_result = platform.tool_service.invoke( + "security_policy", + "evaluate", + {"input_path": str(policy_input_path), "policy_path": str(policy_dir)}, + ) finally: policy_input_path.unlink(missing_ok=True) if not policy_result.success: diff --git a/tests/workflows/test_deploy_flow.py b/tests/workflows/test_deploy_flow.py index 3be4e97..232f4fc 100644 --- a/tests/workflows/test_deploy_flow.py +++ b/tests/workflows/test_deploy_flow.py @@ -135,7 +135,8 @@ def invoke( return type("Result", (), {"success": False, "summary": "unavailable"})() tool_service = ToolService() - platform = type("Platform", (), {"tool_service": tool_service})() + mock_llm = type("MockLLM", (), {})() + platform = type("Platform", (), {"tool_service": tool_service, "llm": mock_llm})() ui = ScriptedUi([]) assert not _preflight( diff --git a/tests/workflows/test_doctor_flow.py b/tests/workflows/test_doctor_flow.py index 092dc78..178b7cd 100644 --- a/tests/workflows/test_doctor_flow.py +++ b/tests/workflows/test_doctor_flow.py @@ -16,6 +16,9 @@ def test_doctor_report_exposes_capability_specific_readiness() -> None: security_workflow_ready=True, terraform_planning_ready=False, azure_deployment_ready=False, + ai_provider="Mock", + ai_mode="OFFLINE", + db_writable=True, ) rendered = render_doctor_report(report)