diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a15439b..19337f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,7 @@ python -m venv .venv source .venv/bin/activate pip install -e ".[dev]" +devops-learn doctor ``` ## Checks before committing diff --git a/README.md b/README.md index 766583a..9cec1a0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ pip install -e ".[dev]" ## Quick start ``` +# Check which real workflows this machine can run before installing or authenticating anything. +devops-learn doctor + # Set up a learner profile (optional but recommended) devops-learn profile --set docker=strong terraform=beginner azure=developing \ --focus terraform --focus azure diff --git a/docs/devsecops.md b/docs/devsecops.md index 1d8d5fb..98b594b 100644 --- a/docs/devsecops.md +++ b/docs/devsecops.md @@ -37,6 +37,7 @@ separate from the policy gate that can still return `BLOCK`. Run a capability check before scanning: ```bash +devops-learn doctor devops-learn security doctor ``` diff --git a/scripts/setup-dev-environment.sh b/scripts/setup-dev-environment.sh new file mode 100755 index 0000000..c22e944 --- /dev/null +++ b/scripts/setup-dev-environment.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Safe onboarding helper: inspect and explain prerequisites; it never installs +# packages, authenticates Azure, creates resources, or changes system settings. + +set -euo pipefail + +if [[ -r /etc/os-release ]]; then + . /etc/os-release + printf 'Detected platform: %s\n' "${PRETTY_NAME:-unknown Linux}" +else + printf 'Detected platform: unknown\n' +fi + +printf '\nThis helper makes no system or cloud changes.\n' +printf 'Install the project environment first: python -m venv .venv && .venv/bin/pip install -e ".[dev]"\n\n' + +for command in git docker terraform az trivy conftest; do + if command -v "$command" >/dev/null 2>&1; then + printf '%-10s found: %s\n' "$command" "$(command -v "$command")" + else + printf '%-10s missing\n' "$command" + fi +done + +cat <<'EOF' + +Supported installation references: + Docker: https://docs.docker.com/engine/install/ + Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli-linux + Trivy: https://trivy.dev/latest/getting-started/installation/ + Conftest: https://www.conftest.dev/install/ + +For this Ubuntu release, confirm that each vendor supports its package source +before modifying APT configuration. Do not pipe remote installation scripts to +the shell. After installing tools, run: + + .venv/bin/devops-learn doctor + +Azure authentication is intentionally manual. When the doctor reports Azure +auth unavailable, run `az login` yourself and rerun the doctor. +EOF diff --git a/src/devops_learn/cli/commands/doctor.py b/src/devops_learn/cli/commands/doctor.py new file mode 100644 index 0000000..8fafbe2 --- /dev/null +++ b/src/devops_learn/cli/commands/doctor.py @@ -0,0 +1,18 @@ +"""`devops-learn doctor`: one safe preflight for real workflows.""" + +from __future__ import annotations + +import argparse + +from devops_learn.bootstrap import Platform +from devops_learn.cli.terminal_ui import TerminalUi +from devops_learn.workflows.doctor_flow import run_doctor + + +def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: + parser = subparsers.add_parser("doctor", help="Report workflow-specific environment readiness") + parser.set_defaults(handler=run) + + +def run(args: argparse.Namespace, platform: Platform) -> None: + run_doctor(platform, TerminalUi()) diff --git a/src/devops_learn/cli/main.py b/src/devops_learn/cli/main.py index 03ca1af..f3ba43b 100644 --- a/src/devops_learn/cli/main.py +++ b/src/devops_learn/cli/main.py @@ -11,6 +11,7 @@ analyze, deploy, destroy, + doctor, explain, history, init, @@ -26,6 +27,7 @@ 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 from devops_learn.tools.git_tool import SimulatedGitTool from devops_learn.tools.kubernetes_tool import SimulatedKubernetesTool from devops_learn.tools.python_tool import RealPythonTool, SimulatedPythonTool @@ -48,6 +50,7 @@ security, deploy, destroy, + doctor, ) @@ -96,6 +99,12 @@ def _approval_gate_for_args(args: argparse.Namespace) -> ApprovalGate: def _tools_for_args(args: argparse.Namespace) -> dict[str, Tool] | None: + if args.command == "doctor": + return { + "doctor": EnvironmentDoctorTool(), + "security_scanner": SecurityScannerTool(), + "security_policy": PolicyTool(), + } if args.command == "security": return {"security_scanner": SecurityScannerTool(), "security_policy": PolicyTool()} if args.command == "terraform": @@ -112,6 +121,7 @@ def _tools_for_args(args: argparse.Namespace) -> dict[str, Tool] | None: } if args.command in {"deploy", "destroy"}: return { + "doctor": EnvironmentDoctorTool(), "python": RealPythonTool(), "git": SimulatedGitTool(), "docker": RealDockerTool(), diff --git a/src/devops_learn/tools/doctor_tool.py b/src/devops_learn/tools/doctor_tool.py new file mode 100644 index 0000000..2be7cc2 --- /dev/null +++ b/src/devops_learn/tools/doctor_tool.py @@ -0,0 +1,101 @@ +"""Safe, bounded checks for the local DevOps learning environment.""" + +from __future__ import annotations + +import json +import shutil +import sys +from typing import Any, Mapping + +from devops_learn.tools import _subprocess_safety +from devops_learn.tools.approval import ApprovalRecord +from devops_learn.tools.base import RiskLevel, Tool, ToolOperationSpec, ToolResult + +_OPERATIONS = (ToolOperationSpec("check", RiskLevel.SAFE, False, False, False),) + + +def _first_line(value: str) -> str: + return next((line.strip() for line in value.splitlines() if line.strip()), "available") + + +def _binary_check(command: str, arguments: list[str]) -> dict[str, object]: + binary = shutil.which(command) + if binary is None: + return {"available": False, "version": "not found"} + result = _subprocess_safety.run_safely([binary, *arguments], cwd=None, timeout=15) + return { + "available": result.returncode == 0, + "version": _first_line(result.stdout or result.stderr), + } + + +def _azure_authentication() -> bool: + az = shutil.which("az") + if az is None: + return False + result = _subprocess_safety.run_safely( + [az, "account", "show", "--output", "json", "--only-show-errors"], + cwd=None, + timeout=20, + ) + if result.returncode != 0: + return False + try: + account = json.loads(result.stdout) + except json.JSONDecodeError: + return False + return isinstance(account, dict) and bool(account.get("id")) and bool(account.get("tenantId")) + + +class EnvironmentDoctorTool(Tool): + """Checks command availability without installing, authenticating, or changing state.""" + + @property + def name(self) -> str: + return "doctor" + + @property + def operations(self) -> tuple[ToolOperationSpec, ...]: + return _OPERATIONS + + def execute( + self, + operation: str, + params: Mapping[str, Any], + *, + dry_run: bool, + approval: ApprovalRecord | None, + ) -> ToolResult: + spec = self.spec_for(operation) + if dry_run: + return ToolResult( + True, "Would inspect environment (dry run)", {}, spec.risk_level, True + ) + + docker = _binary_check("docker", ["--version"]) + docker_daemon = ( + _binary_check("docker", ["info", "--format", "{{.ServerVersion}}"]) + if docker["available"] + else {"available": False, "version": "Docker CLI unavailable"} + ) + azure = _binary_check("az", ["version", "--output", "json", "--only-show-errors"]) + checks = { + "python": { + "available": sys.version_info >= (3, 11), + "version": ".".join(str(item) for item in sys.version_info[:3]), + }, + "git": _binary_check("git", ["--version"]), + "docker": docker, + "docker_daemon": docker_daemon, + "terraform": _binary_check("terraform", ["version"]), + "azure_cli": azure, + "azure_auth": {"available": bool(azure["available"]) and _azure_authentication()}, + } + return ToolResult( + True, + "(real) environment checks completed", + {"checks": checks}, + spec.risk_level, + False, + approval, + ) diff --git a/src/devops_learn/workflows/deploy_flow.py b/src/devops_learn/workflows/deploy_flow.py index 9618209..65d944d 100644 --- a/src/devops_learn/workflows/deploy_flow.py +++ b/src/devops_learn/workflows/deploy_flow.py @@ -6,7 +6,6 @@ from datetime import datetime, timezone from hashlib import sha256 from pathlib import Path -import shutil from typing import Any, Mapping import urllib.error import urllib.request @@ -30,6 +29,7 @@ from devops_learn.tools.terraform_tool import terraform_config_digest from devops_learn.validation import terraform_plan_analysis from devops_learn.workflows.security_flow import SecurityOptions, run_security_scan +from devops_learn.workflows.doctor_flow import collect_doctor_report, render_doctor_report from devops_learn.workflows.ui import Ui @@ -300,13 +300,10 @@ def run_deploy_flow(platform: Platform, ui: Ui, options: DeployOptions) -> None: def _preflight(platform: Platform, ui: Ui, options: DeployOptions) -> bool: - unavailable = [ - name - for name in ("terraform", "docker", "trivy", "conftest", "az") - if not shutil.which(name) - ] - if unavailable: - ui.present("AZURE PREFLIGHT FAILED: install " + ", ".join(unavailable) + ".") + doctor = collect_doctor_report(platform) + if not doctor.azure_deployment_ready: + ui.present(render_doctor_report(doctor)) + ui.present("AZURE PREFLIGHT FAILED: Azure deployment is not ready.") return False result = platform.tool_service.invoke( "azure", "preflight", {"region": options.location, "environment": options.environment} diff --git a/src/devops_learn/workflows/doctor_flow.py b/src/devops_learn/workflows/doctor_flow.py new file mode 100644 index 0000000..ce3c0db --- /dev/null +++ b/src/devops_learn/workflows/doctor_flow.py @@ -0,0 +1,116 @@ +"""Capability-specific local preflight for real platform workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping + +from devops_learn.bootstrap import Platform +from devops_learn.workflows.ui import Ui + + +@dataclass(frozen=True) +class DoctorCheck: + label: str + available: bool + detail: str = "" + + +@dataclass(frozen=True) +class DoctorReport: + checks: tuple[DoctorCheck, ...] + local_workflow_ready: bool + security_workflow_ready: bool + terraform_planning_ready: bool + azure_deployment_ready: bool + + +_CHECKS = ( + ("python", "Python"), + ("git", "Git"), + ("docker", "Docker"), + ("docker_daemon", "Docker daemon"), + ("terraform", "Terraform"), + ("azure_cli", "Azure CLI"), + ("azure_auth", "Azure auth"), + ("trivy", "Trivy"), + ("conftest", "Conftest"), +) + + +def _available(details: Mapping[str, Any], name: str) -> bool: + return bool(details.get(name, {}).get("available", False)) + + +def collect_doctor_report(platform: Platform) -> DoctorReport: + environment = platform.tool_service.invoke("doctor", "check", {}).details.get("checks", {}) + if not isinstance(environment, Mapping): + environment = {} + trivy = platform.tool_service.invoke("security_scanner", "version") + conftest = platform.tool_service.invoke("security_policy", "version") + checks: dict[str, Mapping[str, Any]] = dict(environment) + checks["trivy"] = { + "available": trivy.success, + "version": trivy.summary.removeprefix("(real) ").removeprefix("(real, failed) "), + } + checks["conftest"] = { + "available": conftest.success, + "version": conftest.summary.removeprefix("(real) ").removeprefix("(real, failed) "), + } + rendered = tuple( + DoctorCheck( + label, + _available(checks, key), + str(checks.get(key, {}).get("version", "not found")), + ) + for key, label in _CHECKS + ) + ready = {key: _available(checks, key) for key, _ in _CHECKS} + return DoctorReport( + checks=rendered, + local_workflow_ready=ready["python"] and ready["docker"] and ready["docker_daemon"], + 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()), + ) + + +def render_doctor_report(report: DoctorReport) -> str: + lines = ["DEVOPS-LEARN DOCTOR", ""] + for check in report.checks: + status = "PASS" if check.available else "FAIL" + detail = f" {check.detail}" if check.detail else "" + lines.append(f"{check.label:<14} {status}{detail}") + lines.extend( + ( + "", + "READY FOR:", + f"Local workflow: {'YES' if report.local_workflow_ready else 'NO'}", + 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'}", + ) + ) + actions: list[str] = [] + unavailable = {check.label for check in report.checks if not check.available} + if "Docker" in unavailable: + actions.append("Install Docker, then rerun `devops-learn doctor`.") + elif "Docker daemon" in unavailable: + actions.append("Start Docker, then rerun `devops-learn doctor`.") + if "Azure CLI" in unavailable: + actions.append("Install the Azure CLI, then rerun `devops-learn doctor`.") + elif "Azure auth" in unavailable: + actions.append("Authenticate Azure using `az login`.") + if "Trivy" in unavailable: + actions.append("Install Trivy for the security workflow.") + if "Conftest" in unavailable: + actions.append("Install Conftest for the security workflow.") + if actions: + lines.extend(("", "ACTION REQUIRED:", *actions)) + return "\n".join(lines) + + +def run_doctor(platform: Platform, ui: Ui) -> DoctorReport: + report = collect_doctor_report(platform) + ui.present(render_doctor_report(report)) + return report diff --git a/tests/tools/test_doctor_tool.py b/tests/tools/test_doctor_tool.py new file mode 100644 index 0000000..a5aac2b --- /dev/null +++ b/tests/tools/test_doctor_tool.py @@ -0,0 +1,27 @@ +from devops_learn.tools import _subprocess_safety, doctor_tool +from devops_learn.tools.doctor_tool import EnvironmentDoctorTool + + +def test_doctor_checks_docker_runtime_and_azure_auth_without_exposing_account_data( + monkeypatch, +) -> None: + monkeypatch.setattr(doctor_tool.shutil, "which", lambda command: f"/tools/{command}") + + def fake_run(command, *, cwd, timeout): + if command[1:3] == ["account", "show"]: + return _subprocess_safety.SafeRunResult( + 0, '{"id":"subscription-id","tenantId":"tenant-id"}', "", False + ) + return _subprocess_safety.SafeRunResult(0, "version 1.0\n", "", False) + + monkeypatch.setattr(doctor_tool._subprocess_safety, "run_safely", fake_run) + + result = EnvironmentDoctorTool().execute( + "check", {}, dry_run=False, approval=None + ) + + checks = result.details["checks"] + assert result.success + assert checks["docker_daemon"]["available"] + assert checks["azure_auth"] == {"available": True} + assert "subscription-id" not in str(checks) diff --git a/tests/workflows/test_deploy_flow.py b/tests/workflows/test_deploy_flow.py index 7ce7417..3be4e97 100644 --- a/tests/workflows/test_deploy_flow.py +++ b/tests/workflows/test_deploy_flow.py @@ -16,6 +16,7 @@ from devops_learn.tools.approval import AutoApproveApprovalGate from devops_learn.tools.terraform_tool import RealTerraformTool, terraform_config_digest from devops_learn.workflows.deploy_flow import _plan_and_gate +from devops_learn.workflows.deploy_flow import DeployOptions, _preflight from devops_learn.workflows.ui import Ui FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "fake_terraform" @@ -114,3 +115,31 @@ def test_required_security_and_cloud_approval_bind_the_saved_plan( assert result.human_approvals == ("security", "cloud_action") assert result.terraform_plan_path is not None assert Path(result.terraform_plan_path).is_file() + + +def test_deploy_preflight_reuses_doctor_before_azure_authentication() -> None: + class ToolService: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def invoke( + self, tool: str, operation: str, params: dict[str, object] | None = None + ): + self.calls.append((tool, operation)) + if tool == "doctor": + return type( + "Result", + (), + {"details": {"checks": {"python": {"available": False}}}}, + )() + return type("Result", (), {"success": False, "summary": "unavailable"})() + + tool_service = ToolService() + platform = type("Platform", (), {"tool_service": tool_service})() + ui = ScriptedUi([]) + + assert not _preflight( + platform, ui, DeployOptions("projects/api_platform", environment="azure-integration") + ) + assert ("azure", "preflight") not in tool_service.calls + assert "DEVOPS-LEARN DOCTOR" in "\n".join(ui.presented) diff --git a/tests/workflows/test_doctor_flow.py b/tests/workflows/test_doctor_flow.py new file mode 100644 index 0000000..092dc78 --- /dev/null +++ b/tests/workflows/test_doctor_flow.py @@ -0,0 +1,28 @@ +from devops_learn.workflows.doctor_flow import DoctorCheck, DoctorReport, render_doctor_report + + +def test_doctor_report_exposes_capability_specific_readiness() -> None: + report = DoctorReport( + checks=( + DoctorCheck("Python", True, "3.11.9"), + DoctorCheck("Docker", True, "29.0"), + DoctorCheck("Docker daemon", True, "29.0"), + DoctorCheck("Azure CLI", True, "2.88"), + DoctorCheck("Azure auth", False), + DoctorCheck("Trivy", True, "0.70"), + DoctorCheck("Conftest", True, "0.69"), + ), + local_workflow_ready=True, + security_workflow_ready=True, + terraform_planning_ready=False, + azure_deployment_ready=False, + ) + + rendered = render_doctor_report(report) + + assert "Docker daemon PASS" in rendered + assert "Local workflow: YES" in rendered + assert "Security workflow: YES" in rendered + assert "Terraform planning: NO" in rendered + assert "Azure deployment: NO" in rendered + assert "Authenticate Azure using `az login`." in rendered