Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
devops-learn doctor
```

## Checks before committing
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/devsecops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
41 changes: 41 additions & 0 deletions scripts/setup-dev-environment.sh
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions src/devops_learn/cli/commands/doctor.py
Original file line number Diff line number Diff line change
@@ -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())
10 changes: 10 additions & 0 deletions src/devops_learn/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
analyze,
deploy,
destroy,
doctor,
explain,
history,
init,
Expand All @@ -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
Expand All @@ -48,6 +50,7 @@
security,
deploy,
destroy,
doctor,
)


Expand Down Expand Up @@ -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":
Expand All @@ -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(),
Expand Down
101 changes: 101 additions & 0 deletions src/devops_learn/tools/doctor_tool.py
Original file line number Diff line number Diff line change
@@ -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,
)
13 changes: 5 additions & 8 deletions src/devops_learn/workflows/deploy_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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}
Expand Down
116 changes: 116 additions & 0 deletions src/devops_learn/workflows/doctor_flow.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading