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,1277 @@
|
|
|
1
|
+
"""One-shot Cloud Run wrapper for broker bootstrap, trusted crawling, and signed GCS PUT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import http.client
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import ssl
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, NoReturn, Protocol
|
|
17
|
+
from urllib.error import HTTPError
|
|
18
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
19
|
+
from urllib.request import (
|
|
20
|
+
HTTPRedirectHandler,
|
|
21
|
+
HTTPSHandler,
|
|
22
|
+
ProxyHandler,
|
|
23
|
+
Request,
|
|
24
|
+
build_opener,
|
|
25
|
+
)
|
|
26
|
+
from uuid import UUID
|
|
27
|
+
|
|
28
|
+
from mostlyright.data_harness.canonical import (
|
|
29
|
+
CanonicalJSONError,
|
|
30
|
+
canonical_json_bytes,
|
|
31
|
+
parse_canonical_json,
|
|
32
|
+
parse_json,
|
|
33
|
+
sha256_bytes,
|
|
34
|
+
)
|
|
35
|
+
from mostlyright.data_harness.hosted_crawler import (
|
|
36
|
+
CRAWLER_EGRESS_POLICY_ENV,
|
|
37
|
+
WARM_UP_RESULT_SCHEMA,
|
|
38
|
+
encode_normalization_input,
|
|
39
|
+
)
|
|
40
|
+
from mostlyright.data_harness.hosted_crawler_fetch import fetch_crawler_source
|
|
41
|
+
from mostlyright.data_harness.hosted_crawler_protocol import (
|
|
42
|
+
CRAWLER_REQUEST_SCHEMA_V3,
|
|
43
|
+
CRAWLER_RESULT_MEDIA_TYPE,
|
|
44
|
+
CRAWLER_RESULT_SCHEMA_V1,
|
|
45
|
+
CRAWLER_RESULT_SCHEMA_V3,
|
|
46
|
+
HOSTED_CRAWLER_FAILURE_CODES,
|
|
47
|
+
MAX_CRAWLER_REQUEST_BYTES,
|
|
48
|
+
MAX_CRAWLER_RESULT_BYTES,
|
|
49
|
+
HostedCrawlerExchange,
|
|
50
|
+
HostedCrawlerFailure,
|
|
51
|
+
HostedCrawlerProtocolError,
|
|
52
|
+
HostedCrawlerRequest,
|
|
53
|
+
parse_crawler_request,
|
|
54
|
+
parse_crawler_result,
|
|
55
|
+
)
|
|
56
|
+
from mostlyright.data_harness.linux_process_boundary import (
|
|
57
|
+
_disable_process_dumpability,
|
|
58
|
+
_install_networkless_seccomp,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
UPLOAD_CAPABILITY_SCHEMA = "mostlyright-crawler-upload-capability.v1"
|
|
62
|
+
BOOTSTRAP_RESPONSE_SCHEMA = "mostlyright-crawler-bootstrap.v1"
|
|
63
|
+
UPLOAD_MEDIA_TYPE = CRAWLER_RESULT_MEDIA_TYPE
|
|
64
|
+
RESULTS_BUCKET_ENV = "MOSTLYRIGHT_CRAWLER_RESULTS_BUCKET"
|
|
65
|
+
BOOTSTRAP_AUDIENCE_BASE_ENV = "MOSTLYRIGHT_CRAWLER_BOOTSTRAP_AUDIENCE_BASE"
|
|
66
|
+
BOOTSTRAP_URL_BASE_ENV = "MOSTLYRIGHT_CRAWLER_BOOTSTRAP_URL_BASE"
|
|
67
|
+
CLOUD_RUN_EXECUTION_ENV = "CLOUD_RUN_EXECUTION"
|
|
68
|
+
RESULT_OBJECT_PREFIX = "crawler-quarantine/v2/"
|
|
69
|
+
MAX_UPLOAD_CAPABILITY_BYTES = 32 * 1024
|
|
70
|
+
MAX_BOOTSTRAP_RESPONSE_BYTES = MAX_CRAWLER_REQUEST_BYTES + MAX_UPLOAD_CAPABILITY_BYTES + 16 * 1024
|
|
71
|
+
MAX_METADATA_IDENTITY_TOKEN_BYTES = 16 * 1024
|
|
72
|
+
# The only failures a wrapper can know before Studio has supplied the sealed
|
|
73
|
+
# request and single-use upload capability. Keep this closed in lock-step with
|
|
74
|
+
# Studio's ``PRE_BOOTSTRAP_FAILURE_CODES``; ordinary Courier failures travel in
|
|
75
|
+
# the signed result bundle and must never acquire this control-plane path.
|
|
76
|
+
PRE_BOOTSTRAP_FAILURE_CODES = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"CRAWLER_BOOTSTRAP_REDEMPTION_FAILED",
|
|
79
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_INVALID",
|
|
80
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_TOO_LARGE",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
# Sized for an EPA-scale year: the streamed decode, the streamed verification pass, and
|
|
84
|
+
# the chunked result encode are each one pure-Python walk over ~200M cells, which a live
|
|
85
|
+
# run spends 12-15 minutes on. The old 600 was calibrated to decode alone and killed a
|
|
86
|
+
# healthy child mid-verification the first time one survived that far.
|
|
87
|
+
CHILD_TIMEOUT_SECONDS = 1800.0
|
|
88
|
+
# Sized for EPA-scale archive decode under the raised Courier caps: the pure-Python
|
|
89
|
+
# row materialization of a ~200M-cell year file peaks at a measured 24.5 GiB, so the
|
|
90
|
+
# boundary is 32 GiB, equal to the Studio job's container limit by the exact-value check.
|
|
91
|
+
READER_MEMORY_CGROUP_MAX_BYTES = 34_359_738_368
|
|
92
|
+
UPLOAD_TIMEOUT_SECONDS = 600.0
|
|
93
|
+
# The networkless child runs under a seccomp filter that denies clone/clone3 as
|
|
94
|
+
# process-escape syscalls, so native libraries must not spawn worker threads.
|
|
95
|
+
# numpy's BLAS spawns its pool at import and would be killed mid-import without
|
|
96
|
+
# these pins; a single-threaded child is also the deterministic one. Arrow's
|
|
97
|
+
# prefixed jemalloc additionally announces its denied background thread on
|
|
98
|
+
# stderr, and the wrapper's contract treats any child stderr as failure, so
|
|
99
|
+
# its background thread is disabled outright.
|
|
100
|
+
CRAWLER_CHILD_THREAD_PINS = (
|
|
101
|
+
("JE_ARROW_MALLOC_CONF", "background_thread:false"),
|
|
102
|
+
("MKL_NUM_THREADS", "1"),
|
|
103
|
+
("NUMEXPR_NUM_THREADS", "1"),
|
|
104
|
+
("OMP_NUM_THREADS", "1"),
|
|
105
|
+
("OPENBLAS_NUM_THREADS", "1"),
|
|
106
|
+
)
|
|
107
|
+
BOOTSTRAP_TIMEOUT_SECONDS = 10.0
|
|
108
|
+
METADATA_IDENTITY_URL = (
|
|
109
|
+
"http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity"
|
|
110
|
+
)
|
|
111
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
112
|
+
_BUCKET = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
|
|
113
|
+
_OBJECT_SUFFIX = re.compile(r"^[0-9a-f]{64}$")
|
|
114
|
+
_JWT = re.compile(rb"^[A-Za-z0-9_-]{1,8192}\.[A-Za-z0-9_-]{1,8192}\.[A-Za-z0-9_-]{1,8192}$")
|
|
115
|
+
_CLOUD_RUN_EXECUTION = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$")
|
|
116
|
+
_TIMESTAMP = re.compile(
|
|
117
|
+
r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])"
|
|
118
|
+
r"T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,6})?Z$"
|
|
119
|
+
)
|
|
120
|
+
_SIGNED_QUERY_FIELDS = {
|
|
121
|
+
"X-Goog-Algorithm",
|
|
122
|
+
"X-Goog-Credential",
|
|
123
|
+
"X-Goog-Date",
|
|
124
|
+
"X-Goog-Expires",
|
|
125
|
+
"X-Goog-Signature",
|
|
126
|
+
"X-Goog-SignedHeaders",
|
|
127
|
+
}
|
|
128
|
+
_SIGNED_HEADERS = ";".join(
|
|
129
|
+
sorted(
|
|
130
|
+
(
|
|
131
|
+
"content-type",
|
|
132
|
+
"host",
|
|
133
|
+
"x-goog-content-length-range",
|
|
134
|
+
"x-goog-content-sha256",
|
|
135
|
+
"x-goog-if-generation-match",
|
|
136
|
+
"x-goog-meta-mostlyright-crawler-session",
|
|
137
|
+
"x-goog-meta-mostlyright-egress-policy",
|
|
138
|
+
"x-goog-meta-mostlyright-request-digest",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class HostedCrawlerJobError(RuntimeError):
|
|
145
|
+
"""Fail-closed wrapper error that never includes signed URLs or argv values."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
148
|
+
self.code = code
|
|
149
|
+
self.detail = detail
|
|
150
|
+
super().__init__(f"{detail} [{code}]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class UploadCapability:
|
|
155
|
+
crawler_session_id: str
|
|
156
|
+
request_digest: str
|
|
157
|
+
upload_url: str
|
|
158
|
+
required_headers: tuple[tuple[str, str], ...]
|
|
159
|
+
expires_at: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class CrawlerBootstrap:
|
|
164
|
+
request_bytes: bytes
|
|
165
|
+
upload_capability: UploadCapability
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CrawlerBootstrapTransport(Protocol):
|
|
169
|
+
"""Minimal no-proxy, no-redirect transport used for one bootstrap redemption."""
|
|
170
|
+
|
|
171
|
+
def request(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
method: str,
|
|
175
|
+
url: str,
|
|
176
|
+
headers: dict[str, str],
|
|
177
|
+
body: bytes | None,
|
|
178
|
+
maximum_response_bytes: int,
|
|
179
|
+
) -> tuple[int, bytearray]: ...
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
183
|
+
def redirect_request( # type: ignore[override]
|
|
184
|
+
self,
|
|
185
|
+
req: Request,
|
|
186
|
+
fp: Any,
|
|
187
|
+
code: int,
|
|
188
|
+
msg: str,
|
|
189
|
+
headers: Any,
|
|
190
|
+
newurl: str,
|
|
191
|
+
) -> None:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class UrlLibCrawlerBootstrapTransport:
|
|
196
|
+
"""Bounded transport with ambient proxy use and redirects disabled."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, *, timeout: float = BOOTSTRAP_TIMEOUT_SECONDS) -> None:
|
|
199
|
+
self._timeout = timeout
|
|
200
|
+
self._opener = build_opener(
|
|
201
|
+
ProxyHandler({}),
|
|
202
|
+
HTTPSHandler(context=ssl.create_default_context()),
|
|
203
|
+
_NoRedirect(),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def request(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
method: str,
|
|
210
|
+
url: str,
|
|
211
|
+
headers: dict[str, str],
|
|
212
|
+
body: bytes | None,
|
|
213
|
+
maximum_response_bytes: int,
|
|
214
|
+
) -> tuple[int, bytearray]:
|
|
215
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
216
|
+
try:
|
|
217
|
+
response = self._opener.open(request, timeout=self._timeout)
|
|
218
|
+
except HTTPError as error:
|
|
219
|
+
response = error
|
|
220
|
+
try:
|
|
221
|
+
content_length = response.headers.get("Content-Length")
|
|
222
|
+
if (
|
|
223
|
+
content_length is not None
|
|
224
|
+
and content_length.isdigit()
|
|
225
|
+
and int(content_length) > maximum_response_bytes
|
|
226
|
+
):
|
|
227
|
+
_fail("CRAWLER_BOOTSTRAP_RESPONSE_TOO_LARGE", "bootstrap response is too large")
|
|
228
|
+
content = bytearray(response.read(maximum_response_bytes + 1))
|
|
229
|
+
if len(content) > maximum_response_bytes:
|
|
230
|
+
_zero(content)
|
|
231
|
+
_fail("CRAWLER_BOOTSTRAP_RESPONSE_TOO_LARGE", "bootstrap response is too large")
|
|
232
|
+
return int(response.status), content
|
|
233
|
+
finally:
|
|
234
|
+
response.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fail(code: str, detail: str) -> NoReturn:
|
|
238
|
+
raise HostedCrawlerJobError(code, detail)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _zero(value: bytearray | None) -> None:
|
|
242
|
+
if value is not None:
|
|
243
|
+
value[:] = b"\x00" * len(value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _canonical_timestamp(value: Any, label: str) -> str:
|
|
247
|
+
if not isinstance(value, str) or _TIMESTAMP.fullmatch(value) is None:
|
|
248
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", f"{label} is not canonical UTC")
|
|
249
|
+
try:
|
|
250
|
+
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
251
|
+
except ValueError:
|
|
252
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", f"{label} is not a real UTC time")
|
|
253
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
254
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", f"{label} is not canonical UTC")
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _canonical_uuid(value: Any, label: str) -> str:
|
|
259
|
+
try:
|
|
260
|
+
parsed = UUID(value)
|
|
261
|
+
except (TypeError, ValueError, AttributeError):
|
|
262
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", f"{label} is not a canonical UUID")
|
|
263
|
+
if str(parsed) != value:
|
|
264
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", f"{label} is not a canonical UUID")
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _bootstrap_base(value: Any, *, label: str) -> str:
|
|
269
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 2048:
|
|
270
|
+
_fail("CRAWLER_BOOTSTRAP_CONFIGURATION_INVALID", f"{label} is unavailable")
|
|
271
|
+
parsed = urlsplit(value)
|
|
272
|
+
try:
|
|
273
|
+
port = parsed.port
|
|
274
|
+
except ValueError:
|
|
275
|
+
_fail("CRAWLER_BOOTSTRAP_CONFIGURATION_INVALID", f"{label} is invalid")
|
|
276
|
+
if (
|
|
277
|
+
parsed.scheme != "https"
|
|
278
|
+
or not parsed.hostname
|
|
279
|
+
or parsed.username is not None
|
|
280
|
+
or parsed.password is not None
|
|
281
|
+
or port is not None
|
|
282
|
+
or parsed.query
|
|
283
|
+
or parsed.fragment
|
|
284
|
+
or not parsed.path.startswith("/")
|
|
285
|
+
or parsed.path.endswith("/")
|
|
286
|
+
or "%" in parsed.path
|
|
287
|
+
or "\\" in parsed.path
|
|
288
|
+
or "//" in parsed.path
|
|
289
|
+
or "/./" in parsed.path
|
|
290
|
+
or "/../" in parsed.path
|
|
291
|
+
or value != f"https://{parsed.hostname}{parsed.path}"
|
|
292
|
+
):
|
|
293
|
+
_fail("CRAWLER_BOOTSTRAP_CONFIGURATION_INVALID", f"{label} is invalid")
|
|
294
|
+
return value
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _crawler_session_id(value: Any) -> str:
|
|
298
|
+
try:
|
|
299
|
+
parsed = UUID(value)
|
|
300
|
+
except (TypeError, ValueError, AttributeError):
|
|
301
|
+
_fail("CRAWLER_JOB_ARGUMENT_INVALID", "crawler session identifier is invalid")
|
|
302
|
+
if str(parsed) != value:
|
|
303
|
+
_fail("CRAWLER_JOB_ARGUMENT_INVALID", "crawler session identifier is invalid")
|
|
304
|
+
return value
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cloud_run_execution(value: Any) -> str:
|
|
308
|
+
"""Accept only Cloud Run's canonical injected execution-name token."""
|
|
309
|
+
|
|
310
|
+
if not isinstance(value, str) or _CLOUD_RUN_EXECUTION.fullmatch(value) is None:
|
|
311
|
+
_fail("CRAWLER_BOOTSTRAP_CONFIGURATION_INVALID", "crawler execution coordinate is invalid")
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _metadata_identity_token(
|
|
316
|
+
*,
|
|
317
|
+
audience: str,
|
|
318
|
+
transport: CrawlerBootstrapTransport,
|
|
319
|
+
) -> bytearray:
|
|
320
|
+
metadata_url = f"{METADATA_IDENTITY_URL}?{urlencode({'audience': audience, 'format': 'full'})}"
|
|
321
|
+
try:
|
|
322
|
+
status, token = transport.request(
|
|
323
|
+
method="GET",
|
|
324
|
+
url=metadata_url,
|
|
325
|
+
headers={"Metadata-Flavor": "Google"},
|
|
326
|
+
body=None,
|
|
327
|
+
maximum_response_bytes=MAX_METADATA_IDENTITY_TOKEN_BYTES,
|
|
328
|
+
)
|
|
329
|
+
except HostedCrawlerJobError:
|
|
330
|
+
raise
|
|
331
|
+
except Exception:
|
|
332
|
+
_fail(
|
|
333
|
+
"CRAWLER_WORKLOAD_IDENTITY_UNAVAILABLE",
|
|
334
|
+
"crawler workload identity is unavailable",
|
|
335
|
+
)
|
|
336
|
+
if not isinstance(token, bytearray):
|
|
337
|
+
token = bytearray(token)
|
|
338
|
+
if status != 200 or _JWT.fullmatch(token) is None:
|
|
339
|
+
_zero(token)
|
|
340
|
+
_fail(
|
|
341
|
+
"CRAWLER_WORKLOAD_IDENTITY_UNAVAILABLE",
|
|
342
|
+
"crawler workload identity is unavailable",
|
|
343
|
+
)
|
|
344
|
+
return token
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _redeem_crawler_bootstrap(
|
|
348
|
+
*,
|
|
349
|
+
crawler_session_id: str,
|
|
350
|
+
bucket: str,
|
|
351
|
+
transport: CrawlerBootstrapTransport | None = None,
|
|
352
|
+
now: datetime | None = None,
|
|
353
|
+
) -> CrawlerBootstrap:
|
|
354
|
+
"""Redeem one workload-bound bootstrap without putting authority on argv or env."""
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
_disable_process_dumpability()
|
|
358
|
+
except OSError:
|
|
359
|
+
_fail(
|
|
360
|
+
"CRAWLER_PROCESS_HARDENING_UNAVAILABLE",
|
|
361
|
+
"crawler process hardening is unavailable",
|
|
362
|
+
)
|
|
363
|
+
session_id = _crawler_session_id(crawler_session_id)
|
|
364
|
+
audience_base = _bootstrap_base(
|
|
365
|
+
os.environ.get(BOOTSTRAP_AUDIENCE_BASE_ENV),
|
|
366
|
+
label="bootstrap audience base",
|
|
367
|
+
)
|
|
368
|
+
url_base = _bootstrap_base(
|
|
369
|
+
os.environ.get(BOOTSTRAP_URL_BASE_ENV),
|
|
370
|
+
label="bootstrap URL base",
|
|
371
|
+
)
|
|
372
|
+
audience = f"{audience_base}/{session_id}:redeem"
|
|
373
|
+
redemption_url = f"{url_base}/{session_id}:redeem"
|
|
374
|
+
cloud_run_execution = _cloud_run_execution(os.environ.get(CLOUD_RUN_EXECUTION_ENV))
|
|
375
|
+
client = transport or UrlLibCrawlerBootstrapTransport()
|
|
376
|
+
token: bytearray | None = None
|
|
377
|
+
response: bytearray | None = None
|
|
378
|
+
authorization_headers: dict[str, str] = {}
|
|
379
|
+
token_text = ""
|
|
380
|
+
try:
|
|
381
|
+
token = _metadata_identity_token(audience=audience, transport=client)
|
|
382
|
+
token_text = token.decode("ascii", errors="strict")
|
|
383
|
+
authorization_headers = {
|
|
384
|
+
"Accept": "application/json",
|
|
385
|
+
"Authorization": f"Bearer {token_text}",
|
|
386
|
+
"X-Mostlyright-Cloud-Run-Execution": cloud_run_execution,
|
|
387
|
+
}
|
|
388
|
+
try:
|
|
389
|
+
status, response = client.request(
|
|
390
|
+
method="POST",
|
|
391
|
+
url=redemption_url,
|
|
392
|
+
headers=authorization_headers,
|
|
393
|
+
body=None,
|
|
394
|
+
maximum_response_bytes=MAX_BOOTSTRAP_RESPONSE_BYTES,
|
|
395
|
+
)
|
|
396
|
+
except HostedCrawlerJobError:
|
|
397
|
+
raise
|
|
398
|
+
except Exception:
|
|
399
|
+
_fail(
|
|
400
|
+
"CRAWLER_BOOTSTRAP_REDEMPTION_FAILED",
|
|
401
|
+
"crawler bootstrap redemption failed",
|
|
402
|
+
)
|
|
403
|
+
if not isinstance(response, bytearray):
|
|
404
|
+
response = bytearray(response)
|
|
405
|
+
if status != 200 or not 1 <= len(response) <= MAX_BOOTSTRAP_RESPONSE_BYTES:
|
|
406
|
+
_fail(
|
|
407
|
+
"CRAWLER_BOOTSTRAP_REDEMPTION_FAILED",
|
|
408
|
+
"crawler bootstrap redemption failed",
|
|
409
|
+
)
|
|
410
|
+
value = parse_json(response)
|
|
411
|
+
expected_fields = {
|
|
412
|
+
"schema_version",
|
|
413
|
+
"crawler_session_id",
|
|
414
|
+
"request",
|
|
415
|
+
"upload_capability",
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
not isinstance(value, dict)
|
|
419
|
+
or set(value) != expected_fields
|
|
420
|
+
or value["schema_version"] != BOOTSTRAP_RESPONSE_SCHEMA
|
|
421
|
+
or value["crawler_session_id"] != session_id
|
|
422
|
+
):
|
|
423
|
+
_fail(
|
|
424
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_INVALID",
|
|
425
|
+
"crawler bootstrap response fields or session binding are invalid",
|
|
426
|
+
)
|
|
427
|
+
request = parse_crawler_request(value["request"])
|
|
428
|
+
request_bytes = canonical_json_bytes(request.to_dict())
|
|
429
|
+
if not 1 <= len(request_bytes) <= MAX_CRAWLER_REQUEST_BYTES:
|
|
430
|
+
_fail(
|
|
431
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_INVALID",
|
|
432
|
+
"crawler request exceeds its fixed bound",
|
|
433
|
+
)
|
|
434
|
+
capability = parse_upload_capability(
|
|
435
|
+
value["upload_capability"],
|
|
436
|
+
request_digest=request.digest,
|
|
437
|
+
egress_policy_attestation=request.egress_policy_attestation,
|
|
438
|
+
bucket=bucket,
|
|
439
|
+
now=now,
|
|
440
|
+
)
|
|
441
|
+
if capability.crawler_session_id != session_id:
|
|
442
|
+
_fail(
|
|
443
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_INVALID",
|
|
444
|
+
"crawler upload capability session binding is invalid",
|
|
445
|
+
)
|
|
446
|
+
return CrawlerBootstrap(
|
|
447
|
+
request_bytes=request_bytes,
|
|
448
|
+
upload_capability=capability,
|
|
449
|
+
)
|
|
450
|
+
except (UnicodeDecodeError, CanonicalJSONError, HostedCrawlerProtocolError):
|
|
451
|
+
_fail(
|
|
452
|
+
"CRAWLER_BOOTSTRAP_RESPONSE_INVALID",
|
|
453
|
+
"crawler bootstrap response is invalid",
|
|
454
|
+
)
|
|
455
|
+
finally:
|
|
456
|
+
authorization_headers.clear()
|
|
457
|
+
token_text = ""
|
|
458
|
+
_zero(token)
|
|
459
|
+
_zero(response)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _report_crawler_bootstrap_failure(
|
|
463
|
+
*,
|
|
464
|
+
crawler_session_id: str,
|
|
465
|
+
failure_code: str,
|
|
466
|
+
transport: CrawlerBootstrapTransport | None = None,
|
|
467
|
+
) -> bool:
|
|
468
|
+
"""Best-effort one-way report for a failure before upload authority exists.
|
|
469
|
+
|
|
470
|
+
The failed job still exits non-zero if this report cannot be delivered. It
|
|
471
|
+
obtains an independent workload token for the exact ``:fail`` route, sends
|
|
472
|
+
only an allowlisted code in canonical bytes, and accepts only an empty 204
|
|
473
|
+
acknowledgement. No response diagnostic, request, or storage capability
|
|
474
|
+
can cross back into the crawler through this path.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
if failure_code not in PRE_BOOTSTRAP_FAILURE_CODES:
|
|
478
|
+
return False
|
|
479
|
+
token: bytearray | None = None
|
|
480
|
+
response: bytearray | None = None
|
|
481
|
+
authorization_headers: dict[str, str] = {}
|
|
482
|
+
token_text = ""
|
|
483
|
+
try:
|
|
484
|
+
session_id = _crawler_session_id(crawler_session_id)
|
|
485
|
+
audience_base = _bootstrap_base(
|
|
486
|
+
os.environ.get(BOOTSTRAP_AUDIENCE_BASE_ENV),
|
|
487
|
+
label="bootstrap audience base",
|
|
488
|
+
)
|
|
489
|
+
url_base = _bootstrap_base(
|
|
490
|
+
os.environ.get(BOOTSTRAP_URL_BASE_ENV),
|
|
491
|
+
label="bootstrap URL base",
|
|
492
|
+
)
|
|
493
|
+
audience = f"{audience_base}/{session_id}:fail"
|
|
494
|
+
failure_url = f"{url_base}/{session_id}:fail"
|
|
495
|
+
report = canonical_json_bytes({"failure_code": failure_code})
|
|
496
|
+
client = transport or UrlLibCrawlerBootstrapTransport()
|
|
497
|
+
token = _metadata_identity_token(audience=audience, transport=client)
|
|
498
|
+
token_text = token.decode("ascii", errors="strict")
|
|
499
|
+
authorization_headers = {
|
|
500
|
+
"Accept": "application/json",
|
|
501
|
+
"Authorization": f"Bearer {token_text}",
|
|
502
|
+
"Content-Type": "application/json",
|
|
503
|
+
}
|
|
504
|
+
status, response = client.request(
|
|
505
|
+
method="POST",
|
|
506
|
+
url=failure_url,
|
|
507
|
+
headers=authorization_headers,
|
|
508
|
+
body=report,
|
|
509
|
+
maximum_response_bytes=0,
|
|
510
|
+
)
|
|
511
|
+
if not isinstance(response, bytearray):
|
|
512
|
+
response = bytearray(response)
|
|
513
|
+
return status == 204 and not response
|
|
514
|
+
except (HostedCrawlerJobError, UnicodeDecodeError, CanonicalJSONError, ValueError):
|
|
515
|
+
return False
|
|
516
|
+
except Exception:
|
|
517
|
+
return False
|
|
518
|
+
finally:
|
|
519
|
+
authorization_headers.clear()
|
|
520
|
+
token_text = ""
|
|
521
|
+
_zero(token)
|
|
522
|
+
_zero(response)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _required_headers(
|
|
526
|
+
value: Any,
|
|
527
|
+
*,
|
|
528
|
+
crawler_session_id: str,
|
|
529
|
+
request_digest: str,
|
|
530
|
+
egress_policy_attestation: str,
|
|
531
|
+
) -> tuple[tuple[str, str], ...]:
|
|
532
|
+
expected = (
|
|
533
|
+
("Content-Type", UPLOAD_MEDIA_TYPE),
|
|
534
|
+
("x-goog-content-length-range", f"1,{MAX_CRAWLER_RESULT_BYTES}"),
|
|
535
|
+
("x-goog-content-sha256", "UNSIGNED-PAYLOAD"),
|
|
536
|
+
("x-goog-if-generation-match", "0"),
|
|
537
|
+
("x-goog-meta-mostlyright-crawler-session", crawler_session_id),
|
|
538
|
+
("x-goog-meta-mostlyright-request-digest", request_digest),
|
|
539
|
+
("x-goog-meta-mostlyright-egress-policy", egress_policy_attestation),
|
|
540
|
+
)
|
|
541
|
+
if not isinstance(value, list) or len(value) != len(expected):
|
|
542
|
+
_fail(
|
|
543
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
544
|
+
"required upload header inventory is not exact",
|
|
545
|
+
)
|
|
546
|
+
parsed: list[tuple[str, str]] = []
|
|
547
|
+
for item in value:
|
|
548
|
+
if not isinstance(item, dict) or set(item) != {"name", "value"}:
|
|
549
|
+
_fail(
|
|
550
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
551
|
+
"required upload header entry is not exact",
|
|
552
|
+
)
|
|
553
|
+
name = item["name"]
|
|
554
|
+
header_value = item["value"]
|
|
555
|
+
if (
|
|
556
|
+
not isinstance(name, str)
|
|
557
|
+
or not isinstance(header_value, str)
|
|
558
|
+
or "\r" in name
|
|
559
|
+
or "\n" in name
|
|
560
|
+
or "\r" in header_value
|
|
561
|
+
or "\n" in header_value
|
|
562
|
+
):
|
|
563
|
+
_fail(
|
|
564
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
565
|
+
"required upload header entry is invalid",
|
|
566
|
+
)
|
|
567
|
+
parsed.append((name, header_value))
|
|
568
|
+
if tuple(parsed) != expected:
|
|
569
|
+
_fail(
|
|
570
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
571
|
+
"required upload headers differ from the fixed authority binding",
|
|
572
|
+
)
|
|
573
|
+
return tuple(parsed)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _validate_upload_url(value: Any, *, bucket: str) -> str:
|
|
577
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 16 * 1024:
|
|
578
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", "upload URL is invalid")
|
|
579
|
+
parsed = urlsplit(value)
|
|
580
|
+
try:
|
|
581
|
+
port = parsed.port
|
|
582
|
+
except ValueError:
|
|
583
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", "upload URL port is invalid")
|
|
584
|
+
if (
|
|
585
|
+
parsed.scheme != "https"
|
|
586
|
+
or parsed.hostname != "storage.googleapis.com"
|
|
587
|
+
or parsed.username is not None
|
|
588
|
+
or parsed.password is not None
|
|
589
|
+
or port is not None
|
|
590
|
+
or parsed.fragment
|
|
591
|
+
):
|
|
592
|
+
_fail(
|
|
593
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
594
|
+
"upload URL is outside the exact GCS authority",
|
|
595
|
+
)
|
|
596
|
+
path_prefix = f"/{bucket}/{RESULT_OBJECT_PREFIX}"
|
|
597
|
+
if (
|
|
598
|
+
not parsed.path.startswith(path_prefix)
|
|
599
|
+
or parsed.path.count("/") != path_prefix.count("/")
|
|
600
|
+
or _OBJECT_SUFFIX.fullmatch(parsed.path.removeprefix(path_prefix)) is None
|
|
601
|
+
or "%" in parsed.path
|
|
602
|
+
or "\\" in parsed.path
|
|
603
|
+
or "//" in parsed.path
|
|
604
|
+
or "/./" in parsed.path
|
|
605
|
+
or "/../" in parsed.path
|
|
606
|
+
):
|
|
607
|
+
_fail(
|
|
608
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
609
|
+
"upload URL object path is outside the quarantine prefix",
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
query = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
|
613
|
+
except ValueError:
|
|
614
|
+
_fail(
|
|
615
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
616
|
+
"upload URL signed query inventory is invalid",
|
|
617
|
+
)
|
|
618
|
+
if (
|
|
619
|
+
len(query) != len(_SIGNED_QUERY_FIELDS)
|
|
620
|
+
or {name for name, _ in query} != _SIGNED_QUERY_FIELDS
|
|
621
|
+
or any(not item for _, item in query)
|
|
622
|
+
):
|
|
623
|
+
_fail(
|
|
624
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
625
|
+
"upload URL signed query inventory is not exact",
|
|
626
|
+
)
|
|
627
|
+
values = dict(query)
|
|
628
|
+
if (
|
|
629
|
+
values["X-Goog-Algorithm"] != "GOOG4-RSA-SHA256"
|
|
630
|
+
or values["X-Goog-SignedHeaders"] != _SIGNED_HEADERS
|
|
631
|
+
or not values["X-Goog-Expires"].isdigit()
|
|
632
|
+
# Bounded by the Studio upload window, which covers the child's whole execution
|
|
633
|
+
# budget rather than an interactive session: the capability has to stay
|
|
634
|
+
# presentable through a 15-20 minute year-scale crawl.
|
|
635
|
+
or not 1 <= int(values["X-Goog-Expires"]) <= 2100
|
|
636
|
+
or re.fullmatch(
|
|
637
|
+
r"(?:[0-9a-f]{2}){64,512}",
|
|
638
|
+
values["X-Goog-Signature"],
|
|
639
|
+
)
|
|
640
|
+
is None
|
|
641
|
+
):
|
|
642
|
+
_fail(
|
|
643
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
644
|
+
"upload URL signature parameters are outside the fixed policy",
|
|
645
|
+
)
|
|
646
|
+
return value
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def parse_upload_capability(
|
|
650
|
+
value: Any,
|
|
651
|
+
*,
|
|
652
|
+
request_digest: str,
|
|
653
|
+
egress_policy_attestation: str,
|
|
654
|
+
bucket: str,
|
|
655
|
+
now: datetime | None = None,
|
|
656
|
+
) -> UploadCapability:
|
|
657
|
+
fields = {
|
|
658
|
+
"schema_version",
|
|
659
|
+
"crawler_session_id",
|
|
660
|
+
"request_digest",
|
|
661
|
+
"method",
|
|
662
|
+
"upload_url",
|
|
663
|
+
"required_headers",
|
|
664
|
+
"max_result_bytes",
|
|
665
|
+
"expires_at",
|
|
666
|
+
"redirect_policy",
|
|
667
|
+
"single_use",
|
|
668
|
+
}
|
|
669
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
670
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_INVALID", "upload capability fields are not exact")
|
|
671
|
+
if (
|
|
672
|
+
value["schema_version"] != UPLOAD_CAPABILITY_SCHEMA
|
|
673
|
+
or value["method"] != "PUT"
|
|
674
|
+
or type(value["max_result_bytes"]) is not int
|
|
675
|
+
or value["max_result_bytes"] != MAX_CRAWLER_RESULT_BYTES
|
|
676
|
+
or value["redirect_policy"] != "deny"
|
|
677
|
+
or value["single_use"] is not True
|
|
678
|
+
or not isinstance(value["request_digest"], str)
|
|
679
|
+
or _DIGEST.fullmatch(value["request_digest"]) is None
|
|
680
|
+
or value["request_digest"] != request_digest
|
|
681
|
+
):
|
|
682
|
+
_fail(
|
|
683
|
+
"CRAWLER_UPLOAD_CAPABILITY_INVALID",
|
|
684
|
+
"upload capability constants or request binding mismatch",
|
|
685
|
+
)
|
|
686
|
+
crawler_session_id = _canonical_uuid(
|
|
687
|
+
value["crawler_session_id"],
|
|
688
|
+
"crawler_session_id",
|
|
689
|
+
)
|
|
690
|
+
expires_at = _canonical_timestamp(value["expires_at"], "expires_at")
|
|
691
|
+
expiry = datetime.fromisoformat(expires_at.removesuffix("Z") + "+00:00")
|
|
692
|
+
current = now or datetime.now(UTC)
|
|
693
|
+
if current.tzinfo is None or current.astimezone(UTC) >= expiry:
|
|
694
|
+
_fail("CRAWLER_UPLOAD_CAPABILITY_EXPIRED", "upload capability has expired")
|
|
695
|
+
upload_url = _validate_upload_url(value["upload_url"], bucket=bucket)
|
|
696
|
+
headers = _required_headers(
|
|
697
|
+
value["required_headers"],
|
|
698
|
+
crawler_session_id=crawler_session_id,
|
|
699
|
+
request_digest=request_digest,
|
|
700
|
+
egress_policy_attestation=egress_policy_attestation,
|
|
701
|
+
)
|
|
702
|
+
return UploadCapability(
|
|
703
|
+
crawler_session_id=crawler_session_id,
|
|
704
|
+
request_digest=request_digest,
|
|
705
|
+
upload_url=upload_url,
|
|
706
|
+
required_headers=headers,
|
|
707
|
+
expires_at=expires_at,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _run_crawler_child(
|
|
712
|
+
request: HostedCrawlerRequest,
|
|
713
|
+
normalization_input: bytes,
|
|
714
|
+
) -> bytes:
|
|
715
|
+
crawler = shutil.which("mr-data-crawler")
|
|
716
|
+
if crawler is None or not os.path.isabs(crawler):
|
|
717
|
+
_fail("CRAWLER_EXECUTABLE_UNAVAILABLE", "fixed crawler executable is unavailable")
|
|
718
|
+
completed = subprocess.run(
|
|
719
|
+
[crawler, "--input-fd", "0", "--result-fd", "1"],
|
|
720
|
+
input=normalization_input,
|
|
721
|
+
capture_output=True,
|
|
722
|
+
env={
|
|
723
|
+
CRAWLER_EGRESS_POLICY_ENV: request.egress_policy_attestation,
|
|
724
|
+
**dict(CRAWLER_CHILD_THREAD_PINS),
|
|
725
|
+
},
|
|
726
|
+
close_fds=True,
|
|
727
|
+
preexec_fn=_install_networkless_seccomp,
|
|
728
|
+
check=False,
|
|
729
|
+
timeout=CHILD_TIMEOUT_SECONDS,
|
|
730
|
+
)
|
|
731
|
+
if completed.returncode != 0:
|
|
732
|
+
_fail(_child_failure_code(completed.stderr), "fixed crawler execution failed")
|
|
733
|
+
if completed.stderr or not 1 <= len(completed.stdout) <= MAX_CRAWLER_RESULT_BYTES:
|
|
734
|
+
_fail("CRAWLER_CHILD_INVALID", "fixed crawler output is invalid")
|
|
735
|
+
return completed.stdout
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _run_crawler_warm_up(request: HostedCrawlerRequest) -> None:
|
|
739
|
+
"""Run the mandatory known-sample check before any public source byte is fetched."""
|
|
740
|
+
|
|
741
|
+
crawler = shutil.which("mr-data-crawler")
|
|
742
|
+
if crawler is None or not os.path.isabs(crawler):
|
|
743
|
+
_fail("CRAWLER_EXECUTABLE_UNAVAILABLE", "fixed crawler executable is unavailable")
|
|
744
|
+
completed = subprocess.run(
|
|
745
|
+
[crawler, "--warm-up", "--input-fd", "0", "--result-fd", "1"],
|
|
746
|
+
input=canonical_json_bytes(request.to_dict()),
|
|
747
|
+
capture_output=True,
|
|
748
|
+
env={
|
|
749
|
+
CRAWLER_EGRESS_POLICY_ENV: request.egress_policy_attestation,
|
|
750
|
+
**dict(CRAWLER_CHILD_THREAD_PINS),
|
|
751
|
+
},
|
|
752
|
+
close_fds=True,
|
|
753
|
+
preexec_fn=_install_networkless_seccomp,
|
|
754
|
+
check=False,
|
|
755
|
+
timeout=CHILD_TIMEOUT_SECONDS,
|
|
756
|
+
)
|
|
757
|
+
if completed.returncode != 0:
|
|
758
|
+
code = _child_failure_code(completed.stderr)
|
|
759
|
+
_fail(code if code == "READER_WARMUP" else "READER_WARMUP", "hosted Reader warm-up failed")
|
|
760
|
+
try:
|
|
761
|
+
result = parse_canonical_json(completed.stdout)
|
|
762
|
+
except (CanonicalJSONError, TypeError, ValueError):
|
|
763
|
+
_fail("READER_WARMUP", "hosted Reader warm-up result is invalid")
|
|
764
|
+
if completed.stderr or result != {
|
|
765
|
+
"schema_version": WARM_UP_RESULT_SCHEMA,
|
|
766
|
+
"request_digest": request.digest,
|
|
767
|
+
"status": "passed",
|
|
768
|
+
}:
|
|
769
|
+
_fail("READER_WARMUP", "hosted Reader warm-up result is invalid")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _require_reader_memory_boundary() -> None:
|
|
773
|
+
"""Bind hosted Reader execution to the immutable Cloud Run cgroup ceiling.
|
|
774
|
+
|
|
775
|
+
Cloud Run's job sandbox mounts a hybrid cgroup layout: the kernel enforces the
|
|
776
|
+
container memory limit through the v1 controller at memory/memory.limit_in_bytes,
|
|
777
|
+
and the v2 unified file memory.max is absent. Both spellings name the same
|
|
778
|
+
kernel-enforced ceiling, so either satisfies the boundary; the required value is
|
|
779
|
+
exact in both cases.
|
|
780
|
+
"""
|
|
781
|
+
|
|
782
|
+
value = None
|
|
783
|
+
for candidate in (
|
|
784
|
+
Path("/sys/fs/cgroup/memory.max"),
|
|
785
|
+
Path("/sys/fs/cgroup/memory/memory.limit_in_bytes"),
|
|
786
|
+
):
|
|
787
|
+
try:
|
|
788
|
+
value = candidate.read_text(encoding="ascii").strip()
|
|
789
|
+
except (OSError, UnicodeError):
|
|
790
|
+
continue
|
|
791
|
+
break
|
|
792
|
+
if value is None:
|
|
793
|
+
_fail("CRAWLER_MEMORY_BOUNDARY", "hosted Reader memory cgroup is unavailable")
|
|
794
|
+
if value != str(READER_MEMORY_CGROUP_MAX_BYTES):
|
|
795
|
+
_fail("CRAWLER_MEMORY_BOUNDARY", "hosted Reader memory cgroup is not exact")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _child_failure_code(stderr: bytes) -> str:
|
|
799
|
+
"""Admit only a closed stable child code; never propagate child detail or source text."""
|
|
800
|
+
|
|
801
|
+
if not 1 <= len(stderr) <= 512:
|
|
802
|
+
return "CRAWLER_CHILD_FAILED"
|
|
803
|
+
try:
|
|
804
|
+
value = parse_canonical_json(stderr.strip())
|
|
805
|
+
except (CanonicalJSONError, TypeError, ValueError):
|
|
806
|
+
return "CRAWLER_CHILD_FAILED"
|
|
807
|
+
if (
|
|
808
|
+
isinstance(value, dict)
|
|
809
|
+
and set(value) == {"status", "code"}
|
|
810
|
+
and value.get("status") == "failed"
|
|
811
|
+
and value.get("code") in HOSTED_CRAWLER_FAILURE_CODES
|
|
812
|
+
):
|
|
813
|
+
return str(value["code"])
|
|
814
|
+
return "CRAWLER_CHILD_FAILED"
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _validate_result_against_retrieval(
|
|
818
|
+
*,
|
|
819
|
+
request: HostedCrawlerRequest,
|
|
820
|
+
retrieved: Any,
|
|
821
|
+
result: Any,
|
|
822
|
+
) -> None:
|
|
823
|
+
"""Bind the crawler child's result to the exact bytes and evidence fetched by this job."""
|
|
824
|
+
|
|
825
|
+
if (request.adapter_id, request.adapter_version) == ("public.https", "1.0.0"):
|
|
826
|
+
_validate_public_https_result(request=request, retrieved=retrieved, result=result)
|
|
827
|
+
return
|
|
828
|
+
if (request.adapter_id, request.adapter_version) != ("external.openligadb", "1.0.0"):
|
|
829
|
+
_fail(
|
|
830
|
+
"CRAWLER_CHILD_INVALID",
|
|
831
|
+
"crawler result uses an unsupported adapter coordinate",
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
receipt = result.acquisition_receipt
|
|
835
|
+
observation = result.observation
|
|
836
|
+
if not isinstance(receipt, dict) or not isinstance(observation, dict):
|
|
837
|
+
_fail(
|
|
838
|
+
"CRAWLER_CHILD_INVALID",
|
|
839
|
+
"crawler result does not bind the trusted retrieval evidence",
|
|
840
|
+
)
|
|
841
|
+
receipt_fields = {
|
|
842
|
+
"schema_version",
|
|
843
|
+
"source_id",
|
|
844
|
+
"adapter_id",
|
|
845
|
+
"adapter_version",
|
|
846
|
+
"request_digest",
|
|
847
|
+
"query",
|
|
848
|
+
"source_url",
|
|
849
|
+
"final_url",
|
|
850
|
+
"media_type",
|
|
851
|
+
"acquired_at",
|
|
852
|
+
"raw_content_sha256",
|
|
853
|
+
"raw_size_bytes",
|
|
854
|
+
"normalized_content_sha256",
|
|
855
|
+
"normalized_size_bytes",
|
|
856
|
+
"row_count",
|
|
857
|
+
"column_names",
|
|
858
|
+
"transport_evidence_digest",
|
|
859
|
+
"egress_policy_attestation",
|
|
860
|
+
}
|
|
861
|
+
observation_fields = {
|
|
862
|
+
"schema_version",
|
|
863
|
+
"source_id",
|
|
864
|
+
"adapter_id",
|
|
865
|
+
"adapter_version",
|
|
866
|
+
"request_digest",
|
|
867
|
+
"observed_at",
|
|
868
|
+
"available_at",
|
|
869
|
+
"historical_start",
|
|
870
|
+
"historical_end",
|
|
871
|
+
"event_time_field",
|
|
872
|
+
"live_status",
|
|
873
|
+
"raw_content_sha256",
|
|
874
|
+
"normalized_content_sha256",
|
|
875
|
+
"row_count",
|
|
876
|
+
}
|
|
877
|
+
normalized_digest = sha256_bytes(result.normalized_content)
|
|
878
|
+
identity = (
|
|
879
|
+
receipt.get("source_id"),
|
|
880
|
+
receipt.get("adapter_id"),
|
|
881
|
+
receipt.get("adapter_version"),
|
|
882
|
+
receipt.get("request_digest"),
|
|
883
|
+
)
|
|
884
|
+
expected_identity = (
|
|
885
|
+
request.source_id,
|
|
886
|
+
request.adapter_id,
|
|
887
|
+
request.adapter_version,
|
|
888
|
+
request.digest,
|
|
889
|
+
)
|
|
890
|
+
if (
|
|
891
|
+
set(receipt) != receipt_fields
|
|
892
|
+
or set(observation) != observation_fields
|
|
893
|
+
or identity != expected_identity
|
|
894
|
+
or (
|
|
895
|
+
observation.get("source_id"),
|
|
896
|
+
observation.get("adapter_id"),
|
|
897
|
+
observation.get("adapter_version"),
|
|
898
|
+
observation.get("request_digest"),
|
|
899
|
+
)
|
|
900
|
+
!= expected_identity
|
|
901
|
+
or receipt.get("query") != request.query
|
|
902
|
+
or receipt.get("source_url") != retrieved.source_url
|
|
903
|
+
or receipt.get("final_url") != retrieved.final_url
|
|
904
|
+
or receipt.get("media_type") != retrieved.media_type
|
|
905
|
+
or receipt.get("acquired_at") != request.requested_at
|
|
906
|
+
or receipt.get("raw_content_sha256") != retrieved.content_sha256
|
|
907
|
+
or receipt.get("raw_size_bytes") != len(retrieved.content)
|
|
908
|
+
or receipt.get("transport_evidence_digest") != retrieved.transport_evidence_digest
|
|
909
|
+
or receipt.get("egress_policy_attestation") != request.egress_policy_attestation
|
|
910
|
+
or receipt.get("normalized_content_sha256") != normalized_digest
|
|
911
|
+
or receipt.get("normalized_size_bytes") != len(result.normalized_content)
|
|
912
|
+
or observation.get("observed_at") != request.requested_at
|
|
913
|
+
or observation.get("available_at") != request.requested_at
|
|
914
|
+
or observation.get("event_time_field") != "match_datetime_utc"
|
|
915
|
+
or observation.get("live_status") != "live"
|
|
916
|
+
or observation.get("raw_content_sha256") != retrieved.content_sha256
|
|
917
|
+
or observation.get("normalized_content_sha256") != normalized_digest
|
|
918
|
+
or observation.get("row_count") != receipt.get("row_count")
|
|
919
|
+
):
|
|
920
|
+
_fail(
|
|
921
|
+
"CRAWLER_CHILD_INVALID",
|
|
922
|
+
"crawler result does not bind the trusted retrieval evidence",
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _validate_public_https_result(
|
|
927
|
+
*,
|
|
928
|
+
request: HostedCrawlerRequest,
|
|
929
|
+
retrieved: Any,
|
|
930
|
+
result: Any,
|
|
931
|
+
) -> None:
|
|
932
|
+
"""Bind the public-HTTPS normalizer result to the trusted wrapper retrieval."""
|
|
933
|
+
|
|
934
|
+
receipt = result.acquisition_receipt
|
|
935
|
+
observation = result.observation
|
|
936
|
+
enhanced_cadence_evidence = request.schema_version == CRAWLER_REQUEST_SCHEMA_V3
|
|
937
|
+
receipt_fields = {
|
|
938
|
+
"schema_version",
|
|
939
|
+
"source_id",
|
|
940
|
+
"adapter_id",
|
|
941
|
+
"adapter_version",
|
|
942
|
+
"request_digest",
|
|
943
|
+
"source_url",
|
|
944
|
+
"final_url",
|
|
945
|
+
"fetched_media_type",
|
|
946
|
+
"normalized_media_type",
|
|
947
|
+
"normalized_data_format",
|
|
948
|
+
"normalized_filename",
|
|
949
|
+
"acquired_at",
|
|
950
|
+
"raw_content_sha256",
|
|
951
|
+
"raw_size_bytes",
|
|
952
|
+
"normalized_content_sha256",
|
|
953
|
+
"normalized_size_bytes",
|
|
954
|
+
"row_count",
|
|
955
|
+
"column_names",
|
|
956
|
+
"parsed_schema_digest",
|
|
957
|
+
"family_id",
|
|
958
|
+
"family_version",
|
|
959
|
+
"decode_options_digest",
|
|
960
|
+
"decode_flags",
|
|
961
|
+
"resource_caps",
|
|
962
|
+
"reader_budgets",
|
|
963
|
+
"transport_evidence_digest",
|
|
964
|
+
"egress_policy_attestation",
|
|
965
|
+
"clean_room",
|
|
966
|
+
}
|
|
967
|
+
if enhanced_cadence_evidence:
|
|
968
|
+
receipt_fields |= {
|
|
969
|
+
"row_digest",
|
|
970
|
+
"http_status_code",
|
|
971
|
+
"etag_digest",
|
|
972
|
+
"last_modified_digest",
|
|
973
|
+
}
|
|
974
|
+
observation_fields = {
|
|
975
|
+
"schema_version",
|
|
976
|
+
"source_id",
|
|
977
|
+
"request_digest",
|
|
978
|
+
"observed_at",
|
|
979
|
+
"raw_content_sha256",
|
|
980
|
+
"normalized_content_sha256",
|
|
981
|
+
"row_count",
|
|
982
|
+
}
|
|
983
|
+
if enhanced_cadence_evidence:
|
|
984
|
+
observation_fields |= {
|
|
985
|
+
"row_digest",
|
|
986
|
+
"http_status_code",
|
|
987
|
+
"etag_digest",
|
|
988
|
+
"last_modified_digest",
|
|
989
|
+
}
|
|
990
|
+
if (
|
|
991
|
+
not isinstance(receipt, dict)
|
|
992
|
+
or set(receipt) != receipt_fields
|
|
993
|
+
or not isinstance(observation, dict)
|
|
994
|
+
or set(observation) != observation_fields
|
|
995
|
+
):
|
|
996
|
+
_fail(
|
|
997
|
+
"CRAWLER_CHILD_INVALID",
|
|
998
|
+
"public HTTPS result evidence fields are not exact",
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
query = request.query
|
|
1002
|
+
reader = query.get("reader_pin")
|
|
1003
|
+
if reader is None:
|
|
1004
|
+
reader_identity = (None, None, None)
|
|
1005
|
+
expected_resource_caps = None
|
|
1006
|
+
expected_reader_budgets = None
|
|
1007
|
+
direct_identity = (
|
|
1008
|
+
query.get("data_format"),
|
|
1009
|
+
query.get("filename"),
|
|
1010
|
+
retrieved.media_type,
|
|
1011
|
+
)
|
|
1012
|
+
else:
|
|
1013
|
+
from mostlyright.data_harness.readers.contracts import ReaderPin
|
|
1014
|
+
|
|
1015
|
+
try:
|
|
1016
|
+
pin = ReaderPin(
|
|
1017
|
+
reader["family_id"],
|
|
1018
|
+
reader["family_version"],
|
|
1019
|
+
reader["decode_options"],
|
|
1020
|
+
)
|
|
1021
|
+
except (KeyError, TypeError, ValueError):
|
|
1022
|
+
_fail("CRAWLER_CHILD_INVALID", "public HTTPS Reader identity is invalid")
|
|
1023
|
+
reader_identity = (pin.family_id, pin.family_version, pin.options_digest)
|
|
1024
|
+
resource_caps = query.get("resource_caps")
|
|
1025
|
+
try:
|
|
1026
|
+
from mostlyright.data_harness.readers.registry import TOOLBOX
|
|
1027
|
+
|
|
1028
|
+
family = TOOLBOX.resolve(pin.family_id, pin.family_version)
|
|
1029
|
+
expected_reader_budgets = asdict(
|
|
1030
|
+
family.default_budgets.narrowed_by(
|
|
1031
|
+
{
|
|
1032
|
+
"max_input_bytes": query["limits"]["max_source_bytes"],
|
|
1033
|
+
"max_output_bytes": query["limits"]["max_normalized_bytes"],
|
|
1034
|
+
"max_rows": query["limits"]["max_rows"],
|
|
1035
|
+
"max_columns": query["limits"]["max_columns"],
|
|
1036
|
+
**(resource_caps or {}),
|
|
1037
|
+
}
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
except (KeyError, TypeError, ValueError):
|
|
1041
|
+
_fail("CRAWLER_CHILD_INVALID", "public HTTPS Reader budgets are invalid")
|
|
1042
|
+
expected_resource_caps = resource_caps
|
|
1043
|
+
direct_identity = (
|
|
1044
|
+
receipt.get("normalized_data_format"),
|
|
1045
|
+
receipt.get("normalized_filename"),
|
|
1046
|
+
receipt.get("normalized_media_type"),
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
normalized_digest = sha256_bytes(result.normalized_content)
|
|
1050
|
+
terminal = retrieved.hops[-1]
|
|
1051
|
+
expected_etag_digest = (
|
|
1052
|
+
None if terminal.etag is None else sha256_bytes(terminal.etag.encode("utf-8"))
|
|
1053
|
+
)
|
|
1054
|
+
expected_last_modified_digest = (
|
|
1055
|
+
None
|
|
1056
|
+
if terminal.last_modified is None
|
|
1057
|
+
else sha256_bytes(terminal.last_modified.encode("utf-8"))
|
|
1058
|
+
)
|
|
1059
|
+
row_count = receipt.get("row_count")
|
|
1060
|
+
column_names = receipt.get("column_names")
|
|
1061
|
+
limits = query.get("limits", {})
|
|
1062
|
+
expected_identity = (
|
|
1063
|
+
request.source_id,
|
|
1064
|
+
request.adapter_id,
|
|
1065
|
+
request.adapter_version,
|
|
1066
|
+
request.digest,
|
|
1067
|
+
)
|
|
1068
|
+
if (
|
|
1069
|
+
(
|
|
1070
|
+
receipt.get("source_id"),
|
|
1071
|
+
receipt.get("adapter_id"),
|
|
1072
|
+
receipt.get("adapter_version"),
|
|
1073
|
+
receipt.get("request_digest"),
|
|
1074
|
+
)
|
|
1075
|
+
!= expected_identity
|
|
1076
|
+
or receipt.get("schema_version")
|
|
1077
|
+
!= (
|
|
1078
|
+
"hosted-public-https-acquisition-receipt.v3"
|
|
1079
|
+
if enhanced_cadence_evidence
|
|
1080
|
+
else "hosted-public-https-acquisition-receipt.v1"
|
|
1081
|
+
)
|
|
1082
|
+
or observation.get("schema_version")
|
|
1083
|
+
!= (
|
|
1084
|
+
"hosted-public-https-source-observation.v3"
|
|
1085
|
+
if enhanced_cadence_evidence
|
|
1086
|
+
else "hosted-public-https-source-observation.v1"
|
|
1087
|
+
)
|
|
1088
|
+
or result.schema_version
|
|
1089
|
+
!= (CRAWLER_RESULT_SCHEMA_V3 if enhanced_cadence_evidence else CRAWLER_RESULT_SCHEMA_V1)
|
|
1090
|
+
or observation.get("source_id") != request.source_id
|
|
1091
|
+
or observation.get("request_digest") != request.digest
|
|
1092
|
+
or receipt.get("source_url") != query.get("url")
|
|
1093
|
+
or receipt.get("source_url") != retrieved.source_url
|
|
1094
|
+
or receipt.get("final_url") != retrieved.final_url
|
|
1095
|
+
or receipt.get("fetched_media_type") != retrieved.media_type
|
|
1096
|
+
or receipt.get("acquired_at") != request.requested_at
|
|
1097
|
+
or receipt.get("raw_content_sha256") != retrieved.content_sha256
|
|
1098
|
+
or receipt.get("raw_size_bytes") != len(retrieved.content)
|
|
1099
|
+
or receipt.get("transport_evidence_digest") != retrieved.transport_evidence_digest
|
|
1100
|
+
or receipt.get("egress_policy_attestation") != request.egress_policy_attestation
|
|
1101
|
+
or receipt.get("normalized_content_sha256") != normalized_digest
|
|
1102
|
+
or receipt.get("normalized_size_bytes") != len(result.normalized_content)
|
|
1103
|
+
or (
|
|
1104
|
+
receipt.get("family_id"),
|
|
1105
|
+
receipt.get("family_version"),
|
|
1106
|
+
receipt.get("decode_options_digest"),
|
|
1107
|
+
)
|
|
1108
|
+
!= reader_identity
|
|
1109
|
+
or (
|
|
1110
|
+
receipt.get("normalized_data_format"),
|
|
1111
|
+
receipt.get("normalized_filename"),
|
|
1112
|
+
receipt.get("normalized_media_type"),
|
|
1113
|
+
)
|
|
1114
|
+
!= direct_identity
|
|
1115
|
+
or not isinstance(receipt.get("decode_flags"), list)
|
|
1116
|
+
or receipt.get("resource_caps") != expected_resource_caps
|
|
1117
|
+
or receipt.get("reader_budgets") != expected_reader_budgets
|
|
1118
|
+
or (reader is None and receipt.get("decode_flags") != [])
|
|
1119
|
+
or type(row_count) is not int
|
|
1120
|
+
or not 0 <= row_count <= limits.get("max_rows", -1)
|
|
1121
|
+
or not isinstance(column_names, list)
|
|
1122
|
+
or not 1 <= len(column_names) <= limits.get("max_columns", -1)
|
|
1123
|
+
or any(not isinstance(name, str) or not name for name in column_names)
|
|
1124
|
+
or not isinstance(receipt.get("parsed_schema_digest"), str)
|
|
1125
|
+
or _DIGEST.fullmatch(receipt["parsed_schema_digest"]) is None
|
|
1126
|
+
or receipt.get("clean_room")
|
|
1127
|
+
!= {
|
|
1128
|
+
"host_platform": "linux-amd64",
|
|
1129
|
+
"parser_network_syscalls": "denied",
|
|
1130
|
+
"parser_process_creation": "denied",
|
|
1131
|
+
"no_new_privs": True,
|
|
1132
|
+
}
|
|
1133
|
+
or observation.get("observed_at") != request.requested_at
|
|
1134
|
+
or observation.get("raw_content_sha256") != retrieved.content_sha256
|
|
1135
|
+
or observation.get("normalized_content_sha256") != normalized_digest
|
|
1136
|
+
or observation.get("row_count") != row_count
|
|
1137
|
+
or (
|
|
1138
|
+
enhanced_cadence_evidence
|
|
1139
|
+
and (
|
|
1140
|
+
receipt.get("http_status_code") != terminal.status
|
|
1141
|
+
or receipt.get("etag_digest") != expected_etag_digest
|
|
1142
|
+
or receipt.get("last_modified_digest") != expected_last_modified_digest
|
|
1143
|
+
or not isinstance(receipt.get("row_digest"), str)
|
|
1144
|
+
or _DIGEST.fullmatch(receipt["row_digest"]) is None
|
|
1145
|
+
or observation.get("row_digest") != receipt["row_digest"]
|
|
1146
|
+
or observation.get("http_status_code") != terminal.status
|
|
1147
|
+
or observation.get("etag_digest") != expected_etag_digest
|
|
1148
|
+
or observation.get("last_modified_digest") != expected_last_modified_digest
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
or result.next_watermark is not None
|
|
1152
|
+
):
|
|
1153
|
+
_fail(
|
|
1154
|
+
"CRAWLER_CHILD_INVALID",
|
|
1155
|
+
"public HTTPS result does not bind the trusted retrieval evidence",
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _upload_result(capability: UploadCapability, content: bytes) -> None:
|
|
1160
|
+
parsed = urlsplit(capability.upload_url)
|
|
1161
|
+
target = parsed.path + "?" + parsed.query
|
|
1162
|
+
connection = http.client.HTTPSConnection(
|
|
1163
|
+
"storage.googleapis.com",
|
|
1164
|
+
443,
|
|
1165
|
+
timeout=UPLOAD_TIMEOUT_SECONDS,
|
|
1166
|
+
context=ssl.create_default_context(),
|
|
1167
|
+
)
|
|
1168
|
+
try:
|
|
1169
|
+
connection.putrequest("PUT", target, skip_host=False, skip_accept_encoding=True)
|
|
1170
|
+
connection.putheader("Content-Length", str(len(content)))
|
|
1171
|
+
for name, value in capability.required_headers:
|
|
1172
|
+
connection.putheader(name, value)
|
|
1173
|
+
connection.endheaders(content)
|
|
1174
|
+
response = connection.getresponse()
|
|
1175
|
+
response_body = response.read(64 * 1024 + 1)
|
|
1176
|
+
if response.status not in {200, 201} or len(response_body) > 64 * 1024:
|
|
1177
|
+
_fail("CRAWLER_UPLOAD_FAILED", "quarantine result upload failed")
|
|
1178
|
+
except (OSError, ssl.SSLError, http.client.HTTPException):
|
|
1179
|
+
_fail("CRAWLER_UPLOAD_FAILED", "quarantine result upload failed")
|
|
1180
|
+
finally:
|
|
1181
|
+
connection.close()
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _scrub_argv(argv: list[str] | None) -> None:
|
|
1185
|
+
target = sys.argv if argv is None else argv
|
|
1186
|
+
for index in range(1, len(target)):
|
|
1187
|
+
target[index] = "[redacted]"
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def crawler_job_main(argv: list[str] | None = None) -> int:
|
|
1191
|
+
parser = argparse.ArgumentParser(prog="mr-data-crawler-job")
|
|
1192
|
+
parser.add_argument("--crawler-session-id", required=True)
|
|
1193
|
+
arguments = parser.parse_args(argv)
|
|
1194
|
+
crawler_session_id = arguments.crawler_session_id
|
|
1195
|
+
arguments.crawler_session_id = "[redacted]"
|
|
1196
|
+
_scrub_argv(argv)
|
|
1197
|
+
bootstrap: CrawlerBootstrap | None = None
|
|
1198
|
+
request: HostedCrawlerRequest | None = None
|
|
1199
|
+
try:
|
|
1200
|
+
bucket = os.environ.get(RESULTS_BUCKET_ENV)
|
|
1201
|
+
if not isinstance(bucket, str) or _BUCKET.fullmatch(bucket) is None:
|
|
1202
|
+
_fail(
|
|
1203
|
+
"CRAWLER_RESULTS_BUCKET_REQUIRED",
|
|
1204
|
+
"crawler quarantine bucket pin is unavailable",
|
|
1205
|
+
)
|
|
1206
|
+
bootstrap = _redeem_crawler_bootstrap(
|
|
1207
|
+
crawler_session_id=crawler_session_id,
|
|
1208
|
+
bucket=bucket,
|
|
1209
|
+
)
|
|
1210
|
+
request = parse_crawler_request(parse_canonical_json(bootstrap.request_bytes))
|
|
1211
|
+
if request.adapter_id == "public.https" and request.query.get("reader_pin") is not None:
|
|
1212
|
+
_require_reader_memory_boundary()
|
|
1213
|
+
_run_crawler_warm_up(request)
|
|
1214
|
+
retrieved = fetch_crawler_source(
|
|
1215
|
+
request,
|
|
1216
|
+
expected_egress_policy_attestation=request.egress_policy_attestation,
|
|
1217
|
+
)
|
|
1218
|
+
normalization_input = encode_normalization_input(request, retrieved)
|
|
1219
|
+
result_bytes = _run_crawler_child(request, normalization_input)
|
|
1220
|
+
result = parse_crawler_result(parse_canonical_json(result_bytes))
|
|
1221
|
+
# Constructed for its __post_init__ check, not for the value: the result must
|
|
1222
|
+
# carry this request's digest, source id, adapter id, and adapter version.
|
|
1223
|
+
HostedCrawlerExchange(request=request, result=result)
|
|
1224
|
+
_validate_result_against_retrieval(
|
|
1225
|
+
request=request,
|
|
1226
|
+
retrieved=retrieved,
|
|
1227
|
+
result=result,
|
|
1228
|
+
)
|
|
1229
|
+
if canonical_json_bytes(result.to_dict()) != result_bytes:
|
|
1230
|
+
_fail("CRAWLER_CHILD_INVALID", "crawler result bytes are not exact canonical JSON")
|
|
1231
|
+
_upload_result(bootstrap.upload_capability, result_bytes)
|
|
1232
|
+
return 0
|
|
1233
|
+
except (
|
|
1234
|
+
CanonicalJSONError,
|
|
1235
|
+
HostedCrawlerJobError,
|
|
1236
|
+
HostedCrawlerProtocolError,
|
|
1237
|
+
TypeError,
|
|
1238
|
+
ValueError,
|
|
1239
|
+
subprocess.SubprocessError,
|
|
1240
|
+
) as error:
|
|
1241
|
+
code = getattr(error, "code", "CRAWLER_JOB_FAILED")
|
|
1242
|
+
if bootstrap is None and code in PRE_BOOTSTRAP_FAILURE_CODES:
|
|
1243
|
+
# There is no upload capability before a successful bootstrap, so
|
|
1244
|
+
# this narrow redacted report is the only way Studio can release
|
|
1245
|
+
# capacity and expose the terminal cause before clients time out.
|
|
1246
|
+
_report_crawler_bootstrap_failure(
|
|
1247
|
+
crawler_session_id=crawler_session_id,
|
|
1248
|
+
failure_code=code,
|
|
1249
|
+
)
|
|
1250
|
+
if bootstrap is not None and request is not None and code in HOSTED_CRAWLER_FAILURE_CODES:
|
|
1251
|
+
failure = HostedCrawlerFailure(
|
|
1252
|
+
request_digest=request.digest,
|
|
1253
|
+
source_id=request.source_id,
|
|
1254
|
+
adapter_id=request.adapter_id,
|
|
1255
|
+
adapter_version=request.adapter_version,
|
|
1256
|
+
failure_code=code,
|
|
1257
|
+
)
|
|
1258
|
+
try:
|
|
1259
|
+
_upload_result(
|
|
1260
|
+
bootstrap.upload_capability,
|
|
1261
|
+
canonical_json_bytes(failure.to_dict()),
|
|
1262
|
+
)
|
|
1263
|
+
except HostedCrawlerJobError:
|
|
1264
|
+
pass
|
|
1265
|
+
else:
|
|
1266
|
+
sys.stderr.write(
|
|
1267
|
+
canonical_json_bytes({"status": "reported", "code": code}).decode()
|
|
1268
|
+
)
|
|
1269
|
+
sys.stderr.write("\n")
|
|
1270
|
+
return 0
|
|
1271
|
+
sys.stderr.write(canonical_json_bytes({"status": "failed", "code": code}).decode())
|
|
1272
|
+
sys.stderr.write("\n")
|
|
1273
|
+
return 1
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1277
|
+
raise SystemExit(crawler_job_main())
|