From e16f83580b1c9c0064ac2aa76a05ea9bd0488d11 Mon Sep 17 00:00:00 2001 From: TacoRocket Date: Sat, 18 Apr 2026 14:56:16 -0500 Subject: [PATCH 1/2] Add Azure DevOps validation canaries --- README.md | 30 +- devops-canaries/README.md | 43 ++ devops-canaries/azure-pipelines.yml.tmpl | 18 + .../pipelines/named-target.yml.tmpl | 13 + .../pipelines/template-follow.yml.tmpl | 8 + .../templates/deploy-canary.yml.tmpl | 16 + outputs.tf | 22 +- scripts/sync_devops_canaries.py | 490 ++++++++++++++++++ scripts/validate_azurefox_lab.py | 44 +- tests/test_sync_devops_canaries.py | 119 +++++ tests/test_validate_azurefox_lab.py | 17 + 11 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 devops-canaries/README.md create mode 100644 devops-canaries/azure-pipelines.yml.tmpl create mode 100644 devops-canaries/pipelines/named-target.yml.tmpl create mode 100644 devops-canaries/pipelines/template-follow.yml.tmpl create mode 100644 devops-canaries/templates/deploy-canary.yml.tmpl create mode 100644 scripts/sync_devops_canaries.py create mode 100644 tests/test_sync_devops_canaries.py diff --git a/README.md b/README.md index aee3b2e..6b30c17 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ Current release boundary: - this repo now targets AzureFox `1.3.0` as the current parity boundary - deterministic lab-backed proof now includes `snapshots-disks`, `vmss`, and one Automation account - `lighthouse` and `cross-tenant` are validated as evidence-led tenant surfaces rather than fixed row-count proof -- `devops` is validated conditionally: without `AZUREFOX_DEVOPS_ORG`, the validator expects the truthful missing-organization issue instead of pretending pipeline coverage exists +- `devops` now has three lab-owned YAML canaries once a real Azure DevOps org/project/repo context is provided: + root YAML, same-repo template follow, and named-target App Service join - grouped `chains` follow-up remains optional here; Firefox/AzureFox `1.3.0` added more live-proof-aware `credential-path` wording, but this lab still treats grouped chain output as secondary to the standalone validation gate ## Lab Shape @@ -338,6 +339,33 @@ For richer `devops` proof, point AzureFox at a real Azure DevOps organization be export AZUREFOX_DEVOPS_ORG= ``` +Before the DevOps canaries can be synced, the Azure DevOps side needs these prerequisites: + +1. A project. The default expected project name is `Azurefox Proof Lab`. +2. An Azure Repos repo. The default expected repo name is `lab-proof`. +3. A variable group. The default expected name is `af-proof-lab-vars`. + The current canaries only need the group to exist; one placeholder variable such as + `LAB_PROOF_SECRET=not-real` is enough. +4. An Azure Resource Manager service connection. The default expected name is `af-rg-reader`. + Point it at the same subscription as the lab. Workload identity federation is the preferred + scheme because AzureFox surfaces that trust path cleanly in live output. + +Once those prerequisites exist, sync the tracked canary YAML into the DevOps repo and ensure the +pipeline definitions exist: + +```bash +python3 scripts/sync_devops_canaries.py --org "https://dev.azure.com//" +``` + +That sync step maintains three proof pipelines in the DevOps project: + +- `lab-proof`: direct root-YAML canary +- `lab-proof-template`: same-repo template-follow canary +- `lab-proof-targeted`: named-target App Service canary + +If you use different DevOps names, the sync helper accepts overrides for the project, repo, service +connection, variable group, and pipeline names. + Optional flags: ```bash diff --git a/devops-canaries/README.md b/devops-canaries/README.md new file mode 100644 index 0000000..7b6b4c7 --- /dev/null +++ b/devops-canaries/README.md @@ -0,0 +1,43 @@ +## DevOps Canary Files + +This directory holds the Azure DevOps YAML canary files for the proof lab. + +They are here to make the lab intent obvious, not to claim that this repo +fully provisions every Azure DevOps prerequisite by itself. + +Current boundary: + +- the stranger still needs a real Azure DevOps org/project/repo context +- the stranger still needs working Azure DevOps auth +- this lab owns the tracked YAML canary layer and the repo/pipeline sync seam + where the Azure DevOps API allows it + +The default proof setup is: + +- project: `Azurefox Proof Lab` +- repo: `lab-proof` +- variable group: `af-proof-lab-vars` +- Azure Resource Manager service connection: `af-rg-reader` + +The current canaries only need the variable group to exist; one placeholder +variable such as `LAB_PROOF_SECRET=not-real` is enough. + +Use the sync helper after those prerequisites exist: + +```bash +python3 scripts/sync_devops_canaries.py --org "https://dev.azure.com//" +``` + +The canaries are intentionally small and each one proves a specific AzureFox +behavior: + +- `/azure-pipelines.yml` + - direct root-YAML evidence canary + - service connection and variable group are visible directly in the root file +- `/pipelines/template-follow.yml` + - same-repo template-follow canary + - the root file delegates into a local template +- `/templates/deploy-canary.yml` + - local template that holds the Azure-facing evidence +- `/pipelines/named-target.yml` + - explicit named-target canary for stronger `chains deployment-path` joins diff --git a/devops-canaries/azure-pipelines.yml.tmpl b/devops-canaries/azure-pipelines.yml.tmpl new file mode 100644 index 0000000..93c75dd --- /dev/null +++ b/devops-canaries/azure-pipelines.yml.tmpl @@ -0,0 +1,18 @@ +trigger: none +pr: none + +variables: +- group: __VARIABLE_GROUP__ + +pool: + vmImage: ubuntu-latest + +steps: +- task: AzureCLI@2 + displayName: "Read proof resource group" + inputs: + azureSubscription: __SERVICE_CONNECTION__ + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + az group show --name __OPS_RESOURCE_GROUP__ --query name -o tsv || true diff --git a/devops-canaries/pipelines/named-target.yml.tmpl b/devops-canaries/pipelines/named-target.yml.tmpl new file mode 100644 index 0000000..7b7c7b4 --- /dev/null +++ b/devops-canaries/pipelines/named-target.yml.tmpl @@ -0,0 +1,13 @@ +trigger: none +pr: none + +pool: + vmImage: ubuntu-latest + +steps: +- task: AzureWebApp@1 + displayName: "Named target canary" + inputs: + azureSubscription: __SERVICE_CONNECTION__ + appName: __NAMED_WEBAPP__ + package: $(Build.SourcesDirectory)/README.md diff --git a/devops-canaries/pipelines/template-follow.yml.tmpl b/devops-canaries/pipelines/template-follow.yml.tmpl new file mode 100644 index 0000000..4d82ca5 --- /dev/null +++ b/devops-canaries/pipelines/template-follow.yml.tmpl @@ -0,0 +1,8 @@ +trigger: none +pr: none + +stages: +- stage: TemplateFollow + displayName: "Template follow canary" + jobs: + - template: /templates/deploy-canary.yml diff --git a/devops-canaries/templates/deploy-canary.yml.tmpl b/devops-canaries/templates/deploy-canary.yml.tmpl new file mode 100644 index 0000000..b7ab48a --- /dev/null +++ b/devops-canaries/templates/deploy-canary.yml.tmpl @@ -0,0 +1,16 @@ +jobs: +- job: TemplateFollow + displayName: "Template follow canary" + variables: + - group: __VARIABLE_GROUP__ + pool: + vmImage: ubuntu-latest + steps: + - task: AzureCLI@2 + displayName: "Read template-follow resource group" + inputs: + azureSubscription: __SERVICE_CONNECTION__ + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + az group show --name __WORKLOAD_RESOURCE_GROUP__ --query name -o tsv || true diff --git a/outputs.tf b/outputs.tf index 65e6e44..2bef5bf 100644 --- a/outputs.tf +++ b/outputs.tf @@ -582,6 +582,26 @@ output "validation_manifest" { } devops = { expect_unconfigured_issue_without_org = true + expected_service_connection_name = "af-rg-reader" + expected_variable_group_name = "af-proof-lab-vars" + pipelines = { + root_yaml = { + name = "lab-proof" + expect_variable_group = true + expect_named_target = false + } + template_follow = { + name = "lab-proof-template" + expect_variable_group = true + expect_named_target = false + } + named_target = { + name = "lab-proof-targeted" + expect_variable_group = false + expect_named_target = true + expected_target_clue = "App Service: ${azurerm_linux_web_app.phase2_public.name}" + } + } } lighthouse = { validation_mode = "evidence-led" @@ -602,7 +622,7 @@ output "validation_manifest" { known_gaps = [ "cross-tenant remains tenant- and permission-dependent, so the lab keeps it evidence-led rather than row-count gated.", "lighthouse remains subscription- and tenant-shaped; promote stronger assertions only if the lab intentionally adds delegated-management proof.", - "devops needs a real Azure DevOps organization, project, and pipeline path before it can move past conditional validation of command behavior and truthful issue surfacing.", + "devops still depends on a real Azure DevOps org/project/repo plus a visible Azure service connection and variable group; this repo owns the YAML canary sync once those prerequisites exist.", "automation currently validates the visible zero-object execution posture, but the current AzureFox read path did not return a managed-identity type for the lab-owned Automation account during the April 8, 2026 live pass.", ] } diff --git a/scripts/sync_devops_canaries.py b/scripts/sync_devops_canaries.py new file mode 100644 index 0000000..a54c0bd --- /dev/null +++ b/scripts/sync_devops_canaries.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + + +LAB_DIR = Path(__file__).resolve().parents[1] +CANARY_DIR = LAB_DIR / "devops-canaries" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render and sync Azure DevOps proof canaries into the lab-proof repo." + ) + parser.add_argument( + "--lab-dir", + type=Path, + default=LAB_DIR, + help="Path to the Terraform lab root.", + ) + parser.add_argument( + "--org", + default=os.environ.get("AZUREFOX_DEVOPS_ORG"), + help="Azure DevOps organization name or URL. Defaults to AZUREFOX_DEVOPS_ORG.", + ) + parser.add_argument( + "--project", + default="Azurefox Proof Lab", + help="Azure DevOps project name.", + ) + parser.add_argument( + "--repo", + default="lab-proof", + help="Azure DevOps repo name that stores the proof YAML.", + ) + parser.add_argument( + "--service-connection", + default="af-rg-reader", + help="Azure service connection name used by the canaries.", + ) + parser.add_argument( + "--variable-group", + default="af-proof-lab-vars", + help="Variable group name used by the canaries.", + ) + parser.add_argument( + "--root-pipeline-name", + default="lab-proof", + help="Pipeline definition name for the root-YAML canary.", + ) + parser.add_argument( + "--template-pipeline-name", + default="lab-proof-template", + help="Pipeline definition name for the same-repo template canary.", + ) + parser.add_argument( + "--named-target-pipeline-name", + default="lab-proof-targeted", + help="Pipeline definition name for the named-target canary.", + ) + parser.add_argument( + "--branch", + default="main", + help="Azure Repos branch to update.", + ) + parser.add_argument( + "--skip-pipeline-create", + action="store_true", + help="Only sync repo content and skip pipeline-definition creation.", + ) + args = parser.parse_args() + if not args.org: + parser.error("--org or AZUREFOX_DEVOPS_ORG is required") + return args + + +def normalize_org_url(value: str) -> str: + if value.startswith("https://") or value.startswith("http://"): + return value.rstrip("/") + return f"https://dev.azure.com/{value.rstrip('/')}" + + +def run_json(cmd: list[str], *, cwd: Path | None = None, input_text: str | None = None) -> Any: + result = subprocess.run( + cmd, + cwd=cwd, + input=input_text, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Command failed ({result.returncode}): {' '.join(cmd)}\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + return json.loads(result.stdout) + + +def read_manifest(lab_dir: Path) -> dict[str, Any]: + value = run_json(["tofu", "output", "-json", "validation_manifest"], cwd=lab_dir) + if not isinstance(value, dict): + raise RuntimeError("validation_manifest output was not a JSON object") + return value + + +def select_named_webapp(manifest: dict[str, Any]) -> str: + app_services = manifest["phase3_checkpoint"]["app_services"]["expected_assets"] + public_candidates = [ + asset["name"] + for asset in app_services + if asset.get("public_network_access") == "Enabled" + ] + if not public_candidates: + raise RuntimeError("validation_manifest did not expose a public app-service canary target") + public_candidates.sort(key=lambda name: ("public" not in name, name)) + return public_candidates[0] + + +def render_canary_files(manifest: dict[str, Any], args: argparse.Namespace) -> dict[str, str]: + substitutions = { + "__SERVICE_CONNECTION__": args.service_connection, + "__VARIABLE_GROUP__": args.variable_group, + "__OPS_RESOURCE_GROUP__": manifest["resource_groups"]["ops"], + "__WORKLOAD_RESOURCE_GROUP__": manifest["resource_groups"]["workload"], + "__NAMED_WEBAPP__": select_named_webapp(manifest), + } + templates = { + "/azure-pipelines.yml": (CANARY_DIR / "azure-pipelines.yml.tmpl").read_text( + encoding="utf-8" + ), + "/pipelines/template-follow.yml": ( + CANARY_DIR / "pipelines" / "template-follow.yml.tmpl" + ).read_text(encoding="utf-8"), + "/pipelines/named-target.yml": ( + CANARY_DIR / "pipelines" / "named-target.yml.tmpl" + ).read_text(encoding="utf-8"), + "/templates/deploy-canary.yml": ( + CANARY_DIR / "templates" / "deploy-canary.yml.tmpl" + ).read_text(encoding="utf-8"), + } + rendered: dict[str, str] = {} + for path, content in templates.items(): + rendered_content = content + for needle, replacement in substitutions.items(): + rendered_content = rendered_content.replace(needle, replacement) + rendered[path] = rendered_content + return rendered + + +def get_repository(repo_name: str, *, org: str, project: str) -> dict[str, Any]: + return run_json( + [ + "az", + "repos", + "show", + "--org", + org, + "--project", + project, + "--repository", + repo_name, + "--output", + "json", + ] + ) + + +def get_branch_head(repo_id: str, branch: str, *, org: str, project: str) -> str: + refs = run_json( + [ + "az", + "devops", + "invoke", + "--org", + org, + "--area", + "git", + "--resource", + "refs", + "--route-parameters", + f"project={project}", + f"repositoryId={repo_id}", + "--query-parameters", + f"filter=heads/{branch}", + "--output", + "json", + ] + ) + values = refs.get("value", []) + if not values: + raise RuntimeError(f"Azure DevOps repo is missing branch '{branch}'") + return values[0]["objectId"] + + +def list_repo_paths(repo_id: str, branch: str, *, org: str, project: str) -> set[str]: + items = run_json( + [ + "az", + "devops", + "invoke", + "--org", + org, + "--area", + "git", + "--resource", + "items", + "--route-parameters", + f"project={project}", + f"repositoryId={repo_id}", + "--query-parameters", + "scopePath=/", + "recursionLevel=Full", + f"versionDescriptor.version={branch}", + "versionDescriptor.versionType=branch", + "--output", + "json", + ] + ) + return {item["path"] for item in items.get("value", [])} + + +def push_repo_content( + *, + repo_id: str, + branch: str, + old_object_id: str, + files: dict[str, str], + existing_paths: set[str], + org: str, + project: str, +) -> dict[str, Any]: + changes = [ + { + "changeType": "edit" if path in existing_paths else "add", + "item": {"path": path}, + "newContent": { + "content": content, + "contentType": "rawtext", + }, + } + for path, content in files.items() + ] + payload = { + "refUpdates": [ + { + "name": f"refs/heads/{branch}", + "oldObjectId": old_object_id, + } + ], + "commits": [ + { + "comment": "Add AzureFox DevOps canary files", + "changes": changes, + } + ], + } + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + json.dump(payload, handle) + temp_path = handle.name + try: + return run_json( + [ + "az", + "devops", + "invoke", + "--org", + org, + "--area", + "git", + "--resource", + "pushes", + "--route-parameters", + f"project={project}", + f"repositoryId={repo_id}", + "--http-method", + "POST", + "--in-file", + temp_path, + "--output", + "json", + ] + ) + finally: + Path(temp_path).unlink(missing_ok=True) + + +def list_pipelines(*, org: str, project: str) -> list[dict[str, Any]]: + value = run_json( + [ + "az", + "pipelines", + "list", + "--org", + org, + "--project", + project, + "--output", + "json", + ] + ) + if not isinstance(value, list): + raise RuntimeError("az pipelines list did not return a list") + return value + + +def list_service_endpoints(*, org: str, project: str) -> list[dict[str, Any]]: + value = run_json( + [ + "az", + "devops", + "service-endpoint", + "list", + "--org", + org, + "--project", + project, + "--output", + "json", + ] + ) + if not isinstance(value, list): + raise RuntimeError("az devops service-endpoint list did not return a list") + return value + + +def list_variable_groups(*, org: str, project: str) -> list[dict[str, Any]]: + value = run_json( + [ + "az", + "pipelines", + "variable-group", + "list", + "--org", + org, + "--project", + project, + "--output", + "json", + ] + ) + if not isinstance(value, list): + raise RuntimeError("az pipelines variable-group list did not return a list") + return value + + +def validate_devops_prerequisites( + *, + org: str, + project: str, + repo_name: str, + service_connection_name: str, + variable_group_name: str, +) -> None: + missing: list[str] = [] + try: + get_repository(repo_name, org=org, project=project) + except RuntimeError: + missing.append(f"- create Azure Repos repo '{repo_name}' in project '{project}'") + + if service_connection_name not in { + endpoint.get("name") for endpoint in list_service_endpoints(org=org, project=project) + }: + missing.append( + "- create Azure Resource Manager service connection " + f"'{service_connection_name}' in project '{project}'" + ) + + if variable_group_name not in { + group.get("name") for group in list_variable_groups(org=org, project=project) + }: + missing.append( + f"- create variable group '{variable_group_name}' in project '{project}' " + "with at least one placeholder variable such as LAB_PROOF_SECRET=not-real" + ) + + if missing: + raise RuntimeError( + "Azure DevOps lab prerequisites are missing for the canary sync:\n" + + "\n".join(missing) + ) + + +def ensure_pipeline( + *, + pipeline_name: str, + yaml_path: str, + repo_name: str, + branch: str, + existing_names: set[str], + org: str, + project: str, +) -> None: + if pipeline_name in existing_names: + return + run_json( + [ + "az", + "pipelines", + "create", + "--org", + org, + "--project", + project, + "--name", + pipeline_name, + "--repository", + repo_name, + "--repository-type", + "tfsgit", + "--branch", + branch, + "--yml-path", + yaml_path.lstrip("/"), + "--skip-first-run", + "true", + "--output", + "json", + ] + ) + + +def main() -> None: + args = parse_args() + org_url = normalize_org_url(args.org) + validate_devops_prerequisites( + org=org_url, + project=args.project, + repo_name=args.repo, + service_connection_name=args.service_connection, + variable_group_name=args.variable_group, + ) + manifest = read_manifest(args.lab_dir) + rendered_files = render_canary_files(manifest, args) + repo = get_repository(args.repo, org=org_url, project=args.project) + head_commit = get_branch_head(repo["id"], args.branch, org=org_url, project=args.project) + existing_paths = list_repo_paths(repo["id"], args.branch, org=org_url, project=args.project) + push_repo_content( + repo_id=repo["id"], + branch=args.branch, + old_object_id=head_commit, + files=rendered_files, + existing_paths=existing_paths, + org=org_url, + project=args.project, + ) + + if args.skip_pipeline_create: + return + + existing_names = {pipeline["name"] for pipeline in list_pipelines(org=org_url, project=args.project)} + ensure_pipeline( + pipeline_name=args.root_pipeline_name, + yaml_path="/azure-pipelines.yml", + repo_name=args.repo, + branch=args.branch, + existing_names=existing_names, + org=org_url, + project=args.project, + ) + existing_names.add(args.root_pipeline_name) + ensure_pipeline( + pipeline_name=args.template_pipeline_name, + yaml_path="/pipelines/template-follow.yml", + repo_name=args.repo, + branch=args.branch, + existing_names=existing_names, + org=org_url, + project=args.project, + ) + existing_names.add(args.template_pipeline_name) + ensure_pipeline( + pipeline_name=args.named_target_pipeline_name, + yaml_path="/pipelines/named-target.yml", + repo_name=args.repo, + branch=args.branch, + existing_names=existing_names, + org=org_url, + project=args.project, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_azurefox_lab.py b/scripts/validate_azurefox_lab.py index bbeef3c..5681170 100755 --- a/scripts/validate_azurefox_lab.py +++ b/scripts/validate_azurefox_lab.py @@ -742,6 +742,13 @@ def find_function_app(payload: dict[str, Any], name: str) -> dict[str, Any]: raise AssertionError(f"functions output missing asset '{name}'") +def find_devops_pipeline(payload: dict[str, Any], name: str) -> dict[str, Any]: + for pipeline in payload.get("pipelines", []): + if pipeline.get("name") == name: + return pipeline + raise AssertionError(f"devops output missing pipeline '{name}'") + + def find_container_app(payload: dict[str, Any], name: str) -> dict[str, Any]: for asset in payload.get("container_apps", []): if asset.get("name") == name: @@ -1254,6 +1261,7 @@ def validate_outputs( ) devops_organization = (devops.get("metadata") or {}).get("devops_organization") if devops_organization: + expected_devops = phase4_manifest.get("devops", {}) assert_true( devops_config_issue is None, "devops unexpectedly reported an organization-configuration issue despite metadata.devops_organization", @@ -1262,8 +1270,42 @@ def validate_outputs( isinstance(devops.get("pipelines", []), list), "devops did not return a pipelines list", ) + expected_service_connection_name = expected_devops.get("expected_service_connection_name") + expected_variable_group_name = expected_devops.get("expected_variable_group_name") + expected_pipelines = expected_devops.get("pipelines", {}) + for pipeline_key in ("root_yaml", "template_follow", "named_target"): + pipeline_expectation = expected_pipelines.get(pipeline_key) + if not pipeline_expectation: + continue + pipeline = find_devops_pipeline(devops, pipeline_expectation["name"]) + if expected_service_connection_name: + assert_true( + expected_service_connection_name + in pipeline.get("azure_service_connection_names", []), + f"devops pipeline '{pipeline_expectation['name']}' missing expected Azure service connection", + ) + if pipeline_expectation.get("expect_variable_group") and expected_variable_group_name: + assert_true( + expected_variable_group_name in pipeline.get("variable_group_names", []), + f"devops pipeline '{pipeline_expectation['name']}' missing expected variable group", + ) + if pipeline_expectation.get("expect_named_target"): + expected_target_clue = pipeline_expectation["expected_target_clue"] + assert_true( + expected_target_clue in pipeline.get("target_clues", []), + f"devops pipeline '{pipeline_expectation['name']}' missing expected named-target clue", + ) + assert_true( + pipeline.get("missing_target_mapping") is False, + f"devops pipeline '{pipeline_expectation['name']}' unexpectedly stayed in missing-target mode", + ) + else: + assert_true( + pipeline.get("missing_target_mapping") is True, + f"devops pipeline '{pipeline_expectation['name']}' unexpectedly claimed a named target", + ) checks.append( - "devops used the configured Azure DevOps organization and returned pipeline evidence without a configuration error" + "devops used the configured Azure DevOps organization and surfaced the root-YAML, template-follow, and named-target canaries" ) else: assert_true( diff --git a/tests/test_sync_devops_canaries.py b/tests/test_sync_devops_canaries.py new file mode 100644 index 0000000..ea02e61 --- /dev/null +++ b/tests/test_sync_devops_canaries.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import importlib.util +import unittest +from unittest import mock +from pathlib import Path + + +def load_sync_module(): + module_path = ( + Path("/Users/cfarley/Documents/HarrierOps/Azure/Terraform Labs for AzureFox") + / "scripts" + / "sync_devops_canaries.py" + ) + spec = importlib.util.spec_from_file_location("sync_devops_canaries", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class SyncDevOpsCanariesTests(unittest.TestCase): + def test_normalize_org_url_accepts_name_or_url(self) -> None: + sync = load_sync_module() + + self.assertEqual( + sync.normalize_org_url("example-proof-lab"), + "https://dev.azure.com/example-proof-lab", + ) + self.assertEqual( + sync.normalize_org_url("https://dev.azure.com/example-proof-lab/"), + "https://dev.azure.com/example-proof-lab", + ) + + def test_select_named_webapp_prefers_public_named_asset(self) -> None: + sync = load_sync_module() + + selected = sync.select_named_webapp( + { + "phase3_checkpoint": { + "app_services": { + "expected_assets": [ + { + "name": "app-empty-mi-123456", + "public_network_access": "Enabled", + }, + { + "name": "app-public-api-123456", + "public_network_access": "Enabled", + }, + ] + } + } + } + ) + + self.assertEqual(selected, "app-public-api-123456") + + def test_render_canary_files_includes_lab_specific_values(self) -> None: + sync = load_sync_module() + + args = type( + "Args", + (), + { + "service_connection": "af-rg-reader", + "variable_group": "af-proof-lab-vars", + }, + )() + rendered = sync.render_canary_files( + { + "resource_groups": { + "ops": "rg-ops", + "workload": "rg-workload", + }, + "phase3_checkpoint": { + "app_services": { + "expected_assets": [ + { + "name": "app-empty-mi-123456", + "public_network_access": "Enabled", + }, + { + "name": "app-public-api-123456", + "public_network_access": "Enabled", + }, + ] + } + }, + }, + args, + ) + + self.assertIn("af-proof-lab-vars", rendered["/azure-pipelines.yml"]) + self.assertIn("/templates/deploy-canary.yml", rendered["/pipelines/template-follow.yml"]) + self.assertIn("af-rg-reader", rendered["/templates/deploy-canary.yml"]) + self.assertIn("af-proof-lab-vars", rendered["/templates/deploy-canary.yml"]) + self.assertIn("rg-workload", rendered["/templates/deploy-canary.yml"]) + self.assertIn("app-public-api-123456", rendered["/pipelines/named-target.yml"]) + + def test_validate_devops_prerequisites_raises_for_missing_items(self) -> None: + sync = load_sync_module() + + with self.assertRaisesRegex(RuntimeError, "Azure DevOps lab prerequisites are missing"): + with mock.patch.object(sync, "get_repository", side_effect=RuntimeError("missing")), \ + mock.patch.object(sync, "list_service_endpoints", return_value=[]), \ + mock.patch.object(sync, "list_variable_groups", return_value=[]): + sync.validate_devops_prerequisites( + org="https://dev.azure.com/example", + project="Azurefox Proof Lab", + repo_name="lab-proof", + service_connection_name="af-rg-reader", + variable_group_name="af-proof-lab-vars", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_validate_azurefox_lab.py b/tests/test_validate_azurefox_lab.py index 1141cde..fd6dad6 100644 --- a/tests/test_validate_azurefox_lab.py +++ b/tests/test_validate_azurefox_lab.py @@ -264,6 +264,23 @@ def test_validate_application_gateway_output_accepts_manifest_backed_row(self) - self.assertIn("application-gateway surfaced", message) + def test_find_devops_pipeline_returns_matching_pipeline(self) -> None: + validator = load_validator_module() + + pipeline = validator.find_devops_pipeline( + { + "pipelines": [ + { + "name": "lab-proof-targeted", + "target_clues": ["App Service: app-public-api-123456"], + } + ] + }, + "lab-proof-targeted", + ) + + self.assertEqual(pipeline["name"], "lab-proof-targeted") + def test_validate_application_gateway_output_accepts_firewall_policy_without_mode(self) -> None: validator = load_validator_module() From 98fcd46444a1500f0cc0699a17f8e76406d3cbb7 Mon Sep 17 00:00:00 2001 From: TacoRocket Date: Sat, 18 Apr 2026 14:58:01 -0500 Subject: [PATCH 2/2] Format outputs for CI --- outputs.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/outputs.tf b/outputs.tf index 2bef5bf..56e0178 100644 --- a/outputs.tf +++ b/outputs.tf @@ -586,14 +586,14 @@ output "validation_manifest" { expected_variable_group_name = "af-proof-lab-vars" pipelines = { root_yaml = { - name = "lab-proof" - expect_variable_group = true - expect_named_target = false + name = "lab-proof" + expect_variable_group = true + expect_named_target = false } template_follow = { - name = "lab-proof-template" - expect_variable_group = true - expect_named_target = false + name = "lab-proof-template" + expect_variable_group = true + expect_named_target = false } named_target = { name = "lab-proof-targeted"