diff --git a/README.md b/README.md index 4b4d0ce..bd14b09 100644 --- a/README.md +++ b/README.md @@ -302,10 +302,13 @@ Current deployment is approval-oriented and staged: 2. Run inventory and preflight checks. 3. Generate deployment artifacts. 4. Optionally run `mount-iso` to prepare Redfish virtual media boot. -5. Hand off to provider-specific execution gates. +5. Run `deploy-azure-local` to prepare the Azure Local provider handoff. +6. Review the generated execution manifest and promote only lab-validated provider adapters to live execution. The `mount-iso` job defaults to a simulated execution contract. Set `STRATAONE_ENABLE_LIVE_REDFISH=true` to execute Redfish `VirtualMedia.InsertMedia` and one-time CD/DVD boot override calls against discovered BMC endpoints. +The `deploy-azure-local` job validates Azure Local desired-state fields and the saved `azure-local` provider configuration, generates the Azure Local artifact bundle, writes `azure-local-execution-manifest.json`, emits stage events, and returns `ready-for-provider-handoff` only when tenant, subscription, resource group, region, and `credential_ref` are configured. It does not perform live Azure mutations. + Live or materially changing actions are approval-gated when `STRATAONE_REQUIRE_APPROVALS=true`. A request returns an `approval_id`; approve it through `POST /approvals/{approval_id}/approve`, then retry the action with that `approval_id`. Production job execution uses PostgreSQL for durable state and Redis for dispatch: @@ -411,7 +414,7 @@ This repository currently contains the first buildable foundation: - Azure Local deployment artifact generation - Redfish capability checks - Redfish virtual-media insert/eject and boot override client operations -- Azure Local staged deployment execution contract +- Azure Local provider handoff execution contract with manifest evidence - Drift detection and node replacement lifecycle workflows - Provider detail and configuration API/dashboard controls - Built-in and filesystem provider discovery @@ -424,7 +427,7 @@ This repository currently contains the first buildable foundation: - Example Azure Local branch configuration - Unit and integration-style Redfish mock tests -The next implementation milestone is to implement provider-specific Azure Local execution steps behind explicit approval gates and validate each OEM provider in a hardware lab. +The next implementation milestone is to promote one Azure Local provider handoff stage to a lab-validated live adapter behind explicit approval gates and validate each OEM provider in a hardware lab. ## Production Readiness Notes diff --git a/docs/FIELD_INSTALLATION_GUIDE.md b/docs/FIELD_INSTALLATION_GUIDE.md index e8dd8c3..0955547 100644 --- a/docs/FIELD_INSTALLATION_GUIDE.md +++ b/docs/FIELD_INSTALLATION_GUIDE.md @@ -91,6 +91,14 @@ Go to Providers: Live provider execution should remain disabled until the relevant OEM/platform has validation evidence and approval policy is configured. +For Azure Local handoff readiness, save an `azure-local` provider configuration with: + +- `tenant_id` +- `subscription_id` +- `resource_group` +- `region` +- `credential_ref` + ## 7. Create A Deployment Go to Deployments or New Deployment: @@ -102,6 +110,7 @@ Go to Deployments or New Deployment: 5. Review readiness. 6. Save deployment. 7. Run Validate, Inventory, Preflight, Plan, Artifacts. +8. Run `deploy-azure-local` after approval to produce the provider handoff manifest. ## 8. Approvals diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d1f67da..897edce 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -88,6 +88,8 @@ Each job has: Reports include job metadata, site state, events, and status summary. +For Azure Local sites, the `deploy-azure-local` run prepares a provider handoff. It validates the saved `azure-local` provider configuration, generates deployment artifacts, writes an `azure-local-execution-manifest.json` evidence file, and marks the handoff ready only when required Azure fields and a credential reference are configured. Live Azure mutation remains outside this first handoff stage. + ## Approvals Protected actions appear in Approvals. @@ -149,4 +151,3 @@ Audit: 7. Request/approve protected live action. 8. Run deployment or lifecycle workflow. 9. Export job report. - diff --git a/src/strataone/azure_local.py b/src/strataone/azure_local.py new file mode 100644 index 0000000..4ee02cb --- /dev/null +++ b/src/strataone/azure_local.py @@ -0,0 +1,114 @@ +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from strataone.artifacts import ArtifactGenerator +from strataone.state import PlatformType, SiteSpec + + +AZURE_LOCAL_REQUIRED_CONFIG = ("tenant_id", "subscription_id", "resource_group", "region", "credential_ref") + + +class AzureLocalExecutionContract: + """Build the first safe Azure Local provider handoff without live mutation.""" + + def __init__(self, artifact_generator: ArtifactGenerator | None = None) -> None: + self.artifact_generator = artifact_generator or ArtifactGenerator() + + def prepare(self, spec: SiteSpec, provider_config: dict[str, Any] | None, approval_id: str | None = None) -> dict[str, Any]: + if spec.platform.type != PlatformType.AZURE_LOCAL: + raise ValueError("deploy-azure-local is only supported for Azure Local sites") + + config = self._merged_config(spec, provider_config or {}) + missing = [field for field in AZURE_LOCAL_REQUIRED_CONFIG if not config.get(field)] + bundle = self.artifact_generator.generate(spec) + stages = self._stages(spec, config, missing) + status = "ready-for-provider-handoff" if not missing else "blocked-missing-provider-config" + manifest = { + "generated_at": datetime.now(UTC).isoformat(), + "site_name": spec.site.name, + "approval_id": approval_id, + "provider": "azure-local", + "status": status, + "missing_provider_config": missing, + "azure": {key: value for key, value in config.items() if key != "credential_ref"}, + "credential_ref": config.get("credential_ref"), + "artifact_files": bundle.files, + "stages": stages, + } + manifest_path = Path(bundle.output_dir) / "azure-local-execution-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + return { + "site_name": spec.site.name, + "action": "deploy-azure-local", + "execution_mode": "azure-local-provider-handoff", + "approval_id": approval_id, + "status": status, + "provider_configured": not missing, + "missing_provider_config": missing, + "azure": manifest["azure"], + "artifact_bundle": { + "output_dir": bundle.output_dir, + "files": [*bundle.files, str(manifest_path)], + }, + "stages": stages, + "next_actions": self._next_actions(missing), + "note": "This stage prepares reviewed Azure Local provider artifacts and evidence; live Azure mutation remains gated for a later validated provider adapter.", + } + + def _merged_config(self, spec: SiteSpec, provider_config: dict[str, Any]) -> dict[str, Any]: + azure = spec.platform.azure + return { + "tenant_id": provider_config.get("tenant_id") or (azure.tenant_id if azure else None), + "subscription_id": provider_config.get("subscription_id") or (azure.subscription_id if azure else None), + "resource_group": provider_config.get("resource_group") or (azure.resource_group if azure else None), + "region": provider_config.get("region") or (azure.region if azure else None), + "credential_ref": provider_config.get("credential_ref"), + "cluster_name": provider_config.get("cluster_name") or spec.platform.cluster_name or spec.site.name, + "topology": spec.platform.topology, + } + + def _stages(self, spec: SiteSpec, config: dict[str, Any], missing: list[str]) -> list[dict[str, str]]: + config_status = "succeeded" if not missing else "blocked" + return [ + { + "name": "validate-provider-config", + "description": "Validate Azure tenant, subscription, resource group, region, and credential reference", + "status": config_status, + }, + { + "name": "generate-provider-artifacts", + "description": "Generate Azure Local parameters, Bicep, Terraform handoff, and Arc registration review script", + "status": "succeeded", + }, + { + "name": "prepare-arc-registration", + "description": f"Prepare Arc registration handoff for {len(spec.hardware.nodes)} node(s) in {config.get('resource_group') or ''}", + "status": "ready" if not missing else "waiting-for-config", + }, + { + "name": "deploy-resource-model", + "description": f"Prepare Azure Local resource model for cluster {config.get('cluster_name')}", + "status": "ready" if not missing else "waiting-for-config", + }, + { + "name": "configure-cluster-baseline", + "description": "Prepare topology, networking, storage, policy, monitoring, and update-management baseline handoff", + "status": "ready" if not missing else "waiting-for-config", + }, + ] + + def _next_actions(self, missing: list[str]) -> list[str]: + if missing: + return [ + f"Save Azure Local provider configuration with: {', '.join(missing)}.", + "Retry deploy-azure-local after approval once provider configuration is complete.", + "Keep live Azure mutation disabled until the provider adapter has lab validation evidence.", + ] + return [ + "Review generated Azure Local artifacts and execution manifest.", + "Validate provider credentials and Azure role assignments outside StrataOne.", + "Promote the next slice to a lab-validated live Azure provider adapter behind approvals.", + ] diff --git a/src/strataone/jobs.py b/src/strataone/jobs.py index dec5409..a4006e6 100644 --- a/src/strataone/jobs.py +++ b/src/strataone/jobs.py @@ -3,6 +3,7 @@ from typing import Any from strataone.artifacts import ArtifactGenerator +from strataone.azure_local import AzureLocalExecutionContract from strataone.inventory import InventoryReport from strataone.orchestrator import Orchestrator from strataone.preflight import PreflightRunner @@ -221,32 +222,25 @@ def _eject_iso(self, spec, params: dict[str, Any]) -> dict[str, Any]: return {"site_name": spec.site.name, "action": "eject-iso", "nodes": nodes, "execution_mode": mode} def _deploy_azure_local(self, spec, params: dict[str, Any]) -> dict[str, Any]: - if spec.platform.type.value != "azure-local": - raise ValueError("deploy-azure-local is only supported for Azure Local sites") - stages = [ - self._stage("validate-prerequisites", "Validate Azure tenant, subscription, resource group, region, and node count", "succeeded"), - self._stage("prepare-arc", "Prepare Azure Arc onboarding package and service principal contract", "succeeded"), - self._stage("register-nodes", "Register target nodes as Arc-connected machines", "ready"), - self._stage("deploy-arm", "Deploy Azure Local resource model through ARM/Bicep desired state", "ready"), - self._stage("configure-cluster", "Configure topology, networking, storage, and witness settings", "ready"), - self._stage("attach-governance", "Attach Azure Policy, Monitor, Defender, and Update Manager baselines", "ready"), - ] - for stage in stages: + provider_config = self.store.get_provider_config("azure-local") + result = AzureLocalExecutionContract().prepare( + spec, + provider_config.config if provider_config else None, + approval_id=params.get("approval_id"), + ) + for stage in result["stages"]: self.store.add_job_event(params.get("_job_id", "unknown"), "info", stage["name"], {"status": stage["status"]}) - return { - "site_name": spec.site.name, - "action": "deploy-azure-local", - "execution_mode": "staged-provider-contract", - "approval_id": params.get("approval_id"), - "azure": spec.platform.azure.model_dump(mode="json") if spec.platform.azure else {}, - "stages": stages, - "next_actions": [ - "Confirm provider credentials and Azure permissions.", - "Run preflight and resolve warnings.", - "Approve live deployment action before provider execution.", - ], - "note": "Stages are ready for live Azure execution once provider credentials, approvals, and lab validation are configured.", - } + self.store.add_job_event( + params.get("_job_id", "unknown"), + "info", + "Azure Local provider handoff prepared", + { + "status": result["status"], + "provider_configured": result["provider_configured"], + "artifact_count": len(result["artifact_bundle"]["files"]), + }, + ) + return result def _drift_detect(self, spec, params: dict[str, Any]) -> dict[str, Any]: inventory = self.store.get_inventory(spec.site.name) diff --git a/tests/test_store_jobs.py b/tests/test_store_jobs.py index d531bea..4af06d3 100644 --- a/tests/test_store_jobs.py +++ b/tests/test_store_jobs.py @@ -56,7 +56,51 @@ def test_job_runner_executes_lifecycle_and_azure_local_contracts(tmp_path: Path) time.sleep(0.05) assert store.get_job(drift_id).result["action"] == "drift-detect" - assert store.get_job(deploy_id).result["stages"][0]["name"] == "validate-prerequisites" + deploy = store.get_job(deploy_id) + assert deploy.result["execution_mode"] == "azure-local-provider-handoff" + assert deploy.result["status"] == "blocked-missing-provider-config" + assert deploy.result["missing_provider_config"] == ["credential_ref"] + assert deploy.result["stages"][0]["name"] == "validate-provider-config" + assert deploy.result["stages"][0]["status"] == "blocked" + assert any(path.endswith("azure-local-execution-manifest.json") for path in deploy.result["artifact_bundle"]["files"]) + + +def test_azure_local_contract_is_ready_with_provider_config(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_ARTIFACT_DIR", str(tmp_path / "artifacts")) + store = StrataStore(tmp_path / "strataone.db") + store.upsert_site(load_site_spec(Path("examples/azure-local-branch.yaml"))) + store.upsert_provider_config( + "azure-local", + { + "tenant_id": "tenant-001", + "subscription_id": "subscription-001", + "resource_group": "rg-branch-001", + "region": "westeurope", + "credential_ref": "azure-local-spn", + }, + ) + runner = JobRunner(store) + + job_id = runner.submit("branch-001", "deploy-azure-local", {"approval_id": "approval-001"}) + + for _ in range(30): + job = store.get_job(job_id) + if job and job.status == "succeeded": + break + time.sleep(0.05) + + job = store.get_job(job_id) + assert job.status == "succeeded" + assert job.result["status"] == "ready-for-provider-handoff" + assert job.result["provider_configured"] is True + assert job.result["missing_provider_config"] == [] + assert job.result["azure"]["subscription_id"] == "subscription-001" + assert all(stage["status"] in {"succeeded", "ready"} for stage in job.result["stages"]) + manifest = Path(job.result["artifact_bundle"]["output_dir"]) / "azure-local-execution-manifest.json" + assert manifest.exists() + assert "azure-local-spn" in manifest.read_text(encoding="utf-8") + events = store.list_job_events(job_id) + assert any(event.message == "Azure Local provider handoff prepared" for event in events) def test_job_runner_can_leave_jobs_for_durable_worker(tmp_path: Path, monkeypatch) -> None: