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,523 @@
|
|
|
1
|
+
"""The catalogue release channel: one manifest naming the current generation, and the pin for it.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS. Until ADR 0021 a sealed generation reached a machine exactly one way: an operator
|
|
4
|
+
typed a URL and the archive's SHA-256 next to it (``mr-data catalog-update``). That works for a
|
|
5
|
+
person and not at all for a fleet, and ADR 0021 puts every research lane on a backend worker. A
|
|
6
|
+
worker cannot be typed at. It needs to learn, from its own deploy-owned configuration and from
|
|
7
|
+
nothing else, which generation it is supposed to have -- and it must refuse anything else.
|
|
8
|
+
|
|
9
|
+
THE TWO DOCUMENTS. A *channel manifest* (``channel.json``) is what the channel publishes: it names
|
|
10
|
+
one release archive, its byte count, its digest, and the generation inside it. A *channel pin* is
|
|
11
|
+
what a deployment configures: the channel's address and the exact manifest digest that deployment
|
|
12
|
+
was rolled out with. Neither is trusted on its own. The manifest is bytes off the network and is
|
|
13
|
+
believed only when its SHA-256 equals the pinned one; the archive is believed only when its
|
|
14
|
+
SHA-256 equals the one the believed manifest states; and the generation inside the archive is then
|
|
15
|
+
put through the same ``update.install_release`` verification a hand-typed install goes through.
|
|
16
|
+
There is no new trust root here -- the pin is the root, and it is deploy-owned.
|
|
17
|
+
|
|
18
|
+
WHY THE PIN CARRIES A SELECTION ID. This is deliberately the same shape ``hosted_bootstrap``'s
|
|
19
|
+
``MOSTLYRIGHT_WORKER_POLICY_SELECTION_JSON`` uses: every field of the selection is folded into one
|
|
20
|
+
identifier that the reader recomputes, so a deployment cannot half-change the pin -- edit the
|
|
21
|
+
digest and leave the URL, say -- without the worker noticing that the selection no longer binds
|
|
22
|
+
the fields it is made of. A drifted pin is refused at start, not tolerated until a query happens
|
|
23
|
+
to hit a missing entry.
|
|
24
|
+
|
|
25
|
+
Nothing in this module performs IO. It is the contract; ``channel_client`` is the fetch.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
|
|
36
|
+
from mostlyright.data_harness.canonical import (
|
|
37
|
+
CanonicalJSONError,
|
|
38
|
+
canonical_json_bytes,
|
|
39
|
+
parse_canonical_json,
|
|
40
|
+
parse_json,
|
|
41
|
+
)
|
|
42
|
+
from mostlyright.data_harness.sources.catalog.generation_receipt import is_digest
|
|
43
|
+
from mostlyright.data_harness.sources.catalog.update import (
|
|
44
|
+
SUPPORTED_STORE_KINDS,
|
|
45
|
+
ReleaseDescriptor,
|
|
46
|
+
)
|
|
47
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
48
|
+
SourceContractError,
|
|
49
|
+
require_canonical_timestamp,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"CATALOG_CHANNEL_PIN_ENV",
|
|
54
|
+
"CATALOG_CHANNEL_PIN_SCHEMA",
|
|
55
|
+
"CHANNEL_MANIFEST_OBJECT",
|
|
56
|
+
"CHANNEL_MANIFEST_SCHEMA",
|
|
57
|
+
"MAX_CHANNEL_MANIFEST_BYTES",
|
|
58
|
+
"MAX_CHANNEL_NAME",
|
|
59
|
+
"CatalogChannelPin",
|
|
60
|
+
"ChannelManifest",
|
|
61
|
+
"ChannelRelease",
|
|
62
|
+
"build_channel_manifest",
|
|
63
|
+
"channel_manifest_bytes",
|
|
64
|
+
"parse_catalog_channel_pin",
|
|
65
|
+
"parse_channel_manifest",
|
|
66
|
+
"pin_from_environment",
|
|
67
|
+
"verify_channel_manifest",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
#: The manifest's schema token. A reader that does not know this exact string refuses the document
|
|
71
|
+
#: rather than reading the fields it recognises out of it.
|
|
72
|
+
CHANNEL_MANIFEST_SCHEMA = "mr-data-catalog-channel.v1"
|
|
73
|
+
|
|
74
|
+
#: The one object name a channel publishes its manifest under, relative to the channel base.
|
|
75
|
+
CHANNEL_MANIFEST_OBJECT = "channel.json"
|
|
76
|
+
|
|
77
|
+
#: The pin's schema token, and the environment variable a deployment states the pin in. The name
|
|
78
|
+
#: follows ``MOSTLYRIGHT_WORKER_POLICY_SELECTION_JSON`` because it is the same kind of thing: one
|
|
79
|
+
#: deploy-owned JSON document that the worker template supplies and the worker recomputes.
|
|
80
|
+
CATALOG_CHANNEL_PIN_SCHEMA = "mostlyright-worker-catalog-channel.v1"
|
|
81
|
+
CATALOG_CHANNEL_PIN_ENV = "MOSTLYRIGHT_WORKER_CATALOG_CHANNEL_JSON"
|
|
82
|
+
|
|
83
|
+
#: A manifest names one release and nothing else, so it is small by construction. Anything larger
|
|
84
|
+
#: is refused unread rather than parsed and then found to be over-full.
|
|
85
|
+
MAX_CHANNEL_MANIFEST_BYTES = 8_192
|
|
86
|
+
|
|
87
|
+
#: A channel name is an operator's label for one line of successors ("public", "staging").
|
|
88
|
+
MAX_CHANNEL_NAME = 64
|
|
89
|
+
|
|
90
|
+
#: The largest archive a channel may name. Equal to ``update.MAX_RELEASE_ARCHIVE_BYTES``: the
|
|
91
|
+
#: manifest cannot promise bytes the installer would refuse to read.
|
|
92
|
+
MAX_CHANNEL_ARCHIVE_BYTES = 1_073_741_824
|
|
93
|
+
|
|
94
|
+
_CHANNEL_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}[a-z0-9]|[a-z0-9]")
|
|
95
|
+
_ARCHIVE_OBJECT = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}")
|
|
96
|
+
_SELECTION_ID = re.compile(r"[0-9a-f]{64}")
|
|
97
|
+
|
|
98
|
+
_RELEASE_KEYS = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"archive_bytes",
|
|
101
|
+
"archive_object",
|
|
102
|
+
"archive_sha256",
|
|
103
|
+
"entry_count",
|
|
104
|
+
"predecessor_store_sha256",
|
|
105
|
+
"published_at",
|
|
106
|
+
"store_kind",
|
|
107
|
+
"store_sha256",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
_MANIFEST_KEYS = frozenset({"channel", "manifest_id", "release", "schema_version"})
|
|
111
|
+
_PIN_KEYS = frozenset({"channel_url", "manifest_sha256", "schema_version", "selection_id"})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _refuse(code: str, path: str, detail: str) -> SourceContractError:
|
|
115
|
+
return SourceContractError(code, path, detail)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class ChannelRelease:
|
|
120
|
+
"""The one release a channel manifest names, and everything needed to fetch and check it."""
|
|
121
|
+
|
|
122
|
+
store_kind: str
|
|
123
|
+
store_sha256: str
|
|
124
|
+
predecessor_store_sha256: str | None
|
|
125
|
+
entry_count: int
|
|
126
|
+
published_at: str
|
|
127
|
+
archive_object: str
|
|
128
|
+
archive_sha256: str
|
|
129
|
+
archive_bytes: int
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
if self.store_kind not in SUPPORTED_STORE_KINDS:
|
|
133
|
+
raise _refuse(
|
|
134
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
135
|
+
"release.store_kind",
|
|
136
|
+
"must name a supported store kind",
|
|
137
|
+
)
|
|
138
|
+
for field in ("store_sha256", "archive_sha256"):
|
|
139
|
+
if not is_digest(getattr(self, field)):
|
|
140
|
+
raise _refuse(
|
|
141
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
142
|
+
f"release.{field}",
|
|
143
|
+
"must be one exact lowercase SHA-256",
|
|
144
|
+
)
|
|
145
|
+
if self.predecessor_store_sha256 is not None:
|
|
146
|
+
if not is_digest(self.predecessor_store_sha256):
|
|
147
|
+
raise _refuse(
|
|
148
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
149
|
+
"release.predecessor_store_sha256",
|
|
150
|
+
"must be one exact lowercase SHA-256 or null",
|
|
151
|
+
)
|
|
152
|
+
if self.predecessor_store_sha256 == self.store_sha256:
|
|
153
|
+
raise _refuse(
|
|
154
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
155
|
+
"release.predecessor_store_sha256",
|
|
156
|
+
"must not name the release itself",
|
|
157
|
+
)
|
|
158
|
+
if _ARCHIVE_OBJECT.fullmatch(self.archive_object) is None:
|
|
159
|
+
raise _refuse(
|
|
160
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
161
|
+
"release.archive_object",
|
|
162
|
+
"must be one plain object name with no path separator",
|
|
163
|
+
)
|
|
164
|
+
for field in ("entry_count", "archive_bytes"):
|
|
165
|
+
value = getattr(self, field)
|
|
166
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
167
|
+
raise _refuse(
|
|
168
|
+
"CATALOG_CHANNEL_RELEASE", f"release.{field}", "must be a whole number"
|
|
169
|
+
)
|
|
170
|
+
if not 1 <= self.archive_bytes <= MAX_CHANNEL_ARCHIVE_BYTES:
|
|
171
|
+
raise _refuse(
|
|
172
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
173
|
+
"release.archive_bytes",
|
|
174
|
+
"must be a byte count the installer would accept",
|
|
175
|
+
)
|
|
176
|
+
require_canonical_timestamp(self.published_at, "release.published_at")
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: Any) -> ChannelRelease:
|
|
180
|
+
if not isinstance(data, dict) or set(data) != _RELEASE_KEYS:
|
|
181
|
+
raise _refuse(
|
|
182
|
+
"CATALOG_CHANNEL_RELEASE",
|
|
183
|
+
"release",
|
|
184
|
+
"must carry exactly the declared release fields",
|
|
185
|
+
)
|
|
186
|
+
return cls(
|
|
187
|
+
store_kind=data["store_kind"],
|
|
188
|
+
store_sha256=data["store_sha256"],
|
|
189
|
+
predecessor_store_sha256=data["predecessor_store_sha256"],
|
|
190
|
+
entry_count=data["entry_count"],
|
|
191
|
+
published_at=data["published_at"],
|
|
192
|
+
archive_object=data["archive_object"],
|
|
193
|
+
archive_sha256=data["archive_sha256"],
|
|
194
|
+
archive_bytes=data["archive_bytes"],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> dict[str, Any]:
|
|
198
|
+
return {
|
|
199
|
+
"archive_bytes": self.archive_bytes,
|
|
200
|
+
"archive_object": self.archive_object,
|
|
201
|
+
"archive_sha256": self.archive_sha256,
|
|
202
|
+
"entry_count": self.entry_count,
|
|
203
|
+
"predecessor_store_sha256": self.predecessor_store_sha256,
|
|
204
|
+
"published_at": self.published_at,
|
|
205
|
+
"store_kind": self.store_kind,
|
|
206
|
+
"store_sha256": self.store_sha256,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def descriptor(self) -> ReleaseDescriptor:
|
|
210
|
+
"""The release statement the archive itself must carry, as this manifest states it.
|
|
211
|
+
|
|
212
|
+
The installer re-reads that statement out of the archive and never takes this one for it;
|
|
213
|
+
this is what the manifest *claims*, kept in the manifest's own shape so a mismatch between
|
|
214
|
+
the channel and the bytes is a comparison rather than a surprise.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
return ReleaseDescriptor(
|
|
218
|
+
store_kind=self.store_kind,
|
|
219
|
+
store_sha256=self.store_sha256,
|
|
220
|
+
predecessor_store_sha256=self.predecessor_store_sha256,
|
|
221
|
+
entry_count=self.entry_count,
|
|
222
|
+
published_at=self.published_at,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class ChannelManifest:
|
|
228
|
+
"""One channel's statement of its current generation, identified by its own digest."""
|
|
229
|
+
|
|
230
|
+
channel: str
|
|
231
|
+
release: ChannelRelease
|
|
232
|
+
manifest_id: str
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
if not isinstance(self.channel, str) or _CHANNEL_NAME.fullmatch(self.channel) is None:
|
|
236
|
+
raise _refuse(
|
|
237
|
+
"CATALOG_CHANNEL_MANIFEST",
|
|
238
|
+
"channel",
|
|
239
|
+
"must be one lowercase channel name",
|
|
240
|
+
)
|
|
241
|
+
if len(self.channel) > MAX_CHANNEL_NAME:
|
|
242
|
+
raise _refuse("CATALOG_CHANNEL_MANIFEST", "channel", "channel name is too long")
|
|
243
|
+
if not isinstance(self.manifest_id, str) or _SELECTION_ID.fullmatch(self.manifest_id) is (
|
|
244
|
+
None
|
|
245
|
+
):
|
|
246
|
+
raise _refuse(
|
|
247
|
+
"CATALOG_CHANNEL_MANIFEST",
|
|
248
|
+
"manifest_id",
|
|
249
|
+
"must be one exact lowercase SHA-256",
|
|
250
|
+
)
|
|
251
|
+
if self.manifest_id != _manifest_identity(self.channel, self.release):
|
|
252
|
+
raise _refuse(
|
|
253
|
+
"CATALOG_CHANNEL_MANIFEST",
|
|
254
|
+
"manifest_id",
|
|
255
|
+
"does not bind the exact channel and release this manifest states",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def to_dict(self) -> dict[str, Any]:
|
|
259
|
+
return {
|
|
260
|
+
"channel": self.channel,
|
|
261
|
+
"manifest_id": self.manifest_id,
|
|
262
|
+
"release": self.release.to_dict(),
|
|
263
|
+
"schema_version": CHANNEL_MANIFEST_SCHEMA,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _manifest_identity(channel: str, release: ChannelRelease) -> str:
|
|
268
|
+
"""The manifest's identity: its own digest over everything it says except the identity."""
|
|
269
|
+
|
|
270
|
+
unsigned = {
|
|
271
|
+
"channel": channel,
|
|
272
|
+
"release": release.to_dict(),
|
|
273
|
+
"schema_version": CHANNEL_MANIFEST_SCHEMA,
|
|
274
|
+
}
|
|
275
|
+
return hashlib.sha256(canonical_json_bytes(unsigned)).hexdigest()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_channel_manifest(data: Any) -> ChannelManifest:
|
|
279
|
+
"""Parse one channel manifest from already-decoded JSON, recomputing its identity."""
|
|
280
|
+
|
|
281
|
+
if not isinstance(data, dict) or set(data) != _MANIFEST_KEYS:
|
|
282
|
+
raise _refuse(
|
|
283
|
+
"CATALOG_CHANNEL_MANIFEST",
|
|
284
|
+
"channel",
|
|
285
|
+
"must carry exactly the declared channel manifest fields",
|
|
286
|
+
)
|
|
287
|
+
if data["schema_version"] != CHANNEL_MANIFEST_SCHEMA:
|
|
288
|
+
raise _refuse(
|
|
289
|
+
"CATALOG_CHANNEL_MANIFEST",
|
|
290
|
+
"channel.schema_version",
|
|
291
|
+
f"must be {CHANNEL_MANIFEST_SCHEMA}",
|
|
292
|
+
)
|
|
293
|
+
return ChannelManifest(
|
|
294
|
+
channel=data["channel"],
|
|
295
|
+
release=ChannelRelease.from_dict(data["release"]),
|
|
296
|
+
manifest_id=data["manifest_id"],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_channel_manifest(
|
|
301
|
+
*,
|
|
302
|
+
channel: str,
|
|
303
|
+
store_kind: str,
|
|
304
|
+
store_sha256: str,
|
|
305
|
+
predecessor_store_sha256: str | None,
|
|
306
|
+
entry_count: int,
|
|
307
|
+
published_at: str,
|
|
308
|
+
archive_object: str,
|
|
309
|
+
archive_sha256: str,
|
|
310
|
+
archive_bytes: int,
|
|
311
|
+
) -> ChannelManifest:
|
|
312
|
+
"""Build the manifest for one already-packaged release. Publishing side only."""
|
|
313
|
+
|
|
314
|
+
release = ChannelRelease(
|
|
315
|
+
store_kind=store_kind,
|
|
316
|
+
store_sha256=store_sha256,
|
|
317
|
+
predecessor_store_sha256=predecessor_store_sha256,
|
|
318
|
+
entry_count=entry_count,
|
|
319
|
+
published_at=published_at,
|
|
320
|
+
archive_object=archive_object,
|
|
321
|
+
archive_sha256=archive_sha256,
|
|
322
|
+
archive_bytes=archive_bytes,
|
|
323
|
+
)
|
|
324
|
+
return ChannelManifest(
|
|
325
|
+
channel=channel, release=release, manifest_id=_manifest_identity(channel, release)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def channel_manifest_bytes(manifest: ChannelManifest) -> bytes:
|
|
330
|
+
"""The exact bytes to publish as ``channel.json``. Canonical, so the digest is reproducible."""
|
|
331
|
+
|
|
332
|
+
raw = canonical_json_bytes(manifest.to_dict())
|
|
333
|
+
if len(raw) > MAX_CHANNEL_MANIFEST_BYTES: # pragma: no cover - the shape is closed and small
|
|
334
|
+
raise _refuse(
|
|
335
|
+
"CATALOG_CHANNEL_MANIFEST", "channel", "channel manifest is larger than the bound"
|
|
336
|
+
)
|
|
337
|
+
return raw
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def verify_channel_manifest(raw: bytes, *, expected_manifest_sha256: str) -> ChannelManifest:
|
|
341
|
+
"""Believe one channel manifest only when its bytes are the pinned bytes.
|
|
342
|
+
|
|
343
|
+
The digest is checked *before* the JSON is parsed, so a manifest that is not the pinned one
|
|
344
|
+
never reaches the parser at all. That ordering is the whole point: parsing is the only part of
|
|
345
|
+
this path with any surface, and unpinned bytes must not get to use it.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
if not isinstance(raw, bytes):
|
|
349
|
+
raise _refuse("CATALOG_CHANNEL_MANIFEST", "channel", "manifest must be bytes")
|
|
350
|
+
if not is_digest(expected_manifest_sha256):
|
|
351
|
+
raise _refuse(
|
|
352
|
+
"CATALOG_CHANNEL_DIGEST_SPELLING",
|
|
353
|
+
"expected_manifest_sha256",
|
|
354
|
+
"must be exactly 64 lowercase hex characters",
|
|
355
|
+
)
|
|
356
|
+
if len(raw) > MAX_CHANNEL_MANIFEST_BYTES:
|
|
357
|
+
raise _refuse(
|
|
358
|
+
"CATALOG_CHANNEL_MANIFEST", "channel", "channel manifest is larger than the bound"
|
|
359
|
+
)
|
|
360
|
+
if hashlib.sha256(raw).hexdigest() != expected_manifest_sha256:
|
|
361
|
+
raise _refuse(
|
|
362
|
+
"CATALOG_CHANNEL_DIGEST",
|
|
363
|
+
"channel",
|
|
364
|
+
"channel manifest bytes do not match the pinned SHA-256",
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
data = parse_canonical_json(raw)
|
|
368
|
+
except CanonicalJSONError as error:
|
|
369
|
+
raise _refuse(
|
|
370
|
+
"CATALOG_CHANNEL_MANIFEST", "channel", "channel manifest is not canonical JSON"
|
|
371
|
+
) from error
|
|
372
|
+
return parse_channel_manifest(data)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass(frozen=True)
|
|
376
|
+
class CatalogChannelPin:
|
|
377
|
+
"""What one deployment declares about the catalogue it is supposed to be running.
|
|
378
|
+
|
|
379
|
+
Deliberately the same shape as ``hosted_bootstrap.WorkerPolicyBinding``: every field folded
|
|
380
|
+
into one recomputed ``selection_id``, so a partially-edited pin is a refusal rather than a
|
|
381
|
+
half-applied rollout.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
channel_url: str
|
|
385
|
+
manifest_sha256: str
|
|
386
|
+
selection_id: str
|
|
387
|
+
|
|
388
|
+
def __post_init__(self) -> None:
|
|
389
|
+
_require_channel_url(self.channel_url)
|
|
390
|
+
if not is_digest(self.manifest_sha256):
|
|
391
|
+
raise _refuse(
|
|
392
|
+
"CATALOG_CHANNEL_PIN",
|
|
393
|
+
"catalog_channel.manifest_sha256",
|
|
394
|
+
"must be one exact lowercase SHA-256",
|
|
395
|
+
)
|
|
396
|
+
if (
|
|
397
|
+
not isinstance(self.selection_id, str)
|
|
398
|
+
or _SELECTION_ID.fullmatch(self.selection_id) is None
|
|
399
|
+
):
|
|
400
|
+
raise _refuse(
|
|
401
|
+
"CATALOG_CHANNEL_PIN",
|
|
402
|
+
"catalog_channel.selection_id",
|
|
403
|
+
"must be one exact lowercase SHA-256",
|
|
404
|
+
)
|
|
405
|
+
if self.selection_id != _pin_identity(self.channel_url, self.manifest_sha256):
|
|
406
|
+
raise _refuse(
|
|
407
|
+
"CATALOG_CHANNEL_PIN",
|
|
408
|
+
"catalog_channel.selection_id",
|
|
409
|
+
"does not bind the exact channel address and manifest digest pinned here",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def to_dict(self) -> dict[str, Any]:
|
|
413
|
+
return {
|
|
414
|
+
"channel_url": self.channel_url,
|
|
415
|
+
"manifest_sha256": self.manifest_sha256,
|
|
416
|
+
"schema_version": CATALOG_CHANNEL_PIN_SCHEMA,
|
|
417
|
+
"selection_id": self.selection_id,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _pin_identity(channel_url: str, manifest_sha256: str) -> str:
|
|
422
|
+
unsigned = {
|
|
423
|
+
"channel_url": channel_url,
|
|
424
|
+
"manifest_sha256": manifest_sha256,
|
|
425
|
+
"schema_version": CATALOG_CHANNEL_PIN_SCHEMA,
|
|
426
|
+
}
|
|
427
|
+
return hashlib.sha256(canonical_json_bytes(unsigned)).hexdigest()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _require_channel_url(value: Any) -> str:
|
|
431
|
+
"""A channel address is one plain HTTPS URL ending in the manifest object, and nothing else.
|
|
432
|
+
|
|
433
|
+
No query, no fragment, no user information. A channel is a published location, not a signed
|
|
434
|
+
capability: if a deployment needs authorisation to read it, that travels in a header the
|
|
435
|
+
fetcher adds, never in the address a pin is compared by.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 2048:
|
|
439
|
+
raise _refuse("CATALOG_CHANNEL_PIN", "catalog_channel.channel_url", "is not a usable URL")
|
|
440
|
+
parsed = urlsplit(value)
|
|
441
|
+
if (
|
|
442
|
+
parsed.scheme != "https"
|
|
443
|
+
or not parsed.hostname
|
|
444
|
+
or parsed.username is not None
|
|
445
|
+
or parsed.password is not None
|
|
446
|
+
or parsed.query
|
|
447
|
+
or parsed.fragment
|
|
448
|
+
or not parsed.path.endswith("/" + CHANNEL_MANIFEST_OBJECT)
|
|
449
|
+
):
|
|
450
|
+
raise _refuse(
|
|
451
|
+
"CATALOG_CHANNEL_PIN",
|
|
452
|
+
"catalog_channel.channel_url",
|
|
453
|
+
"must be one https address, without user information, query or fragment, "
|
|
454
|
+
f"whose path ends in /{CHANNEL_MANIFEST_OBJECT}",
|
|
455
|
+
)
|
|
456
|
+
return value
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def parse_catalog_channel_pin(data: Any) -> CatalogChannelPin:
|
|
460
|
+
"""Parse the deploy-owned pin, recomputing the selection identity it states."""
|
|
461
|
+
|
|
462
|
+
if not isinstance(data, dict) or set(data) != _PIN_KEYS:
|
|
463
|
+
raise _refuse(
|
|
464
|
+
"CATALOG_CHANNEL_PIN",
|
|
465
|
+
"catalog_channel",
|
|
466
|
+
"must carry exactly the declared catalogue channel pin fields",
|
|
467
|
+
)
|
|
468
|
+
if data["schema_version"] != CATALOG_CHANNEL_PIN_SCHEMA:
|
|
469
|
+
raise _refuse(
|
|
470
|
+
"CATALOG_CHANNEL_PIN",
|
|
471
|
+
"catalog_channel.schema_version",
|
|
472
|
+
f"must be {CATALOG_CHANNEL_PIN_SCHEMA}",
|
|
473
|
+
)
|
|
474
|
+
return CatalogChannelPin(
|
|
475
|
+
channel_url=data["channel_url"],
|
|
476
|
+
manifest_sha256=data["manifest_sha256"],
|
|
477
|
+
selection_id=data["selection_id"],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_catalog_channel_pin(*, channel_url: str, manifest_sha256: str) -> CatalogChannelPin:
|
|
482
|
+
"""Build the pin a deployment should be configured with. Publishing side only."""
|
|
483
|
+
|
|
484
|
+
_require_channel_url(channel_url)
|
|
485
|
+
if not is_digest(manifest_sha256):
|
|
486
|
+
raise _refuse(
|
|
487
|
+
"CATALOG_CHANNEL_PIN",
|
|
488
|
+
"catalog_channel.manifest_sha256",
|
|
489
|
+
"must be one exact lowercase SHA-256",
|
|
490
|
+
)
|
|
491
|
+
return CatalogChannelPin(
|
|
492
|
+
channel_url=channel_url,
|
|
493
|
+
manifest_sha256=manifest_sha256,
|
|
494
|
+
selection_id=_pin_identity(channel_url, manifest_sha256),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def pin_from_environment(environment: Any) -> CatalogChannelPin | None:
|
|
499
|
+
"""Read the pin out of a worker's environment, or answer None when none is configured.
|
|
500
|
+
|
|
501
|
+
None means "this deployment does not host a catalogue", which is a legitimate configuration
|
|
502
|
+
and the one every deployment has before the channel is rolled out to it. It is emphatically
|
|
503
|
+
not the same as a *malformed* pin, which is a refusal: a deployment that tried to say
|
|
504
|
+
something about its catalogue and said it wrongly must not start as though it had said
|
|
505
|
+
nothing.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
raw = environment.get(CATALOG_CHANNEL_PIN_ENV)
|
|
509
|
+
if raw is None:
|
|
510
|
+
return None
|
|
511
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
512
|
+
raise _refuse(
|
|
513
|
+
"CATALOG_CHANNEL_PIN",
|
|
514
|
+
CATALOG_CHANNEL_PIN_ENV,
|
|
515
|
+
"is present but empty; unset it to run without a hosted catalogue",
|
|
516
|
+
)
|
|
517
|
+
try:
|
|
518
|
+
data = parse_json(raw)
|
|
519
|
+
except CanonicalJSONError as error:
|
|
520
|
+
raise _refuse(
|
|
521
|
+
"CATALOG_CHANNEL_PIN", CATALOG_CHANNEL_PIN_ENV, "is not one JSON object"
|
|
522
|
+
) from error
|
|
523
|
+
return parse_catalog_channel_pin(data)
|