From b5e4ff52799d8a92e12cb02424409b366ef53d38 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:48:03 +1000 Subject: [PATCH 1/6] test(databricks): add opt-in live release deployment smoke --- ...est_databricks_release_deployment_smoke.py | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 tests/live/test_databricks_release_deployment_smoke.py diff --git a/tests/live/test_databricks_release_deployment_smoke.py b/tests/live/test_databricks_release_deployment_smoke.py new file mode 100644 index 00000000..0ef2a96b --- /dev/null +++ b/tests/live/test_databricks_release_deployment_smoke.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import os +import re + +import pytest +from open_data_contract_standard.model import ( + OpenDataContractStandard, + SchemaObject, + SchemaProperty, +) + +from semapact.application.services.deployment_workflow import DeploymentWorkflowService +from semapact.application.services.release_workflow import ( + ReleaseFinalizer, + ReleaseWorkflowService, +) +from semapact.deployment import DeploymentTarget, NativeOperationKind +from semapact.governance import DecisionResult +from semapact.platforms.databricks import create_databricks_workspace_client +from semapact.platforms.databricks.deployment import ( + DatabricksDeploymentExecutionConfig, + DatabricksStatementExecutor, +) +from semapact.platforms.runtime_registry import create_deployment_adapter +from semapact.reconciliation import RuntimeDriftStatus + + +pytestmark = pytest.mark.live_databricks + +_RESERVED_TAGS = ( + "semapact_contract_id", + "semapact_contract_version", + "semapact_release_id", + "semapact_source_revision", +) +_CONFIRMATION = "I_UNDERSTAND_THIS_CREATES_TABLES" + + +def _required_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + pytest.fail(f"live Databricks smoke requires {name}") + return value + + +def _safe_schema(name: str) -> str: + if not name.casefold().startswith("semapact_smoke_"): + pytest.fail( + "live Databricks smoke schemas must start with 'semapact_smoke_'" + ) + return name + + +def _safe_run_id(value: str) -> str: + normalized = re.sub(r"[^a-zA-Z0-9_]", "_", value.strip()) + if not normalized: + pytest.fail("SEMAPACT_LIVE_RUN_ID must contain an identifier") + return normalized[:48].casefold() + + +def _contract( + *, + table_name: str, + include_note: bool, +) -> OpenDataContractStandard: + properties = [ + SchemaProperty( + name="id", + physicalName="id", + logicalType="integer", + physicalType="BIGINT", + required=True, + ) + ] + if include_note: + properties.append( + SchemaProperty( + name="note", + physicalName="note", + logicalType="string", + physicalType="STRING", + required=False, + ) + ) + return OpenDataContractStandard( + apiVersion="v3.1.0", + kind="DataContract", + id=f"semapact-live-smoke-{table_name}", + name=f"SemaPact live smoke {table_name}", + version="1.0.0", + status="active", + schema=[ + SchemaObject( + name=table_name, + physicalName=table_name, + physicalType="table", + properties=properties, + ) + ], + ) + + +def _target(*, catalog: str, schema_name: str, source_reference: str) -> DeploymentTarget: + return DeploymentTarget( + platform="databricks", + runtime_target=f"{catalog}.{schema_name}", + source_reference=source_reference, + ) + + +def _adapter(*, warehouse_id: str): + return create_deployment_adapter( + "databricks", + execution_config=DatabricksDeploymentExecutionConfig( + warehouse_id=warehouse_id + ), + ) + + +def _read_release_tags( + *, + client, + warehouse_id: str, + catalog: str, + schema_name: str, + table_name: str, +) -> dict[str, str]: + executor = DatabricksStatementExecutor( + client=client, + warehouse_id=warehouse_id, + poll_interval_seconds=1, + ) + keys = ", ".join(f"'{key}'" for key in _RESERVED_TAGS) + rows = executor.query_rows( + "SELECT tag_name, tag_value " + f"FROM `{catalog}`.information_schema.table_tags " + f"WHERE schema_name = '{schema_name.casefold()}' " + f"AND table_name = '{table_name.casefold()}' " + f"AND tag_name IN ({keys})" + ) + return {str(key): str(value) for key, value in rows} + + +def _delete_if_present(client, full_name: str) -> None: + exists = client.tables.exists(full_name=full_name) + if bool(getattr(exists, "table_exists", False)): + client.tables.delete(full_name=full_name) + + +def test_live_candidate_release_and_redeployment_converge() -> None: + if os.environ.get("SEMAPACT_LIVE_DATABRICKS_CONFIRM") != _CONFIRMATION: + pytest.fail( + "set SEMAPACT_LIVE_DATABRICKS_CONFIRM=" + f"{_CONFIRMATION} to run the live smoke" + ) + + catalog = _required_env("SEMAPACT_LIVE_DATABRICKS_CATALOG") + uat_schema = _safe_schema(_required_env("SEMAPACT_LIVE_DATABRICKS_UAT_SCHEMA")) + prod_schema = _safe_schema(_required_env("SEMAPACT_LIVE_DATABRICKS_PROD_SCHEMA")) + warehouse_id = _required_env("SEMAPACT_LIVE_DATABRICKS_WAREHOUSE_ID") + run_id = _safe_run_id(_required_env("SEMAPACT_LIVE_RUN_ID")) + + table_name = f"semapact_smoke_{run_id}" + client = create_databricks_workspace_client() + source_reference = str(getattr(client.config, "host", "") or "").strip() + if not source_reference: + pytest.fail("Databricks SDK did not resolve a workspace host") + + uat_full_name = f"{catalog}.{uat_schema}.{table_name}" + prod_full_name = f"{catalog}.{prod_schema}.{table_name}" + + base = _contract(table_name=table_name, include_note=False) + candidate = _contract(table_name=table_name, include_note=True) + deployment = DeploymentWorkflowService() + + try: + _delete_if_present(client, uat_full_name) + _delete_if_present(client, prod_full_name) + + uat_target = _target( + catalog=catalog, + schema_name=uat_schema, + source_reference=source_reference, + ) + + create_bundle = deployment.assess( + base, + base, + base_revision_ref=f"live:base:{run_id}", + candidate_revision_ref=f"live:base:{run_id}", + target=uat_target, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert create_bundle.is_release is False + assert create_bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + + create_result = deployment.deploy( + create_bundle, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert create_result.status is RuntimeDriftStatus.IN_SYNC + assert create_result.contract_release_id is None + + alter_bundle = deployment.assess( + base, + candidate, + base_revision_ref=f"live:base:{run_id}", + candidate_revision_ref=f"live:candidate:{run_id}", + target=uat_target, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert alter_bundle.is_release is False + assert alter_bundle.review_preview.operations[0].kind is NativeOperationKind.ALTER + + alter_result = deployment.deploy( + alter_bundle, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert alter_result.status is RuntimeDriftStatus.IN_SYNC + assert alter_result.contract_release_id is None + assert _read_release_tags( + client=client, + warehouse_id=warehouse_id, + catalog=catalog, + schema_name=uat_schema, + table_name=table_name, + ) == {} + + release_workflow = ReleaseWorkflowService() + release_bundle = release_workflow.assess( + base, + candidate, + base_revision_ref=f"live:base:{run_id}", + candidate_revision_ref=f"live:candidate:{run_id}", + ) + approval = None + if release_bundle.decision.decision is DecisionResult.REVIEW: + approval = release_workflow.approve( + release_bundle, + actor_reference="live-smoke:reviewer", + recorded_at=datetime.now(timezone.utc), + comment="Live Databricks smoke approval", + ) + release = ReleaseFinalizer().finalize( + release_bundle, + approval=approval, + ) + assert release.contract_version != base.version + + prod_target = _target( + catalog=catalog, + schema_name=prod_schema, + source_reference=source_reference, + ) + prod_bundle = deployment.assess_release( + release, + target=prod_target, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert prod_bundle.review_preview.operations[0].kind is NativeOperationKind.CREATE + + prod_result = deployment.deploy( + prod_bundle, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert prod_result.status is RuntimeDriftStatus.IN_SYNC + assert prod_result.contract_release_id == release.contract_release_id + + expected_tags = { + "semapact_contract_id": release.contract_id, + "semapact_contract_version": release.contract_version, + "semapact_release_id": release.contract_release_id, + "semapact_source_revision": release.source_revision_ref, + } + assert _read_release_tags( + client=client, + warehouse_id=warehouse_id, + catalog=catalog, + schema_name=prod_schema, + table_name=table_name, + ) == expected_tags + + repeat_result = deployment.deploy( + prod_bundle, + adapter=_adapter(warehouse_id=warehouse_id), + ) + assert repeat_result.status is RuntimeDriftStatus.IN_SYNC + assert all( + operation.kind is NativeOperationKind.NO_OP + for operation in repeat_result.fresh_preview.operations + ) + assert _read_release_tags( + client=client, + warehouse_id=warehouse_id, + catalog=catalog, + schema_name=prod_schema, + table_name=table_name, + ) == expected_tags + finally: + _delete_if_present(client, uat_full_name) + _delete_if_present(client, prod_full_name) From 44f49dff6e33c0a5bce5f96c24b6a22e57b961f6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:49:04 +1000 Subject: [PATCH 2/6] test(databricks): keep live smoke opt-in --- tests/live/test_databricks_release_deployment_smoke.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/live/test_databricks_release_deployment_smoke.py b/tests/live/test_databricks_release_deployment_smoke.py index 0ef2a96b..6d6725e7 100644 --- a/tests/live/test_databricks_release_deployment_smoke.py +++ b/tests/live/test_databricks_release_deployment_smoke.py @@ -27,7 +27,13 @@ from semapact.reconciliation import RuntimeDriftStatus -pytestmark = pytest.mark.live_databricks +pytestmark = [ + pytest.mark.live_databricks, + pytest.mark.skipif( + os.environ.get("SEMAPACT_RUN_LIVE_DATABRICKS") != "1", + reason="live Databricks smoke is opt-in", + ), +] _RESERVED_TAGS = ( "semapact_contract_id", From a0237436b06056575dee5d2921f4291351605220 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:49:16 +1000 Subject: [PATCH 3/6] test: register live Databricks marker --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bf8b0ddc..998f47bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,9 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +markers = [ + "live_databricks: opt-in tests against a real Databricks workspace", +] [tool.setuptools.packages.find] include = ["semapact*"] From cd2f1f834ddfe7512526b2321c587aa2363b68b6 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:49:27 +1000 Subject: [PATCH 4/6] ci(databricks): add protected live smoke workflow --- .github/workflows/live-databricks-smoke.yml | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/live-databricks-smoke.yml diff --git a/.github/workflows/live-databricks-smoke.yml b/.github/workflows/live-databricks-smoke.yml new file mode 100644 index 00000000..7d5e048f --- /dev/null +++ b/.github/workflows/live-databricks-smoke.yml @@ -0,0 +1,71 @@ +name: Live Databricks Smoke + +on: + workflow_dispatch: + inputs: + catalog: + description: Unity Catalog catalog containing dedicated smoke schemas + required: true + type: string + uat_schema: + description: Dedicated UAT schema (must start with semapact_smoke_) + required: true + default: semapact_smoke_uat + type: string + prod_schema: + description: Dedicated PROD schema (must start with semapact_smoke_) + required: true + default: semapact_smoke_prod + type: string + warehouse_id: + description: SQL warehouse ID used for smoke DDL and tag queries + required: true + type: string + +permissions: + contents: read + +concurrency: + group: live-databricks-smoke + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: databricks-smoke + env: + UV_NO_PROGRESS: "1" + SEMAPACT_RUN_LIVE_DATABRICKS: "1" + SEMAPACT_LIVE_DATABRICKS_CONFIRM: I_UNDERSTAND_THIS_CREATES_TABLES + SEMAPACT_LIVE_DATABRICKS_CATALOG: ${{ inputs.catalog }} + SEMAPACT_LIVE_DATABRICKS_UAT_SCHEMA: ${{ inputs.uat_schema }} + SEMAPACT_LIVE_DATABRICKS_PROD_SCHEMA: ${{ inputs.prod_schema }} + SEMAPACT_LIVE_DATABRICKS_WAREHOUSE_ID: ${{ inputs.warehouse_id }} + SEMAPACT_LIVE_RUN_ID: ${{ github.run_id }}_${{ github.run_attempt }} + DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }} + DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install Databricks test dependencies + run: uv sync --extra databricks --group dev --frozen + + - name: Run live Databricks smoke + run: >- + uv run pytest + tests/live/test_databricks_release_deployment_smoke.py + -m live_databricks + -vv From 32b5fa90e65b508a56ed919cf3fe7d7d3ad2f92b Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:49:43 +1000 Subject: [PATCH 5/6] docs(databricks): document opt-in live smoke --- docs/live_databricks_smoke.md | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/live_databricks_smoke.md diff --git a/docs/live_databricks_smoke.md b/docs/live_databricks_smoke.md new file mode 100644 index 00000000..9efa1f09 --- /dev/null +++ b/docs/live_databricks_smoke.md @@ -0,0 +1,155 @@ +# Live Databricks smoke + +The live Databricks smoke is an opt-in production-integration check for the canonical SemaPact Data Engineer workflow. + +It is intentionally separate from normal PR CI. + +## What it proves + +The smoke runs against a real Databricks workspace and exercises: + +```text +candidate desired state +→ CREATE in UAT +→ fresh IN_SYNC + +candidate schema evolution +→ ADD nullable column in UAT +→ fresh IN_SYNC + +formal release +→ ContractRelease + +ContractRelease +→ CREATE in PROD +→ fresh IN_SYNC +→ Unity Catalog release provenance tags + +same ContractRelease again +→ NO_OP +→ fresh IN_SYNC +→ provenance projection remains idempotent +``` + +Candidate deployment must not create formal release provenance tags. + +## Safety boundary + +The smoke only creates generated tables whose names begin with: + +```text +semapact_smoke_ +``` + +Both configured schemas must also begin with: + +```text +semapact_smoke_ +``` + +The schemas must already exist. The smoke never creates or deletes catalogs or schemas. + +Each run uses a unique table name derived from the GitHub Actions run ID. Cleanup deletes only those exact generated tables. + +The test also requires the explicit confirmation value: + +```text +SEMAPACT_LIVE_DATABRICKS_CONFIRM=I_UNDERSTAND_THIS_CREATES_TABLES +``` + +## GitHub Actions + +Run: + +```text +Live Databricks Smoke +``` + +through `workflow_dispatch`. + +The workflow uses the protected GitHub Environment: + +```text +databricks-smoke +``` + +Configure the environment with Databricks unified-auth credentials. Supported standard environment secrets include: + +```text +DATABRICKS_HOST + +# PAT option +DATABRICKS_TOKEN + +# OAuth service-principal option +DATABRICKS_CLIENT_ID +DATABRICKS_CLIENT_SECRET +``` + +Do not configure both authentication approaches unless the Databricks SDK configuration intentionally requires it. + +Workflow inputs provide: + +```text +catalog +uat_schema +prod_schema +warehouse_id +``` + +Recommended dedicated schemas: + +```text +semapact_smoke_uat +semapact_smoke_prod +``` + +## Required Databricks capability + +The smoke identity must be able to: + +* use the configured catalog and smoke schemas; +* observe table metadata; +* create managed Delta tables in the smoke schemas; +* add a nullable column; +* query the catalog information schema for table tags; +* apply/remove SemaPact-owned table tags; +* delete only the generated smoke tables during cleanup; +* execute the required SQL through the configured warehouse. + +The more general least-privilege production identity design remains tracked separately in the private roadmap. + +## Local execution + +The test is skipped unless explicitly enabled. + +Example environment: + +```text +SEMAPACT_RUN_LIVE_DATABRICKS=1 +SEMAPACT_LIVE_DATABRICKS_CONFIRM=I_UNDERSTAND_THIS_CREATES_TABLES +SEMAPACT_LIVE_DATABRICKS_CATALOG= +SEMAPACT_LIVE_DATABRICKS_UAT_SCHEMA=semapact_smoke_uat +SEMAPACT_LIVE_DATABRICKS_PROD_SCHEMA=semapact_smoke_prod +SEMAPACT_LIVE_DATABRICKS_WAREHOUSE_ID= +SEMAPACT_LIVE_RUN_ID=local_001 +``` + +Then run: + +```bash +pytest tests/live/test_databricks_release_deployment_smoke.py -m live_databricks -vv +``` + +Databricks authentication is resolved by the official SDK through its normal unified-auth configuration. + +## Non-goals + +This smoke does not test: + +* destructive schema evolution; +* rename/type/nullability migration; +* customer production tables; +* performance or load; +* continuous monitoring; +* infrastructure provisioning. From d84283511dcd3bdb57e2dd31e417b499ee6b1543 Mon Sep 17 00:00:00 2001 From: Elliot Sun Date: Mon, 21 Sep 2026 21:50:07 +1000 Subject: [PATCH 6/6] test(databricks): harden live smoke target safety --- ...test_databricks_release_deployment_smoke.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/live/test_databricks_release_deployment_smoke.py b/tests/live/test_databricks_release_deployment_smoke.py index 6d6725e7..848fe17f 100644 --- a/tests/live/test_databricks_release_deployment_smoke.py +++ b/tests/live/test_databricks_release_deployment_smoke.py @@ -17,6 +17,7 @@ ReleaseWorkflowService, ) from semapact.deployment import DeploymentTarget, NativeOperationKind +from semapact.exceptions import ValidationError from semapact.governance import DecisionResult from semapact.platforms.databricks import create_databricks_workspace_client from semapact.platforms.databricks.deployment import ( @@ -25,6 +26,7 @@ ) from semapact.platforms.runtime_registry import create_deployment_adapter from semapact.reconciliation import RuntimeDriftStatus +from semapact.schema import validate_simple_sql_identifier pytestmark = [ @@ -51,7 +53,16 @@ def _required_env(name: str) -> str: return value +def _safe_identifier(name: str, label: str) -> str: + try: + validate_simple_sql_identifier(name, label) + except ValidationError as exc: + pytest.fail(str(exc)) + return name + + def _safe_schema(name: str) -> str: + _safe_identifier(name, "schema") if not name.casefold().startswith("semapact_smoke_"): pytest.fail( "live Databricks smoke schemas must start with 'semapact_smoke_'" @@ -162,9 +173,14 @@ def test_live_candidate_release_and_redeployment_converge() -> None: f"{_CONFIRMATION} to run the live smoke" ) - catalog = _required_env("SEMAPACT_LIVE_DATABRICKS_CATALOG") + catalog = _safe_identifier( + _required_env("SEMAPACT_LIVE_DATABRICKS_CATALOG"), + "catalog", + ) uat_schema = _safe_schema(_required_env("SEMAPACT_LIVE_DATABRICKS_UAT_SCHEMA")) prod_schema = _safe_schema(_required_env("SEMAPACT_LIVE_DATABRICKS_PROD_SCHEMA")) + if uat_schema.casefold() == prod_schema.casefold(): + pytest.fail("live Databricks smoke requires distinct UAT and PROD schemas") warehouse_id = _required_env("SEMAPACT_LIVE_DATABRICKS_WAREHOUSE_ID") run_id = _safe_run_id(_required_env("SEMAPACT_LIVE_RUN_ID"))