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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Project -> Analysis -> Requirements -> Questions -> Recommendations -> Architect
- **Real Terraform execution** (`devops-learn terraform`) runs actual
`terraform fmt`/`init`/`validate`/`plan` against a real, committed Azure
configuration, with structured `terraform show -json` parsing feeding a
deterministic risk classifier. No `apply`/`destroy` yet; `plan` requires
deterministic risk classifier. This standalone command has no `apply`/`destroy`;
`plan` requires
Azure authentication and fails cleanly, with an explanation, without it.
- **Simulated cloud/Kubernetes** execution for safe learning without
credentials or cost (`devops-learn analyze` without `--real-tools`;
Expand Down
6 changes: 3 additions & 3 deletions docs/devsecops.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,6 @@ not marked live-verified until an opt-in Azure run succeeds.

Image scanning requires a locally built image and Docker availability. The
first comparison implementation does not solve every move, rename, or package
upgrade equivalence. Policy overrides, real Terraform plan scanning, Azure
apply, runtime detection, and Kubernetes deployment are intentionally out of
scope.
upgrade equivalence. Policy overrides, full Terraform-plan content scanning,
runtime vulnerability detection, and Kubernetes deployment are intentionally
out of scope.
12 changes: 6 additions & 6 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ structured parsing of `terraform show -json` feeding the existing
`validation/terraform_plan_analysis.analyze()` risk classifier unchanged.
`devops-learn terraform` runs the full vertical slice (fmt -> init ->
validate -> plan -> risk analysis) and teaches Terraform state, provider,
and resource-address concepts just-in-time (`docs/terraform-state.md`). No
`apply`; `apply_approved_plan`/`destroy_approved_environment` are not even
declared on `RealTerraformTool` and stay simulated until Milestone 3. `plan`
requires Azure authentication and fails cleanly, with an explanation, on a
and resource-address concepts just-in-time (`docs/terraform-state.md`). This
standalone command has no apply action; the separately approved lifecycle
commands introduced in Milestone 3 own real apply and destroy. `plan` requires
Azure authentication and fails cleanly, with an explanation, on a
machine without it — `init`/`validate`/`fmt` need no credentials.

## Milestone 3: first Azure vertical slice (implemented, awaiting live verification)
Expand All @@ -64,8 +64,8 @@ Conftest policy. The deterministic gate includes structural secret redaction,
audit/experience evidence, remediation classification, a report artifact, demo,
and PR CI. See `docs/devsecops.md`.

The next dependency is the real Terraform plan plus Azure deployment path,
where this gate must be required before any real apply.
The gate is now bound to each deployment candidate before any real apply. Live
Azure verification remains the final opt-in acceptance step.

## Further out (not yet milestoned)

Expand Down
26 changes: 15 additions & 11 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,22 @@ resources, applies Terraform, or modifies remote state. It:

## Real Terraform execution

`devops-learn terraform` is the only command that uses `RealTerraformTool`
(`tools/terraform_tool.py`) — `devops-learn analyze --real-tools` and `devops-learn local` both
still use `SimulatedTerraformTool` for the `terraform` tool name; only `python`/`docker` become
real for those commands. `RealTerraformTool` declares only `fmt`/`init`/`validate`/`plan` —
`apply_approved_plan` and `destroy_approved_environment` are real-only Tool
operations. Apply accepts only a saved plan in `.devops_learn/plans` and
`devops-learn terraform`, `devops-learn deploy`, and `devops-learn destroy`
use `RealTerraformTool` (`tools/terraform_tool.py`). `devops-learn analyze
--real-tools` and `devops-learn local` still use `SimulatedTerraformTool` for
the `terraform` tool name. The standalone `terraform` command exposes only
`fmt`/`init`/`validate`/`plan`; `apply_approved_plan` and
`destroy_approved_environment` are real-only operations reached through the
explicit lifecycle commands. Apply accepts only a saved plan in `.devops_learn/plans` and
rechecks its digest, source identity, Terraform configuration digest, and
candidate context before invoking Terraform. Destroy requires an explicit
target environment and is followed by a read-only Azure resource-group check.
Neither operation can fall back to simulation; normal CI does not call either.

Every subprocess call goes through `tools/_subprocess_safety.py::run_safely`, which sets an
explicit timeout and always redacts secret-shaped `KEY=value` text and truncates long output
before it reaches a `ToolResult`. `plan()`'s parsing of `terraform show -json` never extracts
Terraform, Docker, and Azure CLI subprocess calls go through
`tools/_subprocess_safety.py::run_safely`, which sets an explicit timeout and
always redacts secret-shaped `KEY=value` text and truncates long output before
it reaches a `ToolResult`. `plan()`'s parsing of `terraform show -json` never extracts
raw resource attribute values (`before`/`after`) — only `{address, action}` per resource change
— which is the primary defense against leaking sensitive plan data; free-text redaction is
defense in depth on top of that.
Expand All @@ -113,7 +115,9 @@ do support a dry run that skips approval when the operation is simulated.

## Cost awareness

No cloud action in V1 can create a billable resource; `cloud.estimate_cost` deliberately never
returns a specific dollar figure, since V1 has no real pricing data to draw from.
The real-only Azure lifecycle can create billable learning resources only after
explicit confirmations and tool-level approval. `cloud.estimate_cost` still
deliberately never returns a specific dollar figure, since it has no live
pricing data to draw from.
Cost impact in a `Recommendation` (e.g. "lower likely cost than a managed cluster") is always
qualitative for the same reason.
6 changes: 4 additions & 2 deletions projects/api_platform/infra/terraform/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# infra/terraform

This is a real, valid Terraform configuration for a minimal Azure footprint:
a resource group, a container registry, and a Log Analytics workspace.
This is a real, valid Terraform configuration for a minimal Azure Container
Apps learning footprint: a resource group, container registry, managed
identity with `AcrPull`, Log Analytics workspace, Container Apps Environment,
and (after the image stage) the FastAPI Container App.

## Files in this directory

Expand Down
18 changes: 11 additions & 7 deletions projects/api_platform/infra/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@
locals {
tags = {
project = "devops-learn-by-doing"
environment = "learning"
environment = var.environment
managed-by = "terraform"
purpose = "devops-learning"
}

resource_prefix = "${var.project_name}-${var.environment}"

# Azure Container Registry names must be alphanumeric only (no hyphens),
# unlike most other Azure resource names -- strip them here rather than
# relaxing var.project_name itself.
registry_name = replace(var.project_name, "-", "")
registry_name = "${replace(local.resource_prefix, "-", "")}${substr(md5(data.azurerm_client_config.current.subscription_id), 0, 6)}acr"
}

data "azurerm_client_config" "current" {}

resource "azurerm_resource_group" "main" {
name = "${var.project_name}-rg"
name = "${local.resource_prefix}-rg"
location = var.location
tags = local.tags
}
Expand All @@ -33,7 +37,7 @@ resource "azurerm_container_registry" "main" {
}

resource "azurerm_user_assigned_identity" "container_app" {
name = "${var.project_name}-app-identity"
name = "${local.resource_prefix}-app-identity"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tags = local.tags
Expand All @@ -46,7 +50,7 @@ resource "azurerm_role_assignment" "container_app_acr_pull" {
}

resource "azurerm_log_analytics_workspace" "main" {
name = "${var.project_name}-logs"
name = "${local.resource_prefix}-logs"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "PerGB2018"
Expand All @@ -55,7 +59,7 @@ resource "azurerm_log_analytics_workspace" "main" {
}

resource "azurerm_container_app_environment" "main" {
name = "${var.project_name}-env"
name = "${local.resource_prefix}-env"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
Expand All @@ -64,7 +68,7 @@ resource "azurerm_container_app_environment" "main" {

resource "azurerm_container_app" "api" {
count = var.deploy_application ? 1 : 0
name = "${var.project_name}-api"
name = "${local.resource_prefix}-api"
resource_group_name = azurerm_resource_group.main.name
container_app_environment_id = azurerm_container_app_environment.main.id
revision_mode = "Single"
Expand Down
11 changes: 11 additions & 0 deletions projects/api_platform/infra/terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ variable "location" {
default = "eastus"
}

variable "environment" {
description = "Learning environment label included in every resource name and tag."
type = string
default = "learning"

validation {
condition = can(regex("^[a-z0-9-]{2,20}$", var.environment))
error_message = "environment must be 2-20 lowercase letters, numbers, or hyphens."
}
}

variable "deploy_application" {
description = "Set after the ACR image has been pushed. Bootstrap stays image-independent."
type = bool
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests", "projects/api_platform/tests"]
pythonpath = ["projects/api_platform"]
markers = [
"azure_integration: creates and destroys the opt-in Azure learning environment",
]
46 changes: 39 additions & 7 deletions src/devops_learn/tools/azure_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,36 @@ def execute(
]
ok, payload, error = _json(command)
safe = _container_app_view(payload)
logs = _subprocess_safety.run_safely(
[
az,
"containerapp",
"logs",
"show",
"--name",
app,
"--resource-group",
resource_group,
"--tail",
"50",
],
cwd=None,
timeout=45,
)
return ToolResult(
ok,
"(real) collected Container App evidence"
if ok
else "(real, failed) could not collect Container App evidence",
safe if ok else {"error": error},
(
{
**safe,
"logs": logs.stdout if logs.returncode == 0 else logs.stderr,
"logs_collected": logs.returncode == 0,
}
if ok
else {"error": error}
),
spec.risk_level,
False,
approval,
Expand Down Expand Up @@ -235,17 +259,23 @@ def _verify_environment(
_container_app_view(payload) if name == "container_app" else _resource_view(payload)
)
expected_region = str(params.get("expected_region", "")).lower()
actual_region = str(observed["resource_group"].get("location", "")).lower()
expected_tags = params.get("expected_tags", {})
actual_tags = observed["resource_group"].get("tags", {})
valid = (not expected_region or expected_region == actual_region) and all(
actual_tags.get(k) == v for k, v in expected_tags.items()
expected_image = str(params.get("expected_image", ""))
resources_match = all(
(not expected_region or str(resource.get("location", "")).lower() == expected_region)
and all(resource.get("tags", {}).get(k) == v for k, v in expected_tags.items())
for resource in observed.values()
)
image_matches = (
not expected_image
or observed["container_app"].get("image") == expected_image
)
valid = resources_match and image_matches
return ToolResult(
valid,
"(real) DECLARED BY TERRAFORM matches OBSERVED IN AZURE"
if valid
else "(real, failed) Azure observation does not match declared region or tags",
else "(real, failed) Azure observation does not match declared region, tags, or image",
observed,
spec.risk_level,
False,
Expand All @@ -267,12 +297,14 @@ def _container_app_view(payload: dict[str, Any]) -> dict[str, Any]:
template = properties.get("template", {})
containers = template.get("containers", [])
ingress = properties.get("configuration", {}).get("ingress", {})
fqdn = ingress.get("fqdn")
endpoint = f"https://{fqdn}" if fqdn and not str(fqdn).startswith("http") else fqdn
return {
"name": payload.get("name"),
"location": payload.get("location"),
"tags": payload.get("tags", {}),
"image": containers[0].get("image") if containers else None,
"endpoint": ingress.get("fqdn"),
"endpoint": endpoint,
"revision": properties.get("latestRevisionName"),
"running_status": properties.get("runningStatus"),
}
12 changes: 4 additions & 8 deletions src/devops_learn/tools/docker_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from __future__ import annotations

import shutil
import subprocess
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

Expand Down Expand Up @@ -62,13 +62,9 @@ def _docker_command() -> str:
return command


def _run(command: list[str], *, check: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
capture_output=True,
text=True,
check=check,
)
def _run(command: list[str]) -> _subprocess_safety.SafeRunResult:
"""Run a bounded Docker command without exposing raw CLI output."""
return _subprocess_safety.run_safely(command, cwd=None, timeout=180)


class SimulatedDockerTool(Tool):
Expand Down
22 changes: 20 additions & 2 deletions src/devops_learn/tools/terraform_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,23 @@ def _variable_args(params: Mapping[str, Any]) -> list[str]:
variables = params.get("variables", {})
if not isinstance(variables, Mapping):
raise ValueError("Terraform variables must be a mapping.")
allowed = {"deploy_application", "app_image"}
allowed = {"deploy_application", "app_image", "location", "environment"}
if set(variables) - allowed:
raise ValueError("Only deploy_application and app_image are accepted by this workflow.")
raise ValueError(
"Only deploy_application, app_image, location, and environment are accepted "
"by this workflow."
)
args: list[str] = []
for name in sorted(variables):
value = variables[name]
if name == "deploy_application" and not isinstance(value, bool):
raise ValueError("deploy_application must be boolean.")
if name == "app_image" and (not isinstance(value, str) or "@sha256:" not in value):
raise ValueError("app_image must be a digest-pinned image reference.")
if name == "location" and (not isinstance(value, str) or not value.strip()):
raise ValueError("location must be a non-empty Azure region name.")
if name == "environment" and (not isinstance(value, str) or not value.strip()):
raise ValueError("environment must be a non-empty deployment environment name.")
args.extend(["-var", f"{name}={str(value).lower() if isinstance(value, bool) else value}"])
return args

Expand Down Expand Up @@ -548,6 +555,16 @@ def _apply(
"config_digest": terraform_config_digest(working_dir),
"plan_digest": _digest_file(plan_path),
}
candidate_identity = params.get("candidate_identity")
if not isinstance(candidate_identity, str) or not candidate_identity:
return ToolResult(
False,
"(real, refused) apply requires the approved deployment candidate identity",
{"reason": "candidate identity was not supplied"},
spec.risk_level,
False,
approval,
)
if any(not value or metadata.get(key) != value for key, value in expected.items()):
return ToolResult(
False,
Expand All @@ -572,6 +589,7 @@ def _apply(
"stdout": result.stdout,
"stderr": result.stderr,
"plan_digest": expected["plan_digest"],
"candidate_identity": candidate_identity,
},
spec.risk_level,
False,
Expand Down
Loading
Loading