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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/FIELD_INSTALLATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -149,4 +151,3 @@ Audit:
7. Request/approve protected live action.
8. Run deployment or lifecycle workflow.
9. Export job report.

114 changes: 114 additions & 0 deletions src/strataone/azure_local.py
Original file line number Diff line number Diff line change
@@ -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 '<resource-group>'}",
"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.",
]
44 changes: 19 additions & 25 deletions src/strataone/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 45 additions & 1 deletion tests/test_store_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading