mostlyright-data 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
"""Bring one released hosted Build down and derive its table notebook here.
|
|
2
|
+
|
|
3
|
+
The hosted half of a deployment ends with Studio releasing an independently checked Build. This
|
|
4
|
+
module is the local half: it reads that release through the pinned generated client, redeems one
|
|
5
|
+
Studio-issued signed download session for the exact sealed evidence, verifies the transferred bytes
|
|
6
|
+
against the digests the release already sealed, rebuilds the Build tree on this machine, verifies
|
|
7
|
+
*that* by ordinary local replay, and only then asks the existing deterministic generator for
|
|
8
|
+
``table.ipynb``.
|
|
9
|
+
|
|
10
|
+
Three properties are the whole point, and each of them is a refusal rather than a comment.
|
|
11
|
+
|
|
12
|
+
**A session is a route, not an authority.** The signed session names a size and a digest, and so
|
|
13
|
+
does the sealed artifact record, and so does the released version's own artifact reference. Bytes
|
|
14
|
+
are accepted only where all three agree, so a session that names a digest of its own decides
|
|
15
|
+
nothing. This is the notebook-sidecar rule of ``docs/ARCHITECTURE.md`` applied one layer out: a
|
|
16
|
+
transport cannot authorize what it carries.
|
|
17
|
+
|
|
18
|
+
**Nothing is exposed before it is verified.** The transfer is bounded and streamed through
|
|
19
|
+
:class:`ResultBytesVerifier`, so a download that will be refused never materializes; the envelope
|
|
20
|
+
is decoded against the released Build fingerprint before a byte reaches the filesystem; the
|
|
21
|
+
rebuilt tree is verified by :func:`pipeline.verify_candidate` before the notebook is derived from
|
|
22
|
+
it; and the notebook is installed by the existing atomic writer, so a half-written one is never
|
|
23
|
+
visible. Rerunning after an interruption converges on the same verified result.
|
|
24
|
+
|
|
25
|
+
**The notebook is derived here, never transported.** Studio has no notebook to offer and would not
|
|
26
|
+
be believed if it had one -- the viewer re-derives the notebook from its own verified snapshot and
|
|
27
|
+
compares bytes before it will show a Table tab (``viewer.py`` ``_dataset_is_admissible``). This
|
|
28
|
+
module therefore lands exactly what the local generator needs to reproduce it, which is the sealed
|
|
29
|
+
Build tree, and nothing that only a remote actor could vouch for.
|
|
30
|
+
|
|
31
|
+
Refusals are :class:`HostedDeployError` because this is the deployment lane's last step, not a
|
|
32
|
+
second lane: a person who ran ``mr-data deploy`` and then ``mr-data deploy-dataset`` is inside one
|
|
33
|
+
journey and should not have to learn two error vocabularies to read its failures.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import errno
|
|
39
|
+
import importlib
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import secrets
|
|
43
|
+
import ssl
|
|
44
|
+
import stat
|
|
45
|
+
import tempfile
|
|
46
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
47
|
+
from contextlib import contextmanager, suppress
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from datetime import UTC, datetime, timedelta
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any, Protocol
|
|
52
|
+
from urllib.error import HTTPError
|
|
53
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
54
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
55
|
+
from uuid import UUID
|
|
56
|
+
|
|
57
|
+
from mostlyright.data_harness import canonical, pipeline
|
|
58
|
+
from mostlyright.data_harness.acquisition.result_download import (
|
|
59
|
+
RESULT_CHUNK_BYTES,
|
|
60
|
+
ResultBytesVerifier,
|
|
61
|
+
ResultDownloadError,
|
|
62
|
+
ValidatedResultDownload,
|
|
63
|
+
)
|
|
64
|
+
from mostlyright.data_harness.hosted_deploy import (
|
|
65
|
+
REQUEST_TIMEOUT_SECONDS,
|
|
66
|
+
STUDIO_SCHEMA_VERSION,
|
|
67
|
+
HostedDeployError,
|
|
68
|
+
StudioToken,
|
|
69
|
+
build_editor_client,
|
|
70
|
+
)
|
|
71
|
+
from mostlyright.data_harness.hosted_handoff import (
|
|
72
|
+
HANDOFF_RECEIPT_SCHEMA,
|
|
73
|
+
HANDOFF_RELATIVE_PATH,
|
|
74
|
+
read_handoff_receipt,
|
|
75
|
+
)
|
|
76
|
+
from mostlyright.data_harness.hosted_worker import (
|
|
77
|
+
CANDIDATE_CONTRACT_VERSION,
|
|
78
|
+
HOSTED_ARTIFACT_BYTE_LIMITS,
|
|
79
|
+
MAX_CANDIDATE_ENVELOPE_BYTES,
|
|
80
|
+
HostedWorkerError,
|
|
81
|
+
_decode_candidate_envelope,
|
|
82
|
+
_restore_candidate_members,
|
|
83
|
+
)
|
|
84
|
+
from mostlyright.data_harness.studio_boundary import StudioBoundaryError, studio_wire_dict
|
|
85
|
+
from mostlyright.data_harness.visual_run.contracts import VisualRunError
|
|
86
|
+
from mostlyright.data_harness.visual_run.reducer import reduce_events
|
|
87
|
+
from mostlyright.data_harness.visual_run.sdk import VisualWork
|
|
88
|
+
from mostlyright.data_harness.visual_run.store import VisualRunStore
|
|
89
|
+
|
|
90
|
+
# The one artifact kind this handoff redeems. The Parquet alone cannot reconstitute a Build the
|
|
91
|
+
# viewer will admit -- admission verifies the whole sealed tree against `candidate/manifest.json`
|
|
92
|
+
# -- and the sealed evidence envelope already carries every member of that tree, including the
|
|
93
|
+
# Parquet, under one digest the release itself names. So the minimum set is one artifact, and
|
|
94
|
+
# taking more would mean trusting more.
|
|
95
|
+
REQUIRED_ARTIFACT_KIND = "candidate_evidence"
|
|
96
|
+
REQUIRED_ARTIFACT_MEDIA_TYPE = "application/json"
|
|
97
|
+
REQUIRED_ARTIFACT_PURPOSE = "public_sealed_artifact_download"
|
|
98
|
+
|
|
99
|
+
# The committed object prefix of the Studio artifact store. It is named `quarantine/` for
|
|
100
|
+
# historical reasons that mostlyrightmd/mostlyright-studio#44 is fixing; nothing under it is
|
|
101
|
+
# quarantined, and the short-lived uncommitted uploads live under `pending/` instead. A client
|
|
102
|
+
# allowlist has to admit the prefix the sealed bytes are actually served from, so this admits the
|
|
103
|
+
# committed one and only the committed one.
|
|
104
|
+
GCS_COMMITTED_PREFIX = ("quarantine", "v2")
|
|
105
|
+
GCS_TRANSFER_HOST = "storage.googleapis.com"
|
|
106
|
+
|
|
107
|
+
# A transferred artifact smaller than this never touches the disk; a larger one spills to a
|
|
108
|
+
# temporary file unlinked at creation, so a refused transfer leaves nothing behind on any exit
|
|
109
|
+
# path. The same threshold `ux/hosted_acquisition` stages hosted results at.
|
|
110
|
+
DOWNLOAD_SPOOL_BYTES = 8 * 1024 * 1024
|
|
111
|
+
# A released version lists the artifacts its run sealed. Eight kinds exist; the bound is generous
|
|
112
|
+
# enough to survive a contract that adds some and small enough that a malformed list cannot turn
|
|
113
|
+
# one handoff into an unbounded sequence of reads.
|
|
114
|
+
MAX_ARTIFACT_REFERENCES = 32
|
|
115
|
+
# How many released versions of one Dataset this will read through to find its own. The listing is
|
|
116
|
+
# unpaginated and returns the whole release history, and a Dataset that refreshes daily reaches
|
|
117
|
+
# four thousand after eleven years -- so this is a bound against a malformed answer, not a limit on
|
|
118
|
+
# how often a Dataset may be refreshed. Selecting from the list costs one linear scan; only the
|
|
119
|
+
# selected record is read in full, so the cost here is the response, not the count.
|
|
120
|
+
MAX_RELEASED_VERSIONS = 4096
|
|
121
|
+
MAX_SIGNED_URL_CHARS = 4096
|
|
122
|
+
# A signed transfer carries a handful of required headers at most. Everything else here is
|
|
123
|
+
# bounded explicitly, so the count is too.
|
|
124
|
+
MAX_SESSION_HEADERS = 16
|
|
125
|
+
# The sealed record of an installed tree, read only to recognise a half-finished install.
|
|
126
|
+
MAX_MANIFEST_BYTES = 8 * 1024 * 1024
|
|
127
|
+
# The generated notebook, read back once to narrate it into the open workbench.
|
|
128
|
+
MAX_NOTEBOOK_BYTES = 64 * 1024 * 1024
|
|
129
|
+
_NO_FOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
130
|
+
MAX_SESSION_LIFETIME = timedelta(hours=1)
|
|
131
|
+
|
|
132
|
+
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
133
|
+
_BUCKET = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
|
|
134
|
+
# The object name is an opaque store-side identifier, not a content digest, so its exact alphabet
|
|
135
|
+
# is Studio's business. What this pattern is for is path confinement: one flat segment with no
|
|
136
|
+
# separator, no traversal, and no escape.
|
|
137
|
+
_OBJECT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$")
|
|
138
|
+
_HEADER_NAME = re.compile(r"^[A-Za-z0-9-]{1,128}$")
|
|
139
|
+
_FORBIDDEN_HEADERS = frozenset({"authorization", "cookie", "host", "proxy-authorization"})
|
|
140
|
+
|
|
141
|
+
# One sentence for the three transfer bounds it covers, so the status, media-type and length
|
|
142
|
+
# refusals stay indistinguishable to a caller no matter which of them fired.
|
|
143
|
+
_DOWNLOAD_INVALID_DETAIL = "the released Build transfer violated its media type, status, or size"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _refuse(code: str, detail: str) -> HostedDeployError:
|
|
147
|
+
"""Build one typed refusal, for a caller that writes ``raise _refuse(...)``.
|
|
148
|
+
|
|
149
|
+
It returns the error rather than raising it, and every call site raises what it returns. That
|
|
150
|
+
is not a style preference: the completeness gates that require every typed code to carry a
|
|
151
|
+
plain sentence and a remediation read the name written at each ``raise``, so a helper that
|
|
152
|
+
raised for its callers would hide every refusal in this module from them.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
return HostedDeployError(code, detail)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class SealedArtifact:
|
|
160
|
+
"""One sealed artifact record, already agreed with the release that names it."""
|
|
161
|
+
|
|
162
|
+
artifact_id: UUID
|
|
163
|
+
workspace_id: UUID
|
|
164
|
+
run_id: UUID
|
|
165
|
+
kind: str
|
|
166
|
+
media_type: str
|
|
167
|
+
content_digest: str
|
|
168
|
+
size_bytes: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class ReleasedBuild:
|
|
173
|
+
"""The released version coordinate, reduced to what the local handoff is allowed to use."""
|
|
174
|
+
|
|
175
|
+
dataset_id: UUID
|
|
176
|
+
table_id: UUID
|
|
177
|
+
table_version_id: UUID
|
|
178
|
+
workspace_id: UUID
|
|
179
|
+
run_id: UUID
|
|
180
|
+
version_number: int
|
|
181
|
+
candidate_digest: str
|
|
182
|
+
artifact_references: tuple[tuple[UUID, str], ...]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class HostedDatasetHandoff:
|
|
187
|
+
"""What one completed handoff installed, and which released Build it came from."""
|
|
188
|
+
|
|
189
|
+
run_dir: Path
|
|
190
|
+
dataset_id: UUID
|
|
191
|
+
table_id: UUID
|
|
192
|
+
table_version_id: UUID
|
|
193
|
+
version_number: int
|
|
194
|
+
hosted_run_id: UUID
|
|
195
|
+
candidate_digest: str
|
|
196
|
+
artifact_id: UUID
|
|
197
|
+
artifact_content_digest: str
|
|
198
|
+
artifact_size_bytes: int
|
|
199
|
+
notebook_path: Path
|
|
200
|
+
transferred: bool
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"schema_version": HANDOFF_RECEIPT_SCHEMA,
|
|
205
|
+
"run_dir": str(self.run_dir),
|
|
206
|
+
"dataset_id": str(self.dataset_id),
|
|
207
|
+
"table_id": str(self.table_id),
|
|
208
|
+
"table_version_id": str(self.table_version_id),
|
|
209
|
+
"version_number": self.version_number,
|
|
210
|
+
"hosted_run_id": str(self.hosted_run_id),
|
|
211
|
+
"candidate_digest": "sha256:" + self.candidate_digest,
|
|
212
|
+
"artifact_id": str(self.artifact_id),
|
|
213
|
+
"artifact_content_digest": self.artifact_content_digest,
|
|
214
|
+
"artifact_size_bytes": self.artifact_size_bytes,
|
|
215
|
+
"notebook": str(self.notebook_path),
|
|
216
|
+
"transferred": self.transferred,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class StudioDatasetReader(Protocol):
|
|
221
|
+
"""The three Studio reads this handoff makes, and nothing that can mutate anything."""
|
|
222
|
+
|
|
223
|
+
def released_versions(self, table_id: UUID) -> list[Mapping[str, Any]]: ...
|
|
224
|
+
|
|
225
|
+
def artifact(self, artifact_id: UUID) -> Mapping[str, Any]: ...
|
|
226
|
+
|
|
227
|
+
def download_session(self, artifact_id: UUID) -> Mapping[str, Any]: ...
|
|
228
|
+
|
|
229
|
+
def close(self) -> None: ...
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class GeneratedStudioDatasetReader:
|
|
233
|
+
"""Read-only released-Build access through the pinned generated editor facade."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, token: StudioToken) -> None:
|
|
236
|
+
self._raw_client, self._client = build_editor_client(token)
|
|
237
|
+
self._operations = {
|
|
238
|
+
"list_table_versions": importlib.import_module(
|
|
239
|
+
"mostlyright_studio.api.tables.list_table_versions"
|
|
240
|
+
),
|
|
241
|
+
"get_artifact": importlib.import_module(
|
|
242
|
+
"mostlyright_studio.api.artifacts.get_artifact"
|
|
243
|
+
),
|
|
244
|
+
"create_artifact_download_session": importlib.import_module(
|
|
245
|
+
"mostlyright_studio.api.artifacts.create_artifact_download_session"
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def _read(self, code: str, operation: Any, **arguments: Any) -> Any:
|
|
250
|
+
"""Make one read and give every way it can fail the same typed answer.
|
|
251
|
+
|
|
252
|
+
A Studio that cannot be reached is the most ordinary failure this command has -- the
|
|
253
|
+
docstring above advertises a laptop that slept as the normal case -- and the generated
|
|
254
|
+
client raises its transport errors as its own exception type, which is not an ``OSError``
|
|
255
|
+
and is in nothing the command boundary catches. Without this the commonest failure of the
|
|
256
|
+
command reached a person as a traceback with no code, no fix, and, under ``--json``, no
|
|
257
|
+
object at all. The token exchange before this and the transfer after it both wrap; only
|
|
258
|
+
the reads did not.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
response = operation(**arguments)
|
|
263
|
+
except HostedDeployError:
|
|
264
|
+
raise
|
|
265
|
+
except (TypeError, AttributeError):
|
|
266
|
+
# Not a transport failure: the pinned client was regenerated with a different shape,
|
|
267
|
+
# and calling it "the cloud could not be reached" would send somebody looking at their
|
|
268
|
+
# network for a contract change. Let it surface as what it is.
|
|
269
|
+
raise
|
|
270
|
+
except Exception as error:
|
|
271
|
+
raise HostedDeployError(code, "the Mostly Right cloud could not be reached") from error
|
|
272
|
+
return _read_parsed(response, code)
|
|
273
|
+
|
|
274
|
+
def released_versions(self, table_id: UUID) -> list[Mapping[str, Any]]:
|
|
275
|
+
operation = self._operations["list_table_versions"]
|
|
276
|
+
parsed = self._read(
|
|
277
|
+
"DEPLOY_DATASET_VERSION_READ_FAILED",
|
|
278
|
+
operation.sync_detailed,
|
|
279
|
+
client=self._raw_client,
|
|
280
|
+
table_id=table_id,
|
|
281
|
+
)
|
|
282
|
+
if not isinstance(parsed, list):
|
|
283
|
+
raise _refuse(
|
|
284
|
+
"DEPLOY_DATASET_VERSION_READ_FAILED",
|
|
285
|
+
"the released Build list did not come back as a list",
|
|
286
|
+
)
|
|
287
|
+
# Bounded here as well as in the validator every reader passes through: normalizing a
|
|
288
|
+
# malformed answer of a million entries is work this never has to do.
|
|
289
|
+
if len(parsed) > MAX_RELEASED_VERSIONS:
|
|
290
|
+
raise _refuse(
|
|
291
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
292
|
+
"the released Build list is longer than this handoff will read",
|
|
293
|
+
)
|
|
294
|
+
return [_wire(item, "DEPLOY_DATASET_VERSION_READ_FAILED") for item in parsed]
|
|
295
|
+
|
|
296
|
+
def artifact(self, artifact_id: UUID) -> Mapping[str, Any]:
|
|
297
|
+
operation = self._operations["get_artifact"]
|
|
298
|
+
parsed = self._read(
|
|
299
|
+
"DEPLOY_DATASET_ARTIFACT_READ_FAILED",
|
|
300
|
+
operation.sync_detailed,
|
|
301
|
+
client=self._raw_client,
|
|
302
|
+
artifact_id=artifact_id,
|
|
303
|
+
)
|
|
304
|
+
return _wire(parsed, "DEPLOY_DATASET_ARTIFACT_READ_FAILED")
|
|
305
|
+
|
|
306
|
+
def download_session(self, artifact_id: UUID) -> Mapping[str, Any]:
|
|
307
|
+
operation = self._operations["create_artifact_download_session"]
|
|
308
|
+
parsed = self._read(
|
|
309
|
+
"DEPLOY_DATASET_SESSION_READ_FAILED",
|
|
310
|
+
operation.sync_detailed,
|
|
311
|
+
client=self._raw_client,
|
|
312
|
+
artifact_id=artifact_id,
|
|
313
|
+
idempotency_key=f"download:{artifact_id}:{secrets.token_hex(16)}",
|
|
314
|
+
)
|
|
315
|
+
return _wire(parsed, "DEPLOY_DATASET_SESSION_READ_FAILED")
|
|
316
|
+
|
|
317
|
+
def close(self) -> None:
|
|
318
|
+
client = getattr(self._raw_client, "_client", None)
|
|
319
|
+
if client is not None:
|
|
320
|
+
client.close()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _read_parsed(response: Any, code: str) -> Any:
|
|
324
|
+
status = int(getattr(response, "status_code", 0))
|
|
325
|
+
parsed = getattr(response, "parsed", None)
|
|
326
|
+
if status not in {200, 201} or parsed is None:
|
|
327
|
+
raise _refuse(code, f"the Mostly Right cloud answered with HTTP {status}")
|
|
328
|
+
return parsed
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _wire(model: Any, code: str) -> Mapping[str, Any]:
|
|
332
|
+
try:
|
|
333
|
+
return studio_wire_dict(model)
|
|
334
|
+
except StudioBoundaryError as error:
|
|
335
|
+
raise HostedDeployError(code, "the answer is not a valid Studio object") from error
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------------------------------------
|
|
339
|
+
# Pure validation: the release, the sealed record, and the signed session
|
|
340
|
+
# ------------------------------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _uuid(value: Any, code: str, label: str) -> UUID:
|
|
344
|
+
if not isinstance(value, str):
|
|
345
|
+
raise _refuse(code, f"{label} is missing")
|
|
346
|
+
try:
|
|
347
|
+
return UUID(value)
|
|
348
|
+
except ValueError as error:
|
|
349
|
+
raise _refuse(code, f"{label} is invalid") from error
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _timestamp(value: Any, code: str, label: str) -> datetime:
|
|
353
|
+
if not isinstance(value, str) or not value.endswith("Z"):
|
|
354
|
+
raise _refuse(code, f"{label} is invalid")
|
|
355
|
+
try:
|
|
356
|
+
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
357
|
+
except ValueError as error:
|
|
358
|
+
raise _refuse(code, f"{label} is invalid") from error
|
|
359
|
+
return parsed.astimezone(UTC)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def select_released_build(
|
|
363
|
+
versions: Sequence[Mapping[str, Any]],
|
|
364
|
+
*,
|
|
365
|
+
dataset_id: UUID,
|
|
366
|
+
table_id: UUID,
|
|
367
|
+
workspace_id: UUID,
|
|
368
|
+
hosted_run_id: UUID,
|
|
369
|
+
) -> ReleasedBuild:
|
|
370
|
+
"""Return the one released version this run produced, or refuse.
|
|
371
|
+
|
|
372
|
+
Selection is by run, not by recency. "The newest version of this Dataset" is a different
|
|
373
|
+
question from "what the run I deployed released", and answering the first when asked the second
|
|
374
|
+
is how a handoff quietly lands somebody else's refresh. A run that has not released anything
|
|
375
|
+
yet has no version here at all, which is why finishing the hosted work is not by itself enough
|
|
376
|
+
to unlock any bytes.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
if len(versions) > MAX_RELEASED_VERSIONS:
|
|
380
|
+
# The bound belongs here rather than at the transport, because every reader answers this
|
|
381
|
+
# question and only one of them is the generated client.
|
|
382
|
+
raise _refuse(
|
|
383
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
384
|
+
"the released Build list is longer than this handoff will read",
|
|
385
|
+
)
|
|
386
|
+
matches = [
|
|
387
|
+
version
|
|
388
|
+
for version in versions
|
|
389
|
+
if isinstance(version, Mapping) and version.get("run_id") == str(hosted_run_id)
|
|
390
|
+
]
|
|
391
|
+
if not matches:
|
|
392
|
+
raise _refuse(
|
|
393
|
+
"DEPLOY_DATASET_NOT_RELEASED",
|
|
394
|
+
"this run has not released a Build yet, so there is nothing to bring back",
|
|
395
|
+
)
|
|
396
|
+
if len(matches) > 1:
|
|
397
|
+
raise _refuse(
|
|
398
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
399
|
+
"this run reports more than one released Build",
|
|
400
|
+
)
|
|
401
|
+
return _validate_released_version(
|
|
402
|
+
matches[0],
|
|
403
|
+
dataset_id=dataset_id,
|
|
404
|
+
table_id=table_id,
|
|
405
|
+
workspace_id=workspace_id,
|
|
406
|
+
hosted_run_id=hosted_run_id,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _validate_released_version(
|
|
411
|
+
version: Mapping[str, Any],
|
|
412
|
+
*,
|
|
413
|
+
dataset_id: UUID,
|
|
414
|
+
table_id: UUID,
|
|
415
|
+
workspace_id: UUID,
|
|
416
|
+
hosted_run_id: UUID,
|
|
417
|
+
) -> ReleasedBuild:
|
|
418
|
+
if not isinstance(version, Mapping):
|
|
419
|
+
raise _refuse(
|
|
420
|
+
"DEPLOY_DATASET_VERSION_INVALID", "the released Build record is not an object"
|
|
421
|
+
)
|
|
422
|
+
gate = version.get("release_gate")
|
|
423
|
+
references = version.get("artifact_references")
|
|
424
|
+
version_number = version.get("version_number")
|
|
425
|
+
digest = version.get("candidate_digest")
|
|
426
|
+
if (
|
|
427
|
+
version.get("schema_version") != STUDIO_SCHEMA_VERSION
|
|
428
|
+
or version.get("workspace_id") != str(workspace_id)
|
|
429
|
+
or version.get("dataset_id") != str(dataset_id)
|
|
430
|
+
or version.get("table_id") != str(table_id)
|
|
431
|
+
or version.get("run_id") != str(hosted_run_id)
|
|
432
|
+
or version.get("release_authority") != "studio_transactional_release_controller"
|
|
433
|
+
or version.get("candidate_contract_version") != CANDIDATE_CONTRACT_VERSION
|
|
434
|
+
or not isinstance(digest, str)
|
|
435
|
+
or _SHA256.fullmatch(digest) is None
|
|
436
|
+
or type(version_number) is not int
|
|
437
|
+
or version_number < 1
|
|
438
|
+
or not isinstance(references, list)
|
|
439
|
+
or not references
|
|
440
|
+
or len(references) > MAX_ARTIFACT_REFERENCES
|
|
441
|
+
):
|
|
442
|
+
raise _refuse(
|
|
443
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
444
|
+
"the released Build record is not the exact shape this handoff accepts",
|
|
445
|
+
)
|
|
446
|
+
if not isinstance(gate, Mapping) or gate.get("verification_decision") != "passed":
|
|
447
|
+
raise _refuse(
|
|
448
|
+
"DEPLOY_DATASET_NOT_RELEASED",
|
|
449
|
+
"this Build has not passed its independent check, so its rows stay unavailable",
|
|
450
|
+
)
|
|
451
|
+
if (
|
|
452
|
+
gate.get("open_blocker_count") != 0
|
|
453
|
+
or gate.get("open_critical_count") != 0
|
|
454
|
+
or gate.get("open_high_count") != 0
|
|
455
|
+
or gate.get("deterministic_checks_passed") is not True
|
|
456
|
+
or gate.get("committed_source_states_verified") is not True
|
|
457
|
+
):
|
|
458
|
+
raise _refuse(
|
|
459
|
+
"DEPLOY_DATASET_NOT_RELEASED",
|
|
460
|
+
"this Build still has open findings, so its rows stay unavailable",
|
|
461
|
+
)
|
|
462
|
+
seen: set[UUID] = set()
|
|
463
|
+
collected: list[tuple[UUID, str]] = []
|
|
464
|
+
for reference in references:
|
|
465
|
+
if not isinstance(reference, Mapping):
|
|
466
|
+
raise _refuse(
|
|
467
|
+
"DEPLOY_DATASET_VERSION_INVALID", "an artifact reference is not an object"
|
|
468
|
+
)
|
|
469
|
+
artifact_id = _uuid(
|
|
470
|
+
reference.get("artifact_id"), "DEPLOY_DATASET_VERSION_INVALID", "an artifact reference"
|
|
471
|
+
)
|
|
472
|
+
content_digest = reference.get("content_digest")
|
|
473
|
+
if (
|
|
474
|
+
reference.get("workspace_id") != str(workspace_id)
|
|
475
|
+
or reference.get("contract_version") != STUDIO_SCHEMA_VERSION
|
|
476
|
+
or not isinstance(content_digest, str)
|
|
477
|
+
or _SHA256.fullmatch(content_digest) is None
|
|
478
|
+
or artifact_id in seen
|
|
479
|
+
):
|
|
480
|
+
raise _refuse(
|
|
481
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
482
|
+
"an artifact reference is not the exact shape this handoff accepts",
|
|
483
|
+
)
|
|
484
|
+
seen.add(artifact_id)
|
|
485
|
+
collected.append((artifact_id, content_digest))
|
|
486
|
+
return ReleasedBuild(
|
|
487
|
+
dataset_id=dataset_id,
|
|
488
|
+
table_id=table_id,
|
|
489
|
+
table_version_id=_uuid(
|
|
490
|
+
version.get("table_version_id"),
|
|
491
|
+
"DEPLOY_DATASET_VERSION_INVALID",
|
|
492
|
+
"the released Build id",
|
|
493
|
+
),
|
|
494
|
+
workspace_id=workspace_id,
|
|
495
|
+
run_id=hosted_run_id,
|
|
496
|
+
version_number=version_number,
|
|
497
|
+
candidate_digest=digest,
|
|
498
|
+
artifact_references=tuple(collected),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def validate_sealed_artifact(
|
|
503
|
+
metadata: Mapping[str, Any],
|
|
504
|
+
*,
|
|
505
|
+
released: ReleasedBuild,
|
|
506
|
+
artifact_id: UUID,
|
|
507
|
+
reference_digest: str,
|
|
508
|
+
) -> SealedArtifact:
|
|
509
|
+
"""Accept one sealed artifact record only where it agrees with the release that named it."""
|
|
510
|
+
|
|
511
|
+
if not isinstance(metadata, Mapping):
|
|
512
|
+
raise _refuse(
|
|
513
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID", "the sealed evidence record is not an object"
|
|
514
|
+
)
|
|
515
|
+
size = metadata.get("size_bytes")
|
|
516
|
+
digest = metadata.get("content_digest")
|
|
517
|
+
kind = metadata.get("kind")
|
|
518
|
+
if (
|
|
519
|
+
metadata.get("schema_version") != STUDIO_SCHEMA_VERSION
|
|
520
|
+
or metadata.get("contract_version") != STUDIO_SCHEMA_VERSION
|
|
521
|
+
or metadata.get("artifact_id") != str(artifact_id)
|
|
522
|
+
or metadata.get("workspace_id") != str(released.workspace_id)
|
|
523
|
+
or metadata.get("run_id") != str(released.run_id)
|
|
524
|
+
or not isinstance(kind, str)
|
|
525
|
+
or not isinstance(digest, str)
|
|
526
|
+
or _SHA256.fullmatch(digest) is None
|
|
527
|
+
or type(size) is not int
|
|
528
|
+
):
|
|
529
|
+
raise _refuse(
|
|
530
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
531
|
+
"the sealed evidence record is not the exact shape this handoff accepts",
|
|
532
|
+
)
|
|
533
|
+
# The reference the release itself carries is the authority; a record that disagrees with it
|
|
534
|
+
# describes some other bytes, whatever it says about itself.
|
|
535
|
+
if digest != reference_digest:
|
|
536
|
+
raise _refuse(
|
|
537
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
538
|
+
"the sealed evidence fingerprint differs from the one the released Build names",
|
|
539
|
+
)
|
|
540
|
+
if metadata.get("storage_state") not in {"sealed", "verified"}:
|
|
541
|
+
raise _refuse(
|
|
542
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
543
|
+
"the sealed evidence is not in a settled, readable state",
|
|
544
|
+
)
|
|
545
|
+
limit = HOSTED_ARTIFACT_BYTE_LIMITS.get(kind)
|
|
546
|
+
if limit is None:
|
|
547
|
+
raise _refuse(
|
|
548
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
549
|
+
"the released Build names evidence of a kind this handoff cannot read",
|
|
550
|
+
)
|
|
551
|
+
if not 1 <= size <= limit:
|
|
552
|
+
raise _refuse(
|
|
553
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
554
|
+
"the sealed evidence size is outside its budget for this kind",
|
|
555
|
+
)
|
|
556
|
+
return SealedArtifact(
|
|
557
|
+
artifact_id=artifact_id,
|
|
558
|
+
workspace_id=released.workspace_id,
|
|
559
|
+
run_id=released.run_id,
|
|
560
|
+
kind=kind,
|
|
561
|
+
media_type=str(metadata.get("media_type", "")),
|
|
562
|
+
content_digest=digest,
|
|
563
|
+
size_bytes=size,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def validate_download_session(
|
|
568
|
+
session: Mapping[str, Any],
|
|
569
|
+
*,
|
|
570
|
+
artifact: SealedArtifact,
|
|
571
|
+
studio_base_url: str,
|
|
572
|
+
now: datetime,
|
|
573
|
+
) -> tuple[ValidatedResultDownload, dict[str, str]]:
|
|
574
|
+
"""Accept one signed download session as a route to already-known bytes, and nothing more.
|
|
575
|
+
|
|
576
|
+
Every fact the transfer will later be judged against -- the size, the fingerprint, the media
|
|
577
|
+
type -- is taken from the sealed record and merely required to match here. A session is
|
|
578
|
+
therefore never the reason a byte is believed; it is only the reason a byte is reachable.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
if not isinstance(session, Mapping):
|
|
582
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer is not an object")
|
|
583
|
+
issued = _timestamp(
|
|
584
|
+
session.get("issued_at"), "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer start time"
|
|
585
|
+
)
|
|
586
|
+
expires = _timestamp(
|
|
587
|
+
session.get("expires_at"), "DEPLOY_DATASET_SESSION_INVALID", "the signed transfer expiry"
|
|
588
|
+
)
|
|
589
|
+
moment = now.astimezone(UTC)
|
|
590
|
+
if (
|
|
591
|
+
session.get("schema_version") != STUDIO_SCHEMA_VERSION
|
|
592
|
+
or session.get("contract_version") != STUDIO_SCHEMA_VERSION
|
|
593
|
+
or session.get("artifact_id") != str(artifact.artifact_id)
|
|
594
|
+
or session.get("workspace_id") != str(artifact.workspace_id)
|
|
595
|
+
or session.get("run_id") != str(artifact.run_id)
|
|
596
|
+
or session.get("kind") != artifact.kind
|
|
597
|
+
or session.get("purpose") != REQUIRED_ARTIFACT_PURPOSE
|
|
598
|
+
or session.get("media_type") != artifact.media_type
|
|
599
|
+
or session.get("direction") != "download"
|
|
600
|
+
or session.get("method") != "GET"
|
|
601
|
+
or session.get("route_authority") != "public_editor"
|
|
602
|
+
or session.get("expected_content_digest") != artifact.content_digest
|
|
603
|
+
or session.get("expected_size_bytes") != artifact.size_bytes
|
|
604
|
+
):
|
|
605
|
+
raise _refuse(
|
|
606
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
607
|
+
"the signed transfer does not describe the evidence it was asked for",
|
|
608
|
+
)
|
|
609
|
+
if issued >= expires or moment >= expires or expires - issued > MAX_SESSION_LIFETIME:
|
|
610
|
+
raise _refuse(
|
|
611
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
612
|
+
"the signed transfer has expired or lasts longer than is allowed",
|
|
613
|
+
)
|
|
614
|
+
if issued > moment + timedelta(minutes=1):
|
|
615
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer starts in the future")
|
|
616
|
+
headers = _session_headers(session.get("required_headers"), media_type=artifact.media_type)
|
|
617
|
+
url = _validate_transfer_url(session.get("signed_url"), studio_base_url=studio_base_url)
|
|
618
|
+
return (
|
|
619
|
+
ValidatedResultDownload(
|
|
620
|
+
url=url,
|
|
621
|
+
size=artifact.size_bytes,
|
|
622
|
+
media_type=artifact.media_type,
|
|
623
|
+
integrity={"kind": "local_sha256", "value": artifact.content_digest},
|
|
624
|
+
),
|
|
625
|
+
headers,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _session_headers(required: Any, *, media_type: str) -> dict[str, str]:
|
|
630
|
+
if not isinstance(required, list) or len(required) > MAX_SESSION_HEADERS:
|
|
631
|
+
raise _refuse(
|
|
632
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
633
|
+
"the signed transfer headers are absent or unreasonably many",
|
|
634
|
+
)
|
|
635
|
+
headers: dict[str, str] = {}
|
|
636
|
+
for item in required:
|
|
637
|
+
if not isinstance(item, Mapping):
|
|
638
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "a signed transfer header is invalid")
|
|
639
|
+
name = item.get("name")
|
|
640
|
+
value = item.get("value")
|
|
641
|
+
if (
|
|
642
|
+
not isinstance(name, str)
|
|
643
|
+
or not isinstance(value, str)
|
|
644
|
+
or _HEADER_NAME.fullmatch(name) is None
|
|
645
|
+
or name.lower() in _FORBIDDEN_HEADERS
|
|
646
|
+
or name.lower() in headers
|
|
647
|
+
or len(value) > 4096
|
|
648
|
+
or "\r" in value
|
|
649
|
+
or "\n" in value
|
|
650
|
+
):
|
|
651
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "a signed transfer header is unsafe")
|
|
652
|
+
headers[name.lower()] = value
|
|
653
|
+
if headers.get("accept", media_type) != media_type:
|
|
654
|
+
raise _refuse(
|
|
655
|
+
"DEPLOY_DATASET_SESSION_INVALID", "the signed transfer asks for a different media type"
|
|
656
|
+
)
|
|
657
|
+
headers["accept"] = media_type
|
|
658
|
+
return headers
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _validate_transfer_url(signed_url: Any, *, studio_base_url: str) -> str:
|
|
662
|
+
"""Admit the two places sealed bytes are actually served from, and no third one.
|
|
663
|
+
|
|
664
|
+
This is deliberately not the allowlist ``acquisition/result_download`` uses. That one pins the
|
|
665
|
+
public-crawl result prefix, which sealed Build evidence is never stored under; reusing it would
|
|
666
|
+
have meant either refusing every real artifact or widening a rule that guards a different
|
|
667
|
+
boundary. So the authority here is its own: the Studio origin the credential was issued for, or
|
|
668
|
+
the committed object prefix of the Studio artifact store.
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
if not isinstance(signed_url, str) or not 1 <= len(signed_url) <= MAX_SIGNED_URL_CHARS:
|
|
672
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid")
|
|
673
|
+
parsed = urlsplit(signed_url)
|
|
674
|
+
base = urlsplit(studio_base_url)
|
|
675
|
+
try:
|
|
676
|
+
port = parsed.port
|
|
677
|
+
except ValueError as error:
|
|
678
|
+
raise _refuse(
|
|
679
|
+
"DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid"
|
|
680
|
+
) from error
|
|
681
|
+
if (
|
|
682
|
+
parsed.username is not None
|
|
683
|
+
or parsed.password is not None
|
|
684
|
+
or parsed.fragment
|
|
685
|
+
or "%" in parsed.path
|
|
686
|
+
or "\\" in parsed.path
|
|
687
|
+
):
|
|
688
|
+
raise _refuse("DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid")
|
|
689
|
+
if parsed.hostname == GCS_TRANSFER_HOST:
|
|
690
|
+
_validate_object_store_url(parsed)
|
|
691
|
+
return signed_url
|
|
692
|
+
if (
|
|
693
|
+
parsed.scheme != base.scheme
|
|
694
|
+
or parsed.hostname != base.hostname
|
|
695
|
+
or port != base.port
|
|
696
|
+
or not parsed.hostname
|
|
697
|
+
):
|
|
698
|
+
raise _refuse(
|
|
699
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
700
|
+
"the signed transfer address is somewhere this command will not go",
|
|
701
|
+
)
|
|
702
|
+
segments = parsed.path.split("/")
|
|
703
|
+
if (
|
|
704
|
+
not parsed.path.startswith("/")
|
|
705
|
+
or len(segments) > 8
|
|
706
|
+
or any(segment in {"", ".", ".."} for segment in segments[1:])
|
|
707
|
+
):
|
|
708
|
+
raise _refuse(
|
|
709
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
710
|
+
"the signed transfer address is somewhere this command will not go",
|
|
711
|
+
)
|
|
712
|
+
return signed_url
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _validate_object_store_url(parsed: Any) -> None:
|
|
716
|
+
segments = parsed.path.split("/")
|
|
717
|
+
if (
|
|
718
|
+
parsed.scheme != "https"
|
|
719
|
+
or parsed.port is not None
|
|
720
|
+
or len(segments) != 5
|
|
721
|
+
or _BUCKET.fullmatch(segments[1]) is None
|
|
722
|
+
or tuple(segments[2:4]) != GCS_COMMITTED_PREFIX
|
|
723
|
+
or _OBJECT_NAME.fullmatch(segments[4]) is None
|
|
724
|
+
):
|
|
725
|
+
raise _refuse(
|
|
726
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
727
|
+
"the signed transfer address is outside the sealed store",
|
|
728
|
+
)
|
|
729
|
+
try:
|
|
730
|
+
query = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
|
731
|
+
except ValueError as error:
|
|
732
|
+
raise _refuse(
|
|
733
|
+
"DEPLOY_DATASET_SESSION_INVALID", "the signed transfer address is invalid"
|
|
734
|
+
) from error
|
|
735
|
+
names = {key for key, _value in query}
|
|
736
|
+
required = {
|
|
737
|
+
"X-Goog-Algorithm",
|
|
738
|
+
"X-Goog-Credential",
|
|
739
|
+
"X-Goog-Date",
|
|
740
|
+
"X-Goog-Expires",
|
|
741
|
+
"X-Goog-SignedHeaders",
|
|
742
|
+
"X-Goog-Signature",
|
|
743
|
+
}
|
|
744
|
+
values = dict(query)
|
|
745
|
+
if (
|
|
746
|
+
len(query) != len(names)
|
|
747
|
+
or not required <= names
|
|
748
|
+
or not names <= required | {"generation"}
|
|
749
|
+
or any(not value for value in values.values())
|
|
750
|
+
or values["X-Goog-Algorithm"] != "GOOG4-RSA-SHA256"
|
|
751
|
+
or not values["X-Goog-Expires"].isdigit()
|
|
752
|
+
or not 1 <= int(values["X-Goog-Expires"]) <= 3600
|
|
753
|
+
or re.fullmatch(r"[0-9]{8}T[0-9]{6}Z", values["X-Goog-Date"]) is None
|
|
754
|
+
or re.fullmatch(r"(?:[0-9A-Fa-f]{2}){64,512}", values["X-Goog-Signature"]) is None
|
|
755
|
+
or re.fullmatch(
|
|
756
|
+
r"[^/\x00-\x20]{1,320}/[0-9]{8}/auto/storage/goog4_request",
|
|
757
|
+
values["X-Goog-Credential"],
|
|
758
|
+
)
|
|
759
|
+
is None
|
|
760
|
+
or values["X-Goog-Credential"].split("/", 2)[1] != values["X-Goog-Date"][:8]
|
|
761
|
+
):
|
|
762
|
+
raise _refuse(
|
|
763
|
+
"DEPLOY_DATASET_SESSION_INVALID",
|
|
764
|
+
"the signed transfer address is not signed the way the store signs",
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ------------------------------------------------------------------------------------------------
|
|
769
|
+
# The transfer
|
|
770
|
+
# ------------------------------------------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
774
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
775
|
+
return None
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def download_sealed_artifact(
|
|
779
|
+
download: ValidatedResultDownload,
|
|
780
|
+
headers: Mapping[str, str],
|
|
781
|
+
) -> bytes:
|
|
782
|
+
"""Transfer one sealed artifact in bounded units, verifying as it goes.
|
|
783
|
+
|
|
784
|
+
The transfer is chunked into a spool while :class:`ResultBytesVerifier` folds each unit into
|
|
785
|
+
the length and the sealed fingerprint, so the process never has to be able to hold a transfer
|
|
786
|
+
it is going to refuse, and an accepted one materializes once, at the end, from bytes already
|
|
787
|
+
verified. Status and media type are read from the head and refused before the body moves, so a
|
|
788
|
+
wrong answer costs no body bytes at all.
|
|
789
|
+
"""
|
|
790
|
+
|
|
791
|
+
opener = build_opener(
|
|
792
|
+
ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
|
|
793
|
+
)
|
|
794
|
+
request = Request(download.url, method="GET", headers=dict(headers))
|
|
795
|
+
try:
|
|
796
|
+
response = opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
797
|
+
except HTTPError as error:
|
|
798
|
+
response = error
|
|
799
|
+
except Exception as error:
|
|
800
|
+
raise HostedDeployError(
|
|
801
|
+
"DEPLOY_DATASET_DOWNLOAD_FAILED", "the released Build could not be transferred"
|
|
802
|
+
) from error
|
|
803
|
+
# The response is the outermost context deliberately: nothing constructed after the open --
|
|
804
|
+
# not the verifier, not the spool -- can strand the socket by raising before a `with` that
|
|
805
|
+
# would have closed it.
|
|
806
|
+
with response:
|
|
807
|
+
media_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
808
|
+
status = int(response.status)
|
|
809
|
+
if status != 200 or media_type != download.media_type:
|
|
810
|
+
raise _refuse("DEPLOY_DATASET_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
|
|
811
|
+
verifier = ResultBytesVerifier(download)
|
|
812
|
+
with tempfile.SpooledTemporaryFile(max_size=DOWNLOAD_SPOOL_BYTES, mode="w+b") as spool:
|
|
813
|
+
remaining = download.size + 1
|
|
814
|
+
while remaining > 0:
|
|
815
|
+
try:
|
|
816
|
+
chunk = response.read(min(RESULT_CHUNK_BYTES, remaining))
|
|
817
|
+
except Exception as error:
|
|
818
|
+
raise HostedDeployError(
|
|
819
|
+
"DEPLOY_DATASET_DOWNLOAD_FAILED",
|
|
820
|
+
"the released Build could not be transferred",
|
|
821
|
+
) from error
|
|
822
|
+
if not chunk:
|
|
823
|
+
break
|
|
824
|
+
try:
|
|
825
|
+
spool.write(chunk)
|
|
826
|
+
except OSError as error:
|
|
827
|
+
raise HostedDeployError(
|
|
828
|
+
"DEPLOY_DATASET_DOWNLOAD_FAILED",
|
|
829
|
+
"the released Build could not be staged for checking",
|
|
830
|
+
) from error
|
|
831
|
+
remaining -= len(chunk)
|
|
832
|
+
verifier.update(chunk)
|
|
833
|
+
if verifier.length != download.size:
|
|
834
|
+
raise _refuse("DEPLOY_DATASET_DOWNLOAD_INVALID", _DOWNLOAD_INVALID_DETAIL)
|
|
835
|
+
try:
|
|
836
|
+
verifier.verify()
|
|
837
|
+
except ResultDownloadError as error:
|
|
838
|
+
raise HostedDeployError(
|
|
839
|
+
"DEPLOY_DATASET_DOWNLOAD_INTEGRITY",
|
|
840
|
+
"the transferred bytes do not match the fingerprint the release sealed",
|
|
841
|
+
) from error
|
|
842
|
+
spool.seek(0)
|
|
843
|
+
return spool.read()
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# ------------------------------------------------------------------------------------------------
|
|
847
|
+
# Local installation
|
|
848
|
+
# ------------------------------------------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _require_run_directory(run_dir: Path) -> Path:
|
|
852
|
+
"""Settle the destination folder without making it.
|
|
853
|
+
|
|
854
|
+
The folder a released Build lands in usually does not exist yet, because the Build that
|
|
855
|
+
produced it was not made here and nothing has had a reason to make the folder. So an absent
|
|
856
|
+
name whose parent is a real folder is accepted -- and it stays absent until there is a verified
|
|
857
|
+
Build to put in it, which is the whole reason this does not make it.
|
|
858
|
+
|
|
859
|
+
Making it here instead was a real defect: the ordinary refusal on this path is "the run has not
|
|
860
|
+
released anything yet", the remediation for it says to run the command again later, and an
|
|
861
|
+
empty folder left behind by that refusal is one ``mr-data view --run-dir`` will not open. The
|
|
862
|
+
command would have broken the automatic reveal it exists to provide, for anyone who checked
|
|
863
|
+
once before opening a window.
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
lexical = Path(os.path.abspath(os.fspath(run_dir)))
|
|
867
|
+
try:
|
|
868
|
+
info = os.stat(lexical, follow_symlinks=False)
|
|
869
|
+
except FileNotFoundError:
|
|
870
|
+
_require_real_directory(
|
|
871
|
+
lexical.parent, f"the folder to write into is not inside a real folder: {lexical}"
|
|
872
|
+
)
|
|
873
|
+
return lexical
|
|
874
|
+
except OSError as error:
|
|
875
|
+
raise HostedDeployError(
|
|
876
|
+
"DEPLOY_DATASET_TARGET_INVALID",
|
|
877
|
+
f"the folder to write into could not be read: {lexical}",
|
|
878
|
+
) from error
|
|
879
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
880
|
+
raise _refuse(
|
|
881
|
+
"DEPLOY_DATASET_TARGET_INVALID",
|
|
882
|
+
f"the destination is already something other than a folder: {lexical}",
|
|
883
|
+
)
|
|
884
|
+
_require_real_directory(lexical, f"the folder to write into must be one real folder: {lexical}")
|
|
885
|
+
return lexical
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _require_real_directory(path: Path, detail: str) -> None:
|
|
889
|
+
try:
|
|
890
|
+
resolved = path.resolve(strict=True)
|
|
891
|
+
info = os.stat(path, follow_symlinks=False)
|
|
892
|
+
except OSError as error:
|
|
893
|
+
raise HostedDeployError("DEPLOY_DATASET_TARGET_INVALID", detail) from error
|
|
894
|
+
if resolved != path or not stat.S_ISDIR(info.st_mode):
|
|
895
|
+
raise _refuse("DEPLOY_DATASET_TARGET_INVALID", detail)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@contextmanager
|
|
899
|
+
def _staging_directory(run_dir: Path) -> Iterator[Path]:
|
|
900
|
+
"""Build the tree beside its destination, so the destination only ever appears finished.
|
|
901
|
+
|
|
902
|
+
Beside rather than inside, because the destination may not exist yet and must not be made
|
|
903
|
+
before there is something verified to put in it. The parent is where the destination itself
|
|
904
|
+
will be created, so it is the same filesystem and the move stays one rename.
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
staging = run_dir.parent / f".hosted-build.{secrets.token_hex(8)}"
|
|
908
|
+
try:
|
|
909
|
+
staging.mkdir(mode=0o700)
|
|
910
|
+
except OSError as error:
|
|
911
|
+
raise HostedDeployError(
|
|
912
|
+
"DEPLOY_DATASET_INSTALL_FAILED",
|
|
913
|
+
f"a folder to build the released Build in could not be made beside {run_dir}",
|
|
914
|
+
) from error
|
|
915
|
+
try:
|
|
916
|
+
yield staging
|
|
917
|
+
finally:
|
|
918
|
+
_remove_staging(staging)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _remove_staging(staging: Path) -> None:
|
|
922
|
+
"""Discard an abandoned staging tree, including the read-only one a sealed Build is.
|
|
923
|
+
|
|
924
|
+
The tree is made writable from the top down before anything is removed from the bottom up.
|
|
925
|
+
Both directions are load-bearing: a sealed Build's directories are read-only by the time it is
|
|
926
|
+
assembled, and removing a file needs write permission on the directory holding it, not on the
|
|
927
|
+
file. Deleting deepest-first alone therefore stopped at the first sealed directory and left the
|
|
928
|
+
whole staging tree behind for every later run to find.
|
|
929
|
+
|
|
930
|
+
Failure here is not raised. This runs on the way out of a handoff that has already decided its
|
|
931
|
+
answer, and a leftover folder is not a reason to replace that answer with a different one.
|
|
932
|
+
"""
|
|
933
|
+
|
|
934
|
+
if not staging.exists():
|
|
935
|
+
return
|
|
936
|
+
entries = sorted(staging.rglob("*"), key=lambda entry: len(entry.parts))
|
|
937
|
+
for item in entries:
|
|
938
|
+
if item.is_dir() and not item.is_symlink():
|
|
939
|
+
try:
|
|
940
|
+
item.chmod(0o700)
|
|
941
|
+
except OSError:
|
|
942
|
+
return
|
|
943
|
+
for item in reversed(entries):
|
|
944
|
+
try:
|
|
945
|
+
if item.is_dir() and not item.is_symlink():
|
|
946
|
+
item.rmdir()
|
|
947
|
+
else:
|
|
948
|
+
item.unlink()
|
|
949
|
+
except OSError:
|
|
950
|
+
return
|
|
951
|
+
try:
|
|
952
|
+
staging.rmdir()
|
|
953
|
+
except OSError:
|
|
954
|
+
return
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def install_released_build(
|
|
958
|
+
run_dir: Path, members: Mapping[str, bytes], *, candidate_digest: str
|
|
959
|
+
) -> None:
|
|
960
|
+
"""Install the whole sealed tree, or none of it.
|
|
961
|
+
|
|
962
|
+
Every member is written into a staging folder first, verified there, and only then moved into
|
|
963
|
+
place in one rename, so an interrupted handoff leaves a discarded staging folder rather than a
|
|
964
|
+
half-populated Build for the next run -- or the viewer -- to find and reason about. The
|
|
965
|
+
destination folder itself is made in the same breath as the move, so a refusal on the way here
|
|
966
|
+
leaves no empty folder behind either.
|
|
967
|
+
"""
|
|
968
|
+
|
|
969
|
+
with _staging_directory(run_dir) as staging:
|
|
970
|
+
try:
|
|
971
|
+
_restore_candidate_members(members, staging)
|
|
972
|
+
except OSError as error:
|
|
973
|
+
raise HostedDeployError(
|
|
974
|
+
"DEPLOY_DATASET_INSTALL_FAILED", "the released Build could not be written here"
|
|
975
|
+
) from error
|
|
976
|
+
# Verified where it is discarded rather than where it is kept. A tree that cannot pass
|
|
977
|
+
# the checks a Build made on this machine passes never moves into place at all, so the
|
|
978
|
+
# destination never holds an unverifiable Build for a later run to refuse as somebody
|
|
979
|
+
# else's -- and the refusal below can say, truthfully, that nothing was written.
|
|
980
|
+
_verify_installed_build(staging, candidate_digest)
|
|
981
|
+
made = _make_destination(run_dir)
|
|
982
|
+
source = staging / "candidate"
|
|
983
|
+
try:
|
|
984
|
+
# Moving a folder needs write permission on the folder being moved, because its own
|
|
985
|
+
# link to its parent changes. The sealed tree is read-only by the time it is built, so
|
|
986
|
+
# that permission is restored for the move and taken away again immediately. Both
|
|
987
|
+
# states are unreachable to anything watching this run: before the move the tree is
|
|
988
|
+
# inside a private staging folder, and after it there is still no notebook beside it,
|
|
989
|
+
# which is what any reader has to see first. A process that dies inside that window
|
|
990
|
+
# leaves the right Build under a mode verification will not accept, which is what
|
|
991
|
+
# `_reseal_interrupted_install` exists to converge on.
|
|
992
|
+
os.chmod(source, 0o700)
|
|
993
|
+
os.rename(source, run_dir / "candidate")
|
|
994
|
+
except OSError as error:
|
|
995
|
+
# A folder this call made and could not fill is one the viewer will not open, so it
|
|
996
|
+
# goes back down. And a full disk is not somebody else's Build being in the way: the
|
|
997
|
+
# two are told apart by what the system said rather than by which line raised.
|
|
998
|
+
if made:
|
|
999
|
+
with suppress(OSError):
|
|
1000
|
+
run_dir.rmdir()
|
|
1001
|
+
if error.errno in {errno.EEXIST, errno.ENOTEMPTY}:
|
|
1002
|
+
raise HostedDeployError(
|
|
1003
|
+
"DEPLOY_DATASET_TARGET_OCCUPIED",
|
|
1004
|
+
f"something is already in the way at {run_dir / 'candidate'}",
|
|
1005
|
+
) from error
|
|
1006
|
+
raise HostedDeployError(
|
|
1007
|
+
"DEPLOY_DATASET_INSTALL_FAILED",
|
|
1008
|
+
f"the released Build could not be put in place at {run_dir}",
|
|
1009
|
+
) from error
|
|
1010
|
+
try:
|
|
1011
|
+
_seal_installed_build(run_dir)
|
|
1012
|
+
_ensure_table_sidecar_lock(run_dir)
|
|
1013
|
+
except OSError as error:
|
|
1014
|
+
raise HostedDeployError(
|
|
1015
|
+
"DEPLOY_DATASET_INSTALL_FAILED", "the released Build could not be settled here"
|
|
1016
|
+
) from error
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _make_destination(run_dir: Path) -> bool:
|
|
1020
|
+
"""Make the destination folder, now that there is a verified Build to put in it.
|
|
1021
|
+
|
|
1022
|
+
Returns whether this call made it, so a move that then fails can take it back down again.
|
|
1023
|
+
|
|
1024
|
+
A folder that appeared between the check at the start of the handoff and this moment is
|
|
1025
|
+
re-checked rather than assumed: the validation up front pins nothing, so a writer in the parent
|
|
1026
|
+
could have planted a link where the folder was going, and the rename that follows would resolve
|
|
1027
|
+
through it.
|
|
1028
|
+
"""
|
|
1029
|
+
|
|
1030
|
+
try:
|
|
1031
|
+
run_dir.mkdir(mode=0o700)
|
|
1032
|
+
except FileExistsError:
|
|
1033
|
+
_require_real_directory(
|
|
1034
|
+
run_dir, f"the folder to write into is not one real folder: {run_dir}"
|
|
1035
|
+
)
|
|
1036
|
+
return False
|
|
1037
|
+
except OSError as error:
|
|
1038
|
+
raise HostedDeployError(
|
|
1039
|
+
"DEPLOY_DATASET_TARGET_INVALID",
|
|
1040
|
+
f"the folder to write into could not be made: {run_dir}",
|
|
1041
|
+
) from error
|
|
1042
|
+
return True
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _seal_installed_build(run_dir: Path) -> None:
|
|
1046
|
+
"""Give the installed tree its sealed mode and make the move survive a power loss."""
|
|
1047
|
+
|
|
1048
|
+
os.chmod(run_dir / "candidate", 0o555)
|
|
1049
|
+
_fsync_directory(run_dir)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _fsync_directory(path: Path) -> None:
|
|
1053
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
1054
|
+
try:
|
|
1055
|
+
os.fsync(descriptor)
|
|
1056
|
+
finally:
|
|
1057
|
+
os.close(descriptor)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _reseal_interrupted_install(run_dir: Path, candidate_digest: str) -> None:
|
|
1061
|
+
"""Finish the sealing step of an install this or an earlier run left half done.
|
|
1062
|
+
|
|
1063
|
+
An install that died between the move and the sealing chmod leaves the exact released Build in
|
|
1064
|
+
place under an owner-writable mode, and verification requires the sealed one -- so the rerun
|
|
1065
|
+
that is supposed to converge would instead read an unverifiable tree and refuse it as somebody
|
|
1066
|
+
else's Build.
|
|
1067
|
+
|
|
1068
|
+
Only that one state is repaired, and it is identified by what the tree says it is rather than
|
|
1069
|
+
by its mode alone: an owner-writable ``candidate`` whose own record names this exact released
|
|
1070
|
+
Build. Nothing is followed on the way -- the folder and its record are opened without following
|
|
1071
|
+
links and the mode is set through the open folder, so a name swapped underneath this cannot
|
|
1072
|
+
redirect it. And repairing it decides nothing: the mode is tightened rather than loosened, and
|
|
1073
|
+
ordinary verification still has to recognise the tree afterwards or the handoff refuses exactly
|
|
1074
|
+
as it did before.
|
|
1075
|
+
"""
|
|
1076
|
+
|
|
1077
|
+
directory = -1
|
|
1078
|
+
try:
|
|
1079
|
+
directory = os.open(run_dir / "candidate", os.O_RDONLY | os.O_DIRECTORY | _NO_FOLLOW)
|
|
1080
|
+
info = os.fstat(directory)
|
|
1081
|
+
if stat.S_IMODE(info.st_mode) != 0o700:
|
|
1082
|
+
return
|
|
1083
|
+
if _recorded_candidate_digest(directory) != candidate_digest:
|
|
1084
|
+
return
|
|
1085
|
+
os.fchmod(directory, 0o555)
|
|
1086
|
+
except OSError:
|
|
1087
|
+
return
|
|
1088
|
+
finally:
|
|
1089
|
+
if directory >= 0:
|
|
1090
|
+
os.close(directory)
|
|
1091
|
+
_fsync_directory(run_dir)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _recorded_candidate_digest(directory: int) -> str | None:
|
|
1095
|
+
"""Read the fingerprint one unsealed tree records for itself, or nothing."""
|
|
1096
|
+
|
|
1097
|
+
descriptor = os.open("manifest.json", os.O_RDONLY | _NO_FOLLOW, dir_fd=directory)
|
|
1098
|
+
try:
|
|
1099
|
+
info = os.fstat(descriptor)
|
|
1100
|
+
if (
|
|
1101
|
+
not stat.S_ISREG(info.st_mode)
|
|
1102
|
+
or info.st_nlink != 1
|
|
1103
|
+
or info.st_size > MAX_MANIFEST_BYTES
|
|
1104
|
+
):
|
|
1105
|
+
return None
|
|
1106
|
+
raw = os.read(descriptor, MAX_MANIFEST_BYTES)
|
|
1107
|
+
finally:
|
|
1108
|
+
os.close(descriptor)
|
|
1109
|
+
try:
|
|
1110
|
+
value = canonical.parse_json(raw)
|
|
1111
|
+
except canonical.CanonicalJSONError:
|
|
1112
|
+
return None
|
|
1113
|
+
recorded = value.get("candidate_digest") if isinstance(value, dict) else None
|
|
1114
|
+
return f"sha256:{recorded}" if isinstance(recorded, str) else None
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _installed_build_digest(run_dir: Path) -> str | None:
|
|
1118
|
+
"""Return the fingerprint of an already-installed Build here, or ``None`` when there is none.
|
|
1119
|
+
|
|
1120
|
+
An unreadable installed tree answers with the empty string rather than ``None``: something is
|
|
1121
|
+
in the way, it is not the released Build, and saying "nothing is here" about it would let the
|
|
1122
|
+
install path try to move a Build on top of it.
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
from mostlyright.data_harness import pipeline
|
|
1126
|
+
|
|
1127
|
+
if not (run_dir / "candidate").exists():
|
|
1128
|
+
return None
|
|
1129
|
+
try:
|
|
1130
|
+
with pipeline.open_verified_snapshot(run_dir) as snapshot:
|
|
1131
|
+
return "sha256:" + snapshot.verified.candidate_digest
|
|
1132
|
+
except Exception:
|
|
1133
|
+
return ""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# ------------------------------------------------------------------------------------------------
|
|
1137
|
+
# The handoff
|
|
1138
|
+
# ------------------------------------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def materialize_hosted_dataset(
|
|
1142
|
+
run_dir: Path,
|
|
1143
|
+
*,
|
|
1144
|
+
dataset_id: UUID,
|
|
1145
|
+
table_id: UUID,
|
|
1146
|
+
hosted_run_id: UUID,
|
|
1147
|
+
token: StudioToken,
|
|
1148
|
+
reader_factory: Callable[[StudioToken], StudioDatasetReader] | None = None,
|
|
1149
|
+
downloader: Callable[[ValidatedResultDownload, Mapping[str, str]], bytes] | None = None,
|
|
1150
|
+
visual_store: VisualRunStore | None = None,
|
|
1151
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
1152
|
+
) -> HostedDatasetHandoff:
|
|
1153
|
+
"""Bring one released hosted Build here and derive its table notebook from it.
|
|
1154
|
+
|
|
1155
|
+
The order is the contract. Nothing on this machine is written before the transferred bytes have
|
|
1156
|
+
matched the fingerprint the release sealed and decoded into the exact Build the release names;
|
|
1157
|
+
nothing is derived from the installed tree before ordinary local verification has re-derived
|
|
1158
|
+
that same fingerprint from it; and the notebook is written by the existing atomic installer, so
|
|
1159
|
+
the viewer never observes a partial one. A rerun after any interruption converges: an already
|
|
1160
|
+
installed Build with the released fingerprint is kept and its notebook simply re-derived.
|
|
1161
|
+
|
|
1162
|
+
``visual_store`` is the open workbench's own run log. Narrating into it is what turns a written
|
|
1163
|
+
file into a revealed Table tab, and it is deliberately the last thing that happens: the
|
|
1164
|
+
viewer will not show the tab on the narration alone, because it re-derives the notebook from
|
|
1165
|
+
its own verified snapshot and compares bytes first, so narrating earlier would only mean
|
|
1166
|
+
announcing something that is not there yet.
|
|
1167
|
+
"""
|
|
1168
|
+
|
|
1169
|
+
target = _require_run_directory(run_dir)
|
|
1170
|
+
if token.workspace_id is None:
|
|
1171
|
+
raise _refuse("DEPLOY_TOKEN_EXCHANGE_FAILED", "the sign-in has no workspace")
|
|
1172
|
+
# The two boundaries are resolved here rather than bound as default arguments, so substituting
|
|
1173
|
+
# either one is a matter of naming it on this module, exactly as it is for every other seam in
|
|
1174
|
+
# this tree. A default bound at definition time is one nothing can reach afterwards.
|
|
1175
|
+
reader = (reader_factory or GeneratedStudioDatasetReader)(token)
|
|
1176
|
+
transfer = downloader or download_sealed_artifact
|
|
1177
|
+
try:
|
|
1178
|
+
released = select_released_build(
|
|
1179
|
+
reader.released_versions(table_id),
|
|
1180
|
+
dataset_id=dataset_id,
|
|
1181
|
+
table_id=table_id,
|
|
1182
|
+
workspace_id=token.workspace_id,
|
|
1183
|
+
hosted_run_id=hosted_run_id,
|
|
1184
|
+
)
|
|
1185
|
+
_require_narratable(visual_store, released.candidate_digest)
|
|
1186
|
+
_reseal_interrupted_install(target, released.candidate_digest)
|
|
1187
|
+
installed = _installed_build_digest(target)
|
|
1188
|
+
transferred = installed != released.candidate_digest
|
|
1189
|
+
artifact = _select_sealed_evidence(reader, released)
|
|
1190
|
+
if transferred:
|
|
1191
|
+
if installed is not None:
|
|
1192
|
+
raise _refuse(
|
|
1193
|
+
"DEPLOY_DATASET_TARGET_OCCUPIED",
|
|
1194
|
+
f"{target} already holds a different Build; name an unused folder with --into",
|
|
1195
|
+
)
|
|
1196
|
+
session, headers = validate_download_session(
|
|
1197
|
+
reader.download_session(artifact.artifact_id),
|
|
1198
|
+
artifact=artifact,
|
|
1199
|
+
studio_base_url=token.studio_base_url,
|
|
1200
|
+
now=now(),
|
|
1201
|
+
)
|
|
1202
|
+
raw = transfer(session, headers)
|
|
1203
|
+
members = _decode_released_build(raw, released.candidate_digest)
|
|
1204
|
+
install_released_build(target, members, candidate_digest=released.candidate_digest)
|
|
1205
|
+
finally:
|
|
1206
|
+
# Closing a socket is not a verdict. Letting it raise here would replace whatever this
|
|
1207
|
+
# handoff had already decided -- including a refusal a person is waiting to read.
|
|
1208
|
+
try:
|
|
1209
|
+
reader.close()
|
|
1210
|
+
except Exception:
|
|
1211
|
+
pass
|
|
1212
|
+
candidate_digest = _verify_installed_build(target, released.candidate_digest)
|
|
1213
|
+
_ensure_table_sidecar_lock(target)
|
|
1214
|
+
notebook_path = _render_notebook(target, replace_existing=transferred)
|
|
1215
|
+
handoff = HostedDatasetHandoff(
|
|
1216
|
+
run_dir=target,
|
|
1217
|
+
dataset_id=released.dataset_id,
|
|
1218
|
+
table_id=released.table_id,
|
|
1219
|
+
table_version_id=released.table_version_id,
|
|
1220
|
+
version_number=released.version_number,
|
|
1221
|
+
hosted_run_id=released.run_id,
|
|
1222
|
+
candidate_digest=candidate_digest,
|
|
1223
|
+
artifact_id=artifact.artifact_id,
|
|
1224
|
+
artifact_content_digest=artifact.content_digest,
|
|
1225
|
+
artifact_size_bytes=artifact.size_bytes,
|
|
1226
|
+
notebook_path=notebook_path,
|
|
1227
|
+
transferred=transferred,
|
|
1228
|
+
)
|
|
1229
|
+
_write_handoff_receipt(target, handoff)
|
|
1230
|
+
_narrate_hosted_build(visual_store, handoff)
|
|
1231
|
+
return handoff
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _require_narratable(visual_store: VisualRunStore | None, candidate_digest: str) -> None:
|
|
1235
|
+
"""Refuse before anything is written when this workbench log belongs to another Build."""
|
|
1236
|
+
|
|
1237
|
+
if visual_store is None:
|
|
1238
|
+
return
|
|
1239
|
+
try:
|
|
1240
|
+
state = reduce_events(visual_store.events())
|
|
1241
|
+
except VisualRunError as error:
|
|
1242
|
+
raise HostedDeployError(
|
|
1243
|
+
"DEPLOY_DATASET_NARRATION_UNAVAILABLE",
|
|
1244
|
+
"the open workbench record could not be read",
|
|
1245
|
+
) from error
|
|
1246
|
+
if (
|
|
1247
|
+
state.candidate_digest is not None
|
|
1248
|
+
and "sha256:" + state.candidate_digest != candidate_digest
|
|
1249
|
+
):
|
|
1250
|
+
raise _refuse(
|
|
1251
|
+
"DEPLOY_DATASET_NARRATION_CONFLICT",
|
|
1252
|
+
"this workbench is already showing a different Build; start a new one for this",
|
|
1253
|
+
)
|
|
1254
|
+
if state.terminal and state.lifecycle != "completed":
|
|
1255
|
+
raise _refuse(
|
|
1256
|
+
"DEPLOY_DATASET_NARRATION_CONFLICT",
|
|
1257
|
+
"this workbench already ended without a dataset; start a new one for this",
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _narrate_hosted_build(
|
|
1262
|
+
visual_store: VisualRunStore | None, handoff: HostedDatasetHandoff
|
|
1263
|
+
) -> None:
|
|
1264
|
+
"""Tell the open workbench what landed, using the same records a local Build writes."""
|
|
1265
|
+
|
|
1266
|
+
if visual_store is None:
|
|
1267
|
+
return
|
|
1268
|
+
digest = handoff.candidate_digest
|
|
1269
|
+
try:
|
|
1270
|
+
content = _read_installed_notebook(handoff.notebook_path)
|
|
1271
|
+
work = VisualWork(visual_store, actor="worker")
|
|
1272
|
+
if not visual_store.events():
|
|
1273
|
+
work.start_run(key=f"hosted-build:{digest[:16]}:started")
|
|
1274
|
+
work.build_sealed(digest, key=f"hosted-build:{digest[:16]}:sealed")
|
|
1275
|
+
work.build_verified(digest, key=f"hosted-build:{digest[:16]}:verified")
|
|
1276
|
+
work.notebook_materialized("table", content, key=f"hosted-build:{digest[:16]}:notebook")
|
|
1277
|
+
if reduce_events(visual_store.events()).lifecycle != "completed":
|
|
1278
|
+
work.complete_run(key=f"hosted-build:{digest[:16]}:completed")
|
|
1279
|
+
except (OSError, ValueError) as error:
|
|
1280
|
+
raise HostedDeployError(
|
|
1281
|
+
"DEPLOY_DATASET_NARRATION_UNAVAILABLE",
|
|
1282
|
+
"the dataset is here, but the open workbench could not be told about it",
|
|
1283
|
+
) from error
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _select_sealed_evidence(reader: StudioDatasetReader, released: ReleasedBuild) -> SealedArtifact:
|
|
1287
|
+
"""Read every artifact the release names and keep the one kind this handoff redeems."""
|
|
1288
|
+
|
|
1289
|
+
selected: SealedArtifact | None = None
|
|
1290
|
+
for artifact_id, reference_digest in released.artifact_references:
|
|
1291
|
+
sealed = validate_sealed_artifact(
|
|
1292
|
+
reader.artifact(artifact_id),
|
|
1293
|
+
released=released,
|
|
1294
|
+
artifact_id=artifact_id,
|
|
1295
|
+
reference_digest=reference_digest,
|
|
1296
|
+
)
|
|
1297
|
+
if sealed.kind != REQUIRED_ARTIFACT_KIND:
|
|
1298
|
+
continue
|
|
1299
|
+
if sealed.media_type != REQUIRED_ARTIFACT_MEDIA_TYPE:
|
|
1300
|
+
# The contract for a kind is part of what the kind means. Evidence that arrives under
|
|
1301
|
+
# the wrong one is refused here rather than repaired later, so a Studio slot whose
|
|
1302
|
+
# contract has drifted cannot be rendered as though it had not.
|
|
1303
|
+
raise _refuse(
|
|
1304
|
+
"DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
|
|
1305
|
+
"the sealed evidence does not meet the contract for its kind",
|
|
1306
|
+
)
|
|
1307
|
+
if selected is not None:
|
|
1308
|
+
raise _refuse(
|
|
1309
|
+
"DEPLOY_DATASET_ARTIFACT_INVALID",
|
|
1310
|
+
"the released Build names its sealed evidence more than once",
|
|
1311
|
+
)
|
|
1312
|
+
selected = sealed
|
|
1313
|
+
if selected is None:
|
|
1314
|
+
raise _refuse(
|
|
1315
|
+
"DEPLOY_DATASET_ARTIFACT_ABSENT",
|
|
1316
|
+
"the released Build does not name the sealed evidence this needs",
|
|
1317
|
+
)
|
|
1318
|
+
return selected
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _decode_released_build(raw: bytes, candidate_digest: str) -> Mapping[str, bytes]:
|
|
1322
|
+
if len(raw) > MAX_CANDIDATE_ENVELOPE_BYTES:
|
|
1323
|
+
raise _refuse(
|
|
1324
|
+
"DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
|
|
1325
|
+
"the sealed evidence is larger than the contract for its kind allows",
|
|
1326
|
+
)
|
|
1327
|
+
try:
|
|
1328
|
+
return _decode_candidate_envelope(raw, candidate_digest)
|
|
1329
|
+
except HostedWorkerError as error:
|
|
1330
|
+
raise HostedDeployError(
|
|
1331
|
+
"DEPLOY_DATASET_ARTIFACT_CONTRACT_INVALID",
|
|
1332
|
+
"the sealed evidence does not meet the contract for its kind",
|
|
1333
|
+
) from error
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _verify_installed_build(run_dir: Path, candidate_digest: str) -> str:
|
|
1337
|
+
"""Re-derive the fingerprint here, from the installed tree, by ordinary local verification."""
|
|
1338
|
+
|
|
1339
|
+
from mostlyright.data_harness import pipeline
|
|
1340
|
+
|
|
1341
|
+
try:
|
|
1342
|
+
verified = pipeline.verify_candidate(run_dir)
|
|
1343
|
+
except Exception as error:
|
|
1344
|
+
raise HostedDeployError(
|
|
1345
|
+
"DEPLOY_DATASET_LOCAL_CHECK_FAILED",
|
|
1346
|
+
"the Build written here did not pass the same checks a local Build passes",
|
|
1347
|
+
) from error
|
|
1348
|
+
if "sha256:" + verified.candidate_digest != candidate_digest:
|
|
1349
|
+
raise _refuse(
|
|
1350
|
+
"DEPLOY_DATASET_LOCAL_CHECK_FAILED",
|
|
1351
|
+
"the Build written here is not the Build the release named",
|
|
1352
|
+
)
|
|
1353
|
+
return verified.candidate_digest
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _ensure_table_sidecar_lock(run_dir: Path) -> None:
|
|
1357
|
+
"""Install the structural sidecar lock omitted from the sealed candidate envelope."""
|
|
1358
|
+
|
|
1359
|
+
directory = os.open(
|
|
1360
|
+
run_dir,
|
|
1361
|
+
os.O_RDONLY | os.O_DIRECTORY | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
1362
|
+
)
|
|
1363
|
+
lock = -1
|
|
1364
|
+
try:
|
|
1365
|
+
try:
|
|
1366
|
+
lock = os.open(
|
|
1367
|
+
pipeline.RUN_BUILD_ACTIVITY_LOCK,
|
|
1368
|
+
os.O_RDWR | os.O_CREAT | os.O_EXCL | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
1369
|
+
0o600,
|
|
1370
|
+
dir_fd=directory,
|
|
1371
|
+
)
|
|
1372
|
+
os.fsync(lock)
|
|
1373
|
+
os.fsync(directory)
|
|
1374
|
+
except FileExistsError:
|
|
1375
|
+
lock = os.open(
|
|
1376
|
+
pipeline.RUN_BUILD_ACTIVITY_LOCK,
|
|
1377
|
+
os.O_RDONLY | _NO_FOLLOW | getattr(os, "O_CLOEXEC", 0),
|
|
1378
|
+
dir_fd=directory,
|
|
1379
|
+
)
|
|
1380
|
+
metadata = os.fstat(lock)
|
|
1381
|
+
named = os.stat(
|
|
1382
|
+
pipeline.RUN_BUILD_ACTIVITY_LOCK,
|
|
1383
|
+
dir_fd=directory,
|
|
1384
|
+
follow_symlinks=False,
|
|
1385
|
+
)
|
|
1386
|
+
if (
|
|
1387
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1388
|
+
or metadata.st_nlink != 1
|
|
1389
|
+
or metadata.st_size != 0
|
|
1390
|
+
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
1391
|
+
or pipeline._entry_identity(metadata) != pipeline._entry_identity(named)
|
|
1392
|
+
):
|
|
1393
|
+
raise OSError(errno.EPERM, "the Table sidecar lock is not one stable empty file")
|
|
1394
|
+
except OSError as error:
|
|
1395
|
+
raise HostedDeployError(
|
|
1396
|
+
"DEPLOY_DATASET_NOTEBOOK_FAILED",
|
|
1397
|
+
"the Table notebook lock could not be established here",
|
|
1398
|
+
) from error
|
|
1399
|
+
finally:
|
|
1400
|
+
if lock >= 0:
|
|
1401
|
+
os.close(lock)
|
|
1402
|
+
os.close(directory)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _render_notebook(run_dir: Path, *, replace_existing: bool) -> Path:
|
|
1406
|
+
"""Ask the one generator for the notebook, replacing only what this run just installed.
|
|
1407
|
+
|
|
1408
|
+
A Build that has just landed has no notebook worth keeping, so its own is written. A rerun over
|
|
1409
|
+
an already installed Build fills an absence and no more: the shipped skill tells an agent to
|
|
1410
|
+
append executed chart cells to ``table.ipynb``, and this may be called unattended and
|
|
1411
|
+
repeatedly by whatever is watching the hosted run, which is exactly the shape that would
|
|
1412
|
+
silently discard them. It is the same line ``notebook.py`` draws between the command a person
|
|
1413
|
+
ran and the viewer's own recovery.
|
|
1414
|
+
"""
|
|
1415
|
+
|
|
1416
|
+
from mostlyright.data_harness.notebook import (
|
|
1417
|
+
TABLE_NOTEBOOK_NAME,
|
|
1418
|
+
render_table_notebook,
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
try:
|
|
1422
|
+
return render_table_notebook(run_dir, replace_existing=replace_existing)
|
|
1423
|
+
except FileExistsError:
|
|
1424
|
+
return run_dir / TABLE_NOTEBOOK_NAME
|
|
1425
|
+
except Exception as error:
|
|
1426
|
+
raise HostedDeployError(
|
|
1427
|
+
"DEPLOY_DATASET_NOTEBOOK_FAILED", "the table notebook could not be written here"
|
|
1428
|
+
) from error
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _read_installed_notebook(path: Path) -> bytes:
|
|
1432
|
+
"""Read the notebook about to be narrated without following a link or taking an unbounded file.
|
|
1433
|
+
|
|
1434
|
+
On a converging rerun this is a file this command did not write, so it is read the way every
|
|
1435
|
+
other file here is read rather than the way a path is usually opened.
|
|
1436
|
+
"""
|
|
1437
|
+
|
|
1438
|
+
descriptor = os.open(path, os.O_RDONLY | _NO_FOLLOW)
|
|
1439
|
+
try:
|
|
1440
|
+
info = os.fstat(descriptor)
|
|
1441
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
1442
|
+
raise OSError(errno.EPERM, "the table notebook is not one ordinary file")
|
|
1443
|
+
if info.st_size > MAX_NOTEBOOK_BYTES:
|
|
1444
|
+
raise OSError(errno.EFBIG, "the table notebook is larger than this will read")
|
|
1445
|
+
return os.read(descriptor, MAX_NOTEBOOK_BYTES)
|
|
1446
|
+
finally:
|
|
1447
|
+
os.close(descriptor)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _write_handoff_receipt(run_dir: Path, handoff: HostedDatasetHandoff) -> None:
|
|
1451
|
+
"""Record which released Build these local bytes came from, atomically and non-secretly.
|
|
1452
|
+
|
|
1453
|
+
This is the coordinate the viewer otherwise has no way to know: it observes content
|
|
1454
|
+
fingerprints and local files, and nothing in it names a hosted run. The receipt states nothing
|
|
1455
|
+
the viewer is allowed to admit anything on -- admission stays exactly what it was -- it only
|
|
1456
|
+
lets what is already admitted be attributed to the run that produced it.
|
|
1457
|
+
"""
|
|
1458
|
+
|
|
1459
|
+
destination = run_dir / HANDOFF_RELATIVE_PATH
|
|
1460
|
+
raw = canonical.canonical_json_bytes(handoff.to_dict())
|
|
1461
|
+
temporary = destination.parent / f".handoff.{secrets.token_hex(8)}"
|
|
1462
|
+
try:
|
|
1463
|
+
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1464
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NO_FOLLOW, 0o600)
|
|
1465
|
+
try:
|
|
1466
|
+
view = memoryview(raw)
|
|
1467
|
+
while view:
|
|
1468
|
+
view = view[os.write(descriptor, view) :]
|
|
1469
|
+
os.fsync(descriptor)
|
|
1470
|
+
finally:
|
|
1471
|
+
os.close(descriptor)
|
|
1472
|
+
os.replace(temporary, destination)
|
|
1473
|
+
except OSError as error:
|
|
1474
|
+
try:
|
|
1475
|
+
temporary.unlink()
|
|
1476
|
+
except OSError:
|
|
1477
|
+
pass
|
|
1478
|
+
raise HostedDeployError(
|
|
1479
|
+
"DEPLOY_DATASET_RECEIPT_FAILED", "the record of this handoff could not be written"
|
|
1480
|
+
) from error
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
__all__ = [
|
|
1484
|
+
"GCS_COMMITTED_PREFIX",
|
|
1485
|
+
"HANDOFF_RECEIPT_SCHEMA",
|
|
1486
|
+
"HANDOFF_RELATIVE_PATH",
|
|
1487
|
+
"REQUIRED_ARTIFACT_KIND",
|
|
1488
|
+
"GeneratedStudioDatasetReader",
|
|
1489
|
+
"HostedDatasetHandoff",
|
|
1490
|
+
"ReleasedBuild",
|
|
1491
|
+
"SealedArtifact",
|
|
1492
|
+
"StudioDatasetReader",
|
|
1493
|
+
"download_sealed_artifact",
|
|
1494
|
+
"install_released_build",
|
|
1495
|
+
"materialize_hosted_dataset",
|
|
1496
|
+
"read_handoff_receipt",
|
|
1497
|
+
"select_released_build",
|
|
1498
|
+
"validate_download_session",
|
|
1499
|
+
"validate_sealed_artifact",
|
|
1500
|
+
]
|