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,625 @@
|
|
|
1
|
+
"""One-shot Cloud Run wrapper for the catalogue harvest -> author -> publish pipeline.
|
|
2
|
+
|
|
3
|
+
WHAT THIS IS FOR. ``catalog-harvest``, ``catalog-author`` and ``catalog-publish`` are the three
|
|
4
|
+
provider-scale mutations that produce a sealed generation. They were built to be driven by an
|
|
5
|
+
operator at a terminal, resumed by hand, and paced by hand -- and the data.gov sweep proved what
|
|
6
|
+
that costs: thirty-six hours of a laptop staying awake, driven by a shell loop, with the key in
|
|
7
|
+
somebody's shell history. Under ADR 0021 the pipeline belongs where every other lane went: a
|
|
8
|
+
backend job, executed by Studio, whose configuration is deploy-owned and whose credential comes
|
|
9
|
+
from Secret Manager at run time and is never baked into an image.
|
|
10
|
+
|
|
11
|
+
WHAT THIS IS NOT. It is not a fourth catalogue command and it adds no new pipeline behaviour. It
|
|
12
|
+
resolves one stage, builds that stage's exact argv, runs that stage's own ``run`` function, and
|
|
13
|
+
publishes the receipt. Resume, pacing, bounds, the typed-incomplete receipt discipline and every
|
|
14
|
+
refusal belong to the stage and are untouched here -- which is the point: an operator running the
|
|
15
|
+
stage by hand and this job running it must produce the same receipt, or the hosted pipeline is a
|
|
16
|
+
second implementation of the pipeline.
|
|
17
|
+
|
|
18
|
+
WHAT IT DOES ADD, and why each is here rather than in the stage:
|
|
19
|
+
|
|
20
|
+
* **The key file.** The harness key boundary requires a regular file owned by the running user
|
|
21
|
+
with no group or other permission bits (``acquisition.http._require_datagov_key_stat``). A
|
|
22
|
+
Cloud Run secret *volume* is none of those -- it is root-owned and world-readable -- so a
|
|
23
|
+
mounted secret cannot be handed to ``--api-key-file`` at all. The secret therefore arrives as an
|
|
24
|
+
environment variable and this wrapper materialises it into a private ``0400`` file, hands the
|
|
25
|
+
path to the stage, and removes the file afterwards whether the stage succeeded or refused. The
|
|
26
|
+
stage never learns where the secret came from, and no refusal in this module quotes a byte of
|
|
27
|
+
it.
|
|
28
|
+
* **The pacing floor.** ``--min-request-interval-ms`` is an argument, which means a job spec could
|
|
29
|
+
set it to zero. Against a keyed provider lane that is not a configuration choice, it is a way to
|
|
30
|
+
get the key blocked, so the floor is enforced here and a spec below it is refused rather than
|
|
31
|
+
clamped. Refused rather than clamped because a spec that asked for something it may not have is
|
|
32
|
+
a mistake somebody should see.
|
|
33
|
+
* **Receipt publication.** A receipt on a container filesystem is a receipt nobody has. Each stage
|
|
34
|
+
writes its receipt where the spec's ``receipt_path`` says, and this wrapper puts that exact file
|
|
35
|
+
and this job's own execution record into the release bucket, create-only, so a retried execution
|
|
36
|
+
cannot overwrite the first attempt's evidence.
|
|
37
|
+
* **The verdict.** The execution record says what the stage answered -- ``stage_status`` is the
|
|
38
|
+
stage's own word, ``status`` is ``complete`` only when the stage's own ``exit_code`` says so --
|
|
39
|
+
and the job then exits with that code. Both, because the two are seen by different readers: the
|
|
40
|
+
receipt is what survives in the bucket for a person or a later stage, and the exit status is the
|
|
41
|
+
*only* thing Cloud Run itself can see. An execution's console row, ``gcloud run jobs executions
|
|
42
|
+
list`` and every alert built on them read the exit status and nothing about the receipt, so a
|
|
43
|
+
stage that stopped and exited zero is a green execution. That is not hypothetical: the data.gov
|
|
44
|
+
sweep of 2026-08-22 stalled for twenty-eight hours against a page it could not fetch, and the
|
|
45
|
+
only reason anybody noticed is that it was running on a laptop somebody was watching. The
|
|
46
|
+
verdict is read from the stage's ``exit_code`` rather than from a table of statuses kept here,
|
|
47
|
+
because the same word means different things to different stages and a second copy of that
|
|
48
|
+
judgement is a copy that goes stale.
|
|
49
|
+
|
|
50
|
+
``--dry-run`` resolves everything and runs nothing: it parses the spec, builds and validates the
|
|
51
|
+
argv against the stage's real parser, checks the pacing floor, reports whether the credential and
|
|
52
|
+
the bucket are configured, and exits. It never reads the secret, never opens a socket, and never
|
|
53
|
+
writes to the work root -- so it is safe to execute against production configuration, which is
|
|
54
|
+
what makes it useful as a deployment check.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import argparse
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import ssl
|
|
63
|
+
import stat
|
|
64
|
+
import sys
|
|
65
|
+
from collections.abc import Mapping, Sequence
|
|
66
|
+
from datetime import UTC, datetime
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Any, NoReturn
|
|
69
|
+
from urllib.error import HTTPError
|
|
70
|
+
from urllib.parse import quote, urlencode
|
|
71
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
72
|
+
|
|
73
|
+
CATALOG_JOB_SPEC_SCHEMA = "mostlyright-catalog-job.v1"
|
|
74
|
+
CATALOG_JOB_RECEIPT_SCHEMA = "mostlyright-catalog-job-receipt.v1"
|
|
75
|
+
|
|
76
|
+
#: The deploy-owned job specification, as one JSON object.
|
|
77
|
+
JOB_SPEC_ENV = "MOSTLYRIGHT_CATALOG_JOB_SPEC_JSON"
|
|
78
|
+
|
|
79
|
+
#: The provider key, delivered by Secret Manager as an environment variable. Never a file mount:
|
|
80
|
+
#: see the module docstring for why a mounted secret cannot satisfy the key boundary.
|
|
81
|
+
DATAGOV_KEY_ENV = "MOSTLYRIGHT_CATALOG_DATAGOV_API_KEY"
|
|
82
|
+
|
|
83
|
+
#: Where receipts and release archives are published.
|
|
84
|
+
RELEASES_BUCKET_ENV = "MOSTLYRIGHT_CATALOG_RELEASES_BUCKET"
|
|
85
|
+
|
|
86
|
+
#: The three stages this job can execute, and the command module each is.
|
|
87
|
+
STAGES = {
|
|
88
|
+
"harvest": "catalog_harvest",
|
|
89
|
+
"author": "catalog_author",
|
|
90
|
+
"publish": "catalog_publish",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#: The keyed data.gov lane's floor, in milliseconds between requests. Proven by running the sweep:
|
|
94
|
+
#: api.data.gov's free tier is 1000 requests an hour, and 3700 ms is the interval the completed
|
|
95
|
+
#: 155-page sweep ran at without a single refusal.
|
|
96
|
+
DATAGOV_MIN_REQUEST_INTERVAL_MS = 3700
|
|
97
|
+
|
|
98
|
+
#: The largest page the keyed lane asks for. Above this the provider truncates silently.
|
|
99
|
+
DATAGOV_MAX_PER_PAGE = 1000
|
|
100
|
+
|
|
101
|
+
#: Arguments the spec may not state, because this wrapper owns them.
|
|
102
|
+
RESERVED_ARGUMENTS = ("--api-key-file", "--json")
|
|
103
|
+
|
|
104
|
+
MAX_JOB_SPEC_BYTES = 64 * 1024
|
|
105
|
+
MAX_KEY_BYTES = 4096
|
|
106
|
+
MAX_RECEIPT_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
107
|
+
UPLOAD_TIMEOUT_SECONDS = 300.0
|
|
108
|
+
METADATA_TOKEN_URL = (
|
|
109
|
+
"http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token"
|
|
110
|
+
)
|
|
111
|
+
STORAGE_WRITE_SCOPE = "https://www.googleapis.com/auth/devstorage.read_write"
|
|
112
|
+
|
|
113
|
+
_SPEC_KEYS = frozenset(
|
|
114
|
+
{"arguments", "receipt_object_prefix", "receipt_path", "run_id", "schema_version", "stage"}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CatalogJobError(RuntimeError):
|
|
119
|
+
"""A typed, credential-safe refusal. ``detail`` never carries the key or its bytes."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
122
|
+
self.code = code
|
|
123
|
+
self.detail = detail
|
|
124
|
+
super().__init__(f"{code}: {detail}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------------------------
|
|
128
|
+
# The job specification
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class JobSpec:
|
|
132
|
+
"""One resolved catalogue job: which stage, which arguments, and where the receipt goes."""
|
|
133
|
+
|
|
134
|
+
__slots__ = ("arguments", "receipt_object_prefix", "receipt_path", "run_id", "stage")
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
stage: str,
|
|
140
|
+
run_id: str,
|
|
141
|
+
arguments: tuple[str, ...],
|
|
142
|
+
receipt_path: str,
|
|
143
|
+
receipt_object_prefix: str,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.stage = stage
|
|
146
|
+
self.run_id = run_id
|
|
147
|
+
self.arguments = arguments
|
|
148
|
+
self.receipt_path = receipt_path
|
|
149
|
+
self.receipt_object_prefix = receipt_object_prefix
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"arguments": list(self.arguments),
|
|
154
|
+
"receipt_object_prefix": self.receipt_object_prefix,
|
|
155
|
+
"receipt_path": self.receipt_path,
|
|
156
|
+
"run_id": self.run_id,
|
|
157
|
+
"schema_version": CATALOG_JOB_SPEC_SCHEMA,
|
|
158
|
+
"stage": self.stage,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_run_id(value: Any) -> str:
|
|
163
|
+
if (
|
|
164
|
+
not isinstance(value, str)
|
|
165
|
+
or not 1 <= len(value) <= 128
|
|
166
|
+
or not all(character.isalnum() or character in "-_" for character in value)
|
|
167
|
+
):
|
|
168
|
+
raise CatalogJobError(
|
|
169
|
+
"CATALOG_JOB_SPEC", "run_id must be one short alphanumeric, dash or underscore label"
|
|
170
|
+
)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _require_object_prefix(value: Any) -> str:
|
|
175
|
+
if (
|
|
176
|
+
not isinstance(value, str)
|
|
177
|
+
or not 1 <= len(value) <= 256
|
|
178
|
+
or not value.endswith("/")
|
|
179
|
+
or value.startswith("/")
|
|
180
|
+
or ".." in value
|
|
181
|
+
or not all(character.isalnum() or character in "-_./" for character in value)
|
|
182
|
+
):
|
|
183
|
+
raise CatalogJobError(
|
|
184
|
+
"CATALOG_JOB_SPEC",
|
|
185
|
+
"receipt_object_prefix must be one relative object prefix ending in a slash",
|
|
186
|
+
)
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_job_spec(data: Any) -> JobSpec:
|
|
191
|
+
"""Parse the deploy-owned specification, refusing anything it does not exactly declare."""
|
|
192
|
+
|
|
193
|
+
if not isinstance(data, dict) or set(data) != _SPEC_KEYS:
|
|
194
|
+
raise CatalogJobError(
|
|
195
|
+
"CATALOG_JOB_SPEC", "the job specification must carry exactly the declared fields"
|
|
196
|
+
)
|
|
197
|
+
if data["schema_version"] != CATALOG_JOB_SPEC_SCHEMA:
|
|
198
|
+
raise CatalogJobError(
|
|
199
|
+
"CATALOG_JOB_SPEC", f"the job specification schema must be {CATALOG_JOB_SPEC_SCHEMA}"
|
|
200
|
+
)
|
|
201
|
+
stage = data["stage"]
|
|
202
|
+
if stage not in STAGES:
|
|
203
|
+
raise CatalogJobError("CATALOG_JOB_SPEC", "stage must be one of harvest, author or publish")
|
|
204
|
+
arguments = data["arguments"]
|
|
205
|
+
if not isinstance(arguments, list) or not all(
|
|
206
|
+
isinstance(item, str) and item and "\x00" not in item for item in arguments
|
|
207
|
+
):
|
|
208
|
+
raise CatalogJobError("CATALOG_JOB_SPEC", "arguments must be a list of nonempty strings")
|
|
209
|
+
for reserved in RESERVED_ARGUMENTS:
|
|
210
|
+
if reserved in arguments:
|
|
211
|
+
raise CatalogJobError(
|
|
212
|
+
"CATALOG_JOB_SPEC",
|
|
213
|
+
f"{reserved} is supplied by the job wrapper and may not be stated in the spec",
|
|
214
|
+
)
|
|
215
|
+
receipt_path = data["receipt_path"]
|
|
216
|
+
if not isinstance(receipt_path, str) or not receipt_path.startswith("/"):
|
|
217
|
+
raise CatalogJobError(
|
|
218
|
+
"CATALOG_JOB_SPEC", "receipt_path must be one absolute path in the container"
|
|
219
|
+
)
|
|
220
|
+
return JobSpec(
|
|
221
|
+
stage=stage,
|
|
222
|
+
run_id=_require_run_id(data["run_id"]),
|
|
223
|
+
arguments=tuple(arguments),
|
|
224
|
+
receipt_path=receipt_path,
|
|
225
|
+
receipt_object_prefix=_require_object_prefix(data["receipt_object_prefix"]),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def job_spec_from_environment(environment: Mapping[str, str]) -> JobSpec:
|
|
230
|
+
raw = environment.get(JOB_SPEC_ENV)
|
|
231
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
232
|
+
raise CatalogJobError(
|
|
233
|
+
"CATALOG_JOB_SPEC", f"{JOB_SPEC_ENV} is not set on this job's template"
|
|
234
|
+
)
|
|
235
|
+
if len(raw.encode("utf-8")) > MAX_JOB_SPEC_BYTES:
|
|
236
|
+
raise CatalogJobError("CATALOG_JOB_SPEC", f"{JOB_SPEC_ENV} is larger than the bound")
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(raw)
|
|
239
|
+
except ValueError as error:
|
|
240
|
+
raise CatalogJobError("CATALOG_JOB_SPEC", f"{JOB_SPEC_ENV} is not one JSON object") from (
|
|
241
|
+
error
|
|
242
|
+
)
|
|
243
|
+
return parse_job_spec(data)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------------------------
|
|
247
|
+
# Pacing
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def require_pacing_floor(stage: str, namespace: argparse.Namespace) -> None:
|
|
251
|
+
"""Refuse a harvest that asks to go faster than the keyed lane may be driven.
|
|
252
|
+
|
|
253
|
+
Only the harvest stage reaches the provider; author and publish reach no network at all, so
|
|
254
|
+
there is nothing to pace and asserting a floor on them would be theatre.
|
|
255
|
+
|
|
256
|
+
A ``--preflight-only`` harvest is the same kind of theatre and is exempt for the same reason.
|
|
257
|
+
It reads the key file, makes no request, and writes nothing, and ``catalog_harvest`` forbids
|
|
258
|
+
``--min-request-interval-ms`` in that form -- so demanding one here made the preflight spec
|
|
259
|
+
unsatisfiable: stating the flag is refused by the stage, and omitting it was refused here.
|
|
260
|
+
The floor belongs to the requests a sweep makes, and a preflight makes none.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
if stage != "harvest":
|
|
264
|
+
return
|
|
265
|
+
if getattr(namespace, "preflight_only", False):
|
|
266
|
+
return
|
|
267
|
+
interval = getattr(namespace, "min_request_interval_ms", None)
|
|
268
|
+
if interval is None or int(interval) < DATAGOV_MIN_REQUEST_INTERVAL_MS:
|
|
269
|
+
raise CatalogJobError(
|
|
270
|
+
"CATALOG_JOB_PACING",
|
|
271
|
+
"--min-request-interval-ms must be at least "
|
|
272
|
+
f"{DATAGOV_MIN_REQUEST_INTERVAL_MS} for the keyed provider lane",
|
|
273
|
+
)
|
|
274
|
+
per_page = getattr(namespace, "per_page", None)
|
|
275
|
+
if per_page is not None and int(per_page) > DATAGOV_MAX_PER_PAGE:
|
|
276
|
+
raise CatalogJobError(
|
|
277
|
+
"CATALOG_JOB_PACING", f"--per-page must not exceed {DATAGOV_MAX_PER_PAGE}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def resolve_stage(spec: JobSpec, extra: Sequence[str] = ()) -> tuple[Any, argparse.Namespace]:
|
|
282
|
+
"""Load the stage's own command descriptor and parse the spec's argv with its own parser.
|
|
283
|
+
|
|
284
|
+
Parsing with the real parser rather than a copy of it is what makes ``--dry-run`` worth
|
|
285
|
+
running: a spec that names an argument the stage does not have fails here, in a deployment
|
|
286
|
+
check, rather than an hour into an execution.
|
|
287
|
+
|
|
288
|
+
Argparse only carries the rules a flag states about itself, and the harvest's rules are
|
|
289
|
+
modal -- ``--output-root`` is required by a sweep and forbidden by a preflight -- so argparse
|
|
290
|
+
accepting a spec never meant the stage would. The stage's own ``validate`` is asked here,
|
|
291
|
+
on the path both ``--dry-run`` and a real execution take, so the two cannot disagree about
|
|
292
|
+
which specs are runnable and a spec that cannot run cannot sit deployed looking healthy.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
from mostlyright.data_harness.sources.contracts import SourceContractError
|
|
296
|
+
from mostlyright.data_harness.ux.commands import discovered_commands
|
|
297
|
+
|
|
298
|
+
module = STAGES[spec.stage]
|
|
299
|
+
descriptor = next(
|
|
300
|
+
(item for item in discovered_commands() if item.implementation_module == module), None
|
|
301
|
+
)
|
|
302
|
+
if descriptor is None: # pragma: no cover - the descriptor tuple is closed and tested
|
|
303
|
+
raise CatalogJobError("CATALOG_JOB_SPEC", f"no command is registered for {spec.stage}")
|
|
304
|
+
resolved = descriptor.resolve()
|
|
305
|
+
parser = argparse.ArgumentParser(prog=f"mr-data {resolved.name}", add_help=False)
|
|
306
|
+
resolved.add_arguments(parser)
|
|
307
|
+
try:
|
|
308
|
+
namespace = parser.parse_args([*spec.arguments, *extra])
|
|
309
|
+
except SystemExit as error:
|
|
310
|
+
raise CatalogJobError(
|
|
311
|
+
"CATALOG_JOB_SPEC", f"the {spec.stage} arguments are not valid for that command"
|
|
312
|
+
) from error
|
|
313
|
+
require_pacing_floor(spec.stage, namespace)
|
|
314
|
+
if resolved.validate is not None:
|
|
315
|
+
try:
|
|
316
|
+
resolved.validate(namespace)
|
|
317
|
+
except SourceContractError as error:
|
|
318
|
+
raise CatalogJobError(
|
|
319
|
+
"CATALOG_JOB_SPEC",
|
|
320
|
+
f"the {spec.stage} arguments are not one this stage would run: {error}",
|
|
321
|
+
) from error
|
|
322
|
+
return resolved, namespace
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# --------------------------------------------------------------------------------------------
|
|
326
|
+
# The credential
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def materialize_key_file(environment: Mapping[str, str], directory: Path) -> Path:
|
|
330
|
+
"""Write the Secret Manager key into a private file the key boundary will accept.
|
|
331
|
+
|
|
332
|
+
Created with ``O_EXCL`` at mode 0600 and then narrowed to 0400: the boundary requires no group
|
|
333
|
+
or other bits and a link count of exactly one, and creating it exclusively is what makes the
|
|
334
|
+
link count a fact rather than a hope.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
raw = environment.get(DATAGOV_KEY_ENV)
|
|
338
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
339
|
+
raise CatalogJobError(
|
|
340
|
+
"CATALOG_JOB_CREDENTIAL",
|
|
341
|
+
f"{DATAGOV_KEY_ENV} is not set; the keyed provider lane cannot run without it",
|
|
342
|
+
)
|
|
343
|
+
key = raw.strip()
|
|
344
|
+
if len(key) > MAX_KEY_BYTES or not all(0x21 <= ord(character) <= 0x7E for character in key):
|
|
345
|
+
raise CatalogJobError(
|
|
346
|
+
"CATALOG_JOB_CREDENTIAL", "the provider key is not one printable ASCII line"
|
|
347
|
+
)
|
|
348
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
os.chmod(directory, 0o700)
|
|
350
|
+
path = directory / "provider-api-key"
|
|
351
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
352
|
+
descriptor = os.open(path, flags, 0o600)
|
|
353
|
+
try:
|
|
354
|
+
os.write(descriptor, key.encode("ascii"))
|
|
355
|
+
os.fchmod(descriptor, stat.S_IRUSR)
|
|
356
|
+
finally:
|
|
357
|
+
os.close(descriptor)
|
|
358
|
+
return path
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _discard_key_file(path: Path) -> None:
|
|
362
|
+
try:
|
|
363
|
+
os.chmod(path, stat.S_IWUSR)
|
|
364
|
+
os.unlink(path)
|
|
365
|
+
except OSError: # pragma: no cover - a removed file is the outcome either way
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --------------------------------------------------------------------------------------------
|
|
370
|
+
# Publishing the receipt
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
374
|
+
def redirect_request(
|
|
375
|
+
self,
|
|
376
|
+
request: Request,
|
|
377
|
+
file_pointer: Any,
|
|
378
|
+
code: int,
|
|
379
|
+
message: str,
|
|
380
|
+
headers: Any,
|
|
381
|
+
new_url: str,
|
|
382
|
+
) -> None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _opener() -> Any:
|
|
387
|
+
return build_opener(
|
|
388
|
+
ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), _NoRedirect()
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _access_token() -> str:
|
|
393
|
+
request = Request(
|
|
394
|
+
METADATA_TOKEN_URL + "?" + urlencode({"scopes": STORAGE_WRITE_SCOPE}),
|
|
395
|
+
headers={"Metadata-Flavor": "Google"},
|
|
396
|
+
method="GET",
|
|
397
|
+
)
|
|
398
|
+
try:
|
|
399
|
+
with _opener().open(request, timeout=30.0) as response:
|
|
400
|
+
payload = json.loads(response.read(16 * 1024))
|
|
401
|
+
except (HTTPError, OSError, ValueError) as error:
|
|
402
|
+
raise CatalogJobError(
|
|
403
|
+
"CATALOG_JOB_IDENTITY", "this job could not obtain its own access token"
|
|
404
|
+
) from error
|
|
405
|
+
token = payload.get("access_token") if isinstance(payload, dict) else None
|
|
406
|
+
if not isinstance(token, str) or not token:
|
|
407
|
+
raise CatalogJobError(
|
|
408
|
+
"CATALOG_JOB_IDENTITY", "the metadata server returned no access token"
|
|
409
|
+
)
|
|
410
|
+
return token
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def upload_object(bucket: str, name: str, body: bytes, *, content_type: str) -> str:
|
|
414
|
+
"""Write one object into the release bucket, and answer the object's name.
|
|
415
|
+
|
|
416
|
+
``ifGenerationMatch=0`` makes the write create-only: a receipt is evidence about one execution
|
|
417
|
+
and must never overwrite evidence about another. A retried execution therefore fails to write
|
|
418
|
+
rather than silently replacing the first attempt's record.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
if len(body) > MAX_RECEIPT_UPLOAD_BYTES:
|
|
422
|
+
raise CatalogJobError("CATALOG_JOB_RECEIPT", "the receipt is larger than the upload bound")
|
|
423
|
+
query = urlencode({"uploadType": "media", "name": name, "ifGenerationMatch": "0"})
|
|
424
|
+
url = f"https://storage.googleapis.com/upload/storage/v1/b/{quote(bucket, safe='')}/o?{query}"
|
|
425
|
+
request = Request(
|
|
426
|
+
url,
|
|
427
|
+
data=body,
|
|
428
|
+
headers={
|
|
429
|
+
"Authorization": "Bearer " + _access_token(),
|
|
430
|
+
"Content-Type": content_type,
|
|
431
|
+
"Content-Length": str(len(body)),
|
|
432
|
+
},
|
|
433
|
+
method="POST",
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
with _opener().open(request, timeout=UPLOAD_TIMEOUT_SECONDS) as response:
|
|
437
|
+
if int(response.status) not in (200, 201):
|
|
438
|
+
raise CatalogJobError(
|
|
439
|
+
"CATALOG_JOB_RECEIPT", f"the bucket answered {response.status}"
|
|
440
|
+
)
|
|
441
|
+
except HTTPError as error:
|
|
442
|
+
raise CatalogJobError(
|
|
443
|
+
"CATALOG_JOB_RECEIPT", f"the bucket answered {error.code} for {name}"
|
|
444
|
+
) from error
|
|
445
|
+
except OSError as error:
|
|
446
|
+
raise CatalogJobError(
|
|
447
|
+
"CATALOG_JOB_RECEIPT", f"the receipt for {name} was not written"
|
|
448
|
+
) from error
|
|
449
|
+
return name
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# --------------------------------------------------------------------------------------------
|
|
453
|
+
# The job
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _now() -> str:
|
|
457
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
#: What ``--dry-run`` puts where the credential path will go. The harvest stage requires the
|
|
461
|
+
#: argument to be present, and a dry run must not read the secret to satisfy a parser, so it
|
|
462
|
+
#: parses a path that is never opened and is obviously not a real one if it ever leaks into a log.
|
|
463
|
+
DRY_RUN_KEY_PATH = "/nonexistent/supplied-at-run-time"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _dry_run(spec: JobSpec, environment: Mapping[str, str]) -> dict[str, Any]:
|
|
467
|
+
extra = ("--api-key-file", DRY_RUN_KEY_PATH) if spec.stage == "harvest" else ()
|
|
468
|
+
resolved, namespace = resolve_stage(spec, extra)
|
|
469
|
+
return {
|
|
470
|
+
"schema_version": CATALOG_JOB_RECEIPT_SCHEMA,
|
|
471
|
+
"status": "dry-run",
|
|
472
|
+
"stage": spec.stage,
|
|
473
|
+
"run_id": spec.run_id,
|
|
474
|
+
"command": resolved.name,
|
|
475
|
+
"arguments": list(spec.arguments),
|
|
476
|
+
"resume": bool(getattr(namespace, "resume", False)),
|
|
477
|
+
"min_request_interval_ms": getattr(namespace, "min_request_interval_ms", None),
|
|
478
|
+
"receipt_path": spec.receipt_path,
|
|
479
|
+
"receipt_object_prefix": spec.receipt_object_prefix,
|
|
480
|
+
"credential_configured": bool(environment.get(DATAGOV_KEY_ENV)),
|
|
481
|
+
"releases_bucket": environment.get(RELEASES_BUCKET_ENV),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def stage_verdict(resolved: Any, payload: Mapping[str, Any]) -> tuple[str | None, int]:
|
|
486
|
+
"""Read the stage's own answer: the word it used, and whether that word is a success.
|
|
487
|
+
|
|
488
|
+
The verdict is *derived* from the stage's ``exit_code``, never from a table of acceptable
|
|
489
|
+
statuses kept here. The stages already own this judgement -- ``catalog-harvest`` knows that
|
|
490
|
+
``harvest_incomplete`` is not a finished sweep and ``harvest_preflight_accepted`` is not a
|
|
491
|
+
failure -- and a second copy of it in this wrapper is a copy that goes stale. The day a stage
|
|
492
|
+
grows a fourth outcome, a table would not recognise it and would wave it through as complete;
|
|
493
|
+
deriving means an unrecognised answer fails closed instead.
|
|
494
|
+
|
|
495
|
+
A stage that declares no ``exit_code`` succeeded if it produced a payload at all, which is the
|
|
496
|
+
rule ``CommandSpec`` already states for the terminal.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
stage_status = payload.get("status")
|
|
500
|
+
exit_code = 0 if resolved.exit_code is None else int(resolved.exit_code(payload))
|
|
501
|
+
return (stage_status if isinstance(stage_status, str) else None), exit_code
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _read_stage_receipt(path: Path) -> bytes | None:
|
|
505
|
+
"""The stage's own receipt file, or None when the stage did not write one there.
|
|
506
|
+
|
|
507
|
+
Bounded, and absence is not an error: ``--preflight-only`` writes no receipt, and a stage that
|
|
508
|
+
refused before installing one has nothing to publish. What is refused is a receipt too large
|
|
509
|
+
to be one, which is a fact about the file rather than about the stage.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
raw = path.read_bytes()
|
|
514
|
+
except OSError:
|
|
515
|
+
return None
|
|
516
|
+
if len(raw) > MAX_RECEIPT_UPLOAD_BYTES:
|
|
517
|
+
raise CatalogJobError(
|
|
518
|
+
"CATALOG_JOB_RECEIPT", f"the stage receipt at {path} is larger than the upload bound"
|
|
519
|
+
)
|
|
520
|
+
return raw
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def run_job(
|
|
524
|
+
environment: Mapping[str, str] | None = None, *, dry_run: bool = False
|
|
525
|
+
) -> dict[str, Any]:
|
|
526
|
+
"""Execute one stage of the catalogue pipeline, or resolve it and stop."""
|
|
527
|
+
|
|
528
|
+
env = os.environ if environment is None else environment
|
|
529
|
+
spec = job_spec_from_environment(env)
|
|
530
|
+
if dry_run:
|
|
531
|
+
return _dry_run(spec, env)
|
|
532
|
+
bucket = env.get(RELEASES_BUCKET_ENV)
|
|
533
|
+
if not bucket:
|
|
534
|
+
raise CatalogJobError(
|
|
535
|
+
"CATALOG_JOB_SPEC", f"{RELEASES_BUCKET_ENV} is not set on this job's template"
|
|
536
|
+
)
|
|
537
|
+
key_directory = Path(env.get("MOSTLYRIGHT_CATALOG_JOB_KEY_DIR") or "/tmp/catalog-job-key")
|
|
538
|
+
key_path: Path | None = None
|
|
539
|
+
extra: tuple[str, ...] = ()
|
|
540
|
+
if spec.stage == "harvest":
|
|
541
|
+
key_path = materialize_key_file(env, key_directory)
|
|
542
|
+
extra = ("--api-key-file", str(key_path))
|
|
543
|
+
try:
|
|
544
|
+
resolved, namespace = resolve_stage(spec, extra)
|
|
545
|
+
started_at = _now()
|
|
546
|
+
payload = resolved.run(namespace)
|
|
547
|
+
finally:
|
|
548
|
+
if key_path is not None:
|
|
549
|
+
_discard_key_file(key_path)
|
|
550
|
+
stage_status, exit_code = stage_verdict(resolved, payload)
|
|
551
|
+
receipt = {
|
|
552
|
+
"schema_version": CATALOG_JOB_RECEIPT_SCHEMA,
|
|
553
|
+
"status": "complete" if exit_code == 0 else "incomplete",
|
|
554
|
+
"stage_status": stage_status,
|
|
555
|
+
"exit_code": exit_code,
|
|
556
|
+
"stage": spec.stage,
|
|
557
|
+
"run_id": spec.run_id,
|
|
558
|
+
"command": resolved.name,
|
|
559
|
+
"arguments": list(spec.arguments),
|
|
560
|
+
"started_at": started_at,
|
|
561
|
+
"finished_at": _now(),
|
|
562
|
+
"stage_payload": payload,
|
|
563
|
+
}
|
|
564
|
+
published = [
|
|
565
|
+
upload_object(
|
|
566
|
+
bucket,
|
|
567
|
+
f"{spec.receipt_object_prefix}{spec.run_id}/{spec.stage}.json",
|
|
568
|
+
json.dumps(receipt, sort_keys=True, ensure_ascii=False).encode("utf-8"),
|
|
569
|
+
content_type="application/json",
|
|
570
|
+
)
|
|
571
|
+
]
|
|
572
|
+
stage_receipt = _read_stage_receipt(Path(spec.receipt_path))
|
|
573
|
+
if stage_receipt is not None:
|
|
574
|
+
# The stage's own receipt, not a summary of it. It is the resume journal and the
|
|
575
|
+
# authoritative evidence for this stage, and a receipt that exists only on a container
|
|
576
|
+
# filesystem is a receipt nobody has once the execution ends.
|
|
577
|
+
published.append(
|
|
578
|
+
upload_object(
|
|
579
|
+
bucket,
|
|
580
|
+
f"{spec.receipt_object_prefix}{spec.run_id}/{spec.stage}-receipt.json",
|
|
581
|
+
stage_receipt,
|
|
582
|
+
content_type="application/json",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
receipt["published_objects"] = published
|
|
586
|
+
return receipt
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _fatal(error: Exception) -> NoReturn:
|
|
590
|
+
code = getattr(error, "code", None) or "CATALOG_JOB_FAILED"
|
|
591
|
+
detail = getattr(error, "detail", None) or type(error).__name__
|
|
592
|
+
sys.stderr.write(
|
|
593
|
+
json.dumps({"status": "failed", "code": code, "detail": detail}, sort_keys=True) + "\n"
|
|
594
|
+
)
|
|
595
|
+
raise SystemExit(1)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def catalog_job_main(argv: Sequence[str] | None = None) -> int:
|
|
599
|
+
parser = argparse.ArgumentParser(
|
|
600
|
+
prog="mr-data-catalog-job",
|
|
601
|
+
description="Run one stage of the catalogue pipeline as a backend job.",
|
|
602
|
+
)
|
|
603
|
+
parser.add_argument(
|
|
604
|
+
"--dry-run",
|
|
605
|
+
action="store_true",
|
|
606
|
+
help="resolve the spec and the stage's arguments, run nothing, and print the plan",
|
|
607
|
+
)
|
|
608
|
+
args = parser.parse_args(argv)
|
|
609
|
+
try:
|
|
610
|
+
payload = run_job(dry_run=args.dry_run)
|
|
611
|
+
except Exception as error:
|
|
612
|
+
_fatal(error)
|
|
613
|
+
sys.stdout.write(json.dumps(payload, sort_keys=True, ensure_ascii=False) + "\n")
|
|
614
|
+
# The receipt is already written and published by here, and only then does the job report the
|
|
615
|
+
# stage's verdict as its own exit status. Cloud Run sees nothing else: an execution's console
|
|
616
|
+
# row, `gcloud run jobs executions list`, and every alert built on either read the exit status
|
|
617
|
+
# and nothing about the receipt. A sweep that stopped and exited zero is a green execution, so
|
|
618
|
+
# the stall is invisible exactly when somebody most needs to see it. Ordering matters as much
|
|
619
|
+
# as the code: an incomplete harvest's receipt is its resume journal, so it is published first
|
|
620
|
+
# and the failure reported second, never the other way around.
|
|
621
|
+
return int(payload.get("exit_code", 0))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
if __name__ == "__main__": # pragma: no cover - the console script is the entry point
|
|
625
|
+
raise SystemExit(catalog_job_main())
|