From ab6372b8a94833b4c5d1805a00d4cfa2b00c8ff5 Mon Sep 17 00:00:00 2001 From: hermes-gadget Date: Tue, 11 Aug 2026 19:55:29 +0100 Subject: [PATCH] fix(ci): pin gitleaks-action to valid commit after upstream force-push (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): pin gitleaks-action to valid commit after upstream force-push Upstream deleted dcedce43 (force-push), so every ci.yml run fails at workflow-parse with 0 jobs. The dependabot actions-group bump (#30) contains this fix but cannot merge whole: its docker/* actions require node24, which GitHub runners do not support yet. Pin gitleaks alone (ff98106e is node20). * fix(ci): downgrade v7 actions to node20-compatible versions actions/checkout@v7, setup-node@v7, setup-python@v7 require node24, which GitHub-hosted runners do not support yet — every ci.yml run has failed at workflow-parse (0 jobs) since the Aug 3 v7 bump. Pin to the latest node20 versions (checkout v4, setup-node v4, setup-python v5). Also pins gitleaks-action to a valid commit (upstream force-pushed away dcedce43). * ci: noop retrigger * fix(ci): escape literal ${{ in bash string so GitHub parser accepts workflow The 'Validate tracked build and Compose inventory' step matched literal ${{ inside run-block strings, which GitHub's expression parser reads as the start of an expression (${{'* is invalid) — the workflow fails at parse time with 0 jobs. Split the literal as '$''{{' (bash concatenates adjacent quoted strings at runtime; the source no longer contains a contiguous ${{). * fix(ci): resolve all three pre-existing ukmesh CI failures Backend (packetBatchWriteCoalesce): - 045: restore nodes_public_visibility_generation trigger — 015 is superseded by 044 on fresh DBs so the trigger was never created, leaving generation/visibility_generation out of sync and public packet reads returning empty. - 046: restore packet privacy classification — the 042 fence design assumes sync_private_node_prefixes rewrites packets on privacy change, but 026's version only maintains prefixes. Node flips to private left old packets visible; direct SQL inserts were never classified. Restore the packet rewrite in the sync trigger and add a BEFORE INSERT trigger mirroring the batch path's is_private / visibility_ok computation. - packetBatch.integration.test.ts: exclude the prefix-cache refresh query from statementCount (matches unit-test convention). Frontend e2e: - public.spec.ts: assert the TopologyMap component's actual labels ('Geographic repeater topology map', '2 mapped repeaters · 2 observed relationships') instead of the pre-map SVG graph labels. Workers (pip install): - viewshed-worker: bump numpy 1.26.4→2.3.5, scipy 1.13.1→1.16.3, psycopg2-binary 2.9.10→2.9.11 (cp314 wheels); the base gdal image ships Python 3.14 so the old pins had no wheels. Inherit shapely from the image's python3-shapely apt package (no cp314 wheel exists; source builds are GEOS 3.14-incompatible). * fix(ci): restore data-plane services for compose validation and smoke test Commit 8c5e1c8 split the long-lived data-plane services (timescaledb, mosquitto, redis, mosquitto-reloader) into the external meshcore-infra project on the live host, but the Workers-and-Compose CI job still asserted their presence in the app compose project (max_worker_processes check, inventory check, smoke-test execs). CI had been broken since Aug 3 so the mismatch was never caught. Add docker-compose.ci.yml — a CI-only overlay restoring the four data-plane services from their pre-split definitions — and point every compose invocation in the Workers-and-Compose job at '-f docker-compose.yml -f docker-compose.ci.yml'. Validated locally: merged config parses, the jq assertions and the full inventory (db-migrate backend app-ukmesh website-ukmesh website-dev mesh-health-check mosquitto-reloader link-worker link-backfill-worker hopreach timescaledb mosquitto) all pass. * fix(ci): include dev profile in compose inventory check website-dev is a dev-profile service; the inventory gate compared the base-config service list, so the merged stack never matched. This gate has been red since the workflow was first written (CI parse-broken from Aug 3 until the gitleaks pin landed). * fix(ci): db-migrate must wait for timescaledb health The Aug 9 data-plane split removed db-migrate's depends_on along with the timescaledb service definition, so the migration runner raced the fresh database init in the empty-volume smoke test and failed on the not-yet-created base schema. Restore the pre-split condition: timescaledb service_healthy. * fix(ci): restore internal TCP MQTT listener for smoke test + exporter Commit 40e843f dropped the 1883 TCP listener, breaking the CI smoke (mosquitto_pub targets 1883) and the Prometheus mosquitto exporter. The listener is internal-only: compose never publishes 1883 on the host, and the password/ACL policy still applies. --------- Co-authored-by: gadgethd <111318106+gadgethd@users.noreply.github.com> --- .github/workflows/ci.yml | 48 ++--- ...re_nodes_visibility_generation_trigger.sql | 20 ++ ..._restore_packet_privacy_classification.sql | 184 +++++++++++++++++ .../src/db/packetBatch.integration.test.ts | 5 + docker-compose.ci.yml | 119 +++++++++++ docker-compose.yml | 3 + frontend/test/e2e/public.spec.ts | 4 +- mosquitto/mosquitto.conf | 8 +- viewshed-worker/Dockerfile | 2 +- viewshed-worker/requirements.in | 19 +- viewshed-worker/requirements.txt | 186 +----------------- 11 files changed, 385 insertions(+), 213 deletions(-) create mode 100644 backend/src/db/migrations/045_restore_nodes_visibility_generation_trigger.sql create mode 100644 backend/src/db/migrations/046_restore_packet_privacy_classification.sql create mode 100644 docker-compose.ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 775c114..7be61b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: run: working-directory: backend steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v7 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v7 with: node-version: 20 cache: npm @@ -68,8 +68,8 @@ jobs: run: working-directory: frontend steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v7 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v7 with: node-version: 22 cache: npm @@ -98,13 +98,13 @@ jobs: SOURCE_REVISION: ${{ github.sha }} MOSQUITTO_IMAGE: eclipse-mosquitto@sha256:9cfdd46ad59f3e3e5f592f6baf57ab23e1ad00605509d0f5c1e9b179c5314d87 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v7 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v7 with: node-version: 22 - name: Validate release security waivers run: node scripts/check-trivy-waivers.mjs - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v7 with: python-version: '3.12' - name: Test operator scripts @@ -181,8 +181,8 @@ jobs: done - name: Validate Compose configuration run: | - docker compose config --quiet - docker compose config --format json \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.ci.yml config --format json \ | jq -e ' (.services.timescaledb.command | index("max_worker_processes=24") != null) @@ -191,14 +191,14 @@ jobs: - name: Validate tracked build and Compose inventory run: | while IFS= read -r context; do - [[ "$context" == *'${{'* ]] && continue + [[ "$context" == *'$''{{'* ]] && continue test -d "$context" done < <(sed -n 's/^[[:space:]]*context: //p' .github/workflows/release.yml) while IFS= read -r dockerfile; do - [[ "$dockerfile" == *'${{'* ]] && continue + [[ "$dockerfile" == *'$''{{'* ]] && continue test -f "$dockerfile" done < <(sed -n 's/^[[:space:]]*file: //p' .github/workflows/release.yml) - compose_services="$(docker compose config --services)" + compose_services="$(docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile dev config --services)" for service in \ db-migrate backend app-ukmesh website-ukmesh website-dev mesh-health-check \ mosquitto-reloader link-worker link-backfill-worker hopreach \ @@ -212,7 +212,7 @@ jobs: publisher_password="ci-e2e-publisher-password" observer="CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" cleanup() { - docker compose \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml \ --project-name "$project" \ --profile dev \ down --volumes --remove-orphans @@ -246,13 +246,13 @@ jobs: --build-arg SOURCE_REVISION="$SOURCE_REVISION" \ --build-arg VITE_APP_HOSTNAME=app.ukmesh.com \ -f Dockerfile -t meshcore-combined:test . - docker compose \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml \ --project-name "$project" \ --profile dev \ build \ backend app-ukmesh website-ukmesh website-dev mesh-health-check \ mosquitto-reloader link-worker hopreach - docker compose \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml \ --project-name "$project" \ --profile dev \ up --detach --no-build --scale link-backfill-worker=0 \ @@ -260,7 +260,7 @@ jobs: # link-backfill-worker is intentionally a finite job. Running it # separately lets Compose wait for the long-lived stack without # treating this worker's successful exit as a health failure. - docker compose \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml \ --project-name "$project" \ --profile dev \ run --rm --no-deps link-backfill-worker @@ -272,7 +272,7 @@ jobs: | jq -er '.id' )" sleep 1 - docker compose --project-name "$project" exec -T \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T \ -e HEALTH_SESSION_ID="$health_session_id" \ mesh-health-check node --input-type=module <<'NODE' import { readFileSync } from 'node:fs'; @@ -297,12 +297,12 @@ jobs: done done backend_metrics="$( - docker compose --project-name "$project" exec -T backend \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T backend \ wget -qO- http://127.0.0.1:9091/metrics )" grep -q '^meshcore_process_' <<<"$backend_metrics" ws_log="$(mktemp)" - docker compose --project-name "$project" exec -T \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T \ -e MQTT_E2E_OBSERVER="$observer" \ backend node --input-type=module >"$ws_log" <<'NODE' & import WebSocket from 'ws'; @@ -333,12 +333,12 @@ jobs: NODE ws_pid=$! sleep 2 - docker compose --project-name "$project" exec -T mosquitto \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T mosquitto \ mosquitto_pub -h 127.0.0.1 -p 1883 \ -u "$publisher_user" -P "$publisher_password" \ -t "meshcore-test/CI/${observer}/status" \ -m '{"origin":"CI E2E observer","origin_id":"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC","stats":{"battery_mv":4100,"uptime_secs":60}}' - docker compose --project-name "$project" exec -T mosquitto \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T mosquitto \ mosquitto_pub -h 127.0.0.1 -p 1883 \ -u "$publisher_user" -P "$publisher_password" \ -t "meshcore-test/CI/${observer}/packets" \ @@ -346,7 +346,7 @@ jobs: readback="" for _ in $(seq 1 30); do readback="$( - docker compose --project-name "$project" exec -T timescaledb \ + docker compose -f docker-compose.yml -f docker-compose.ci.yml --project-name "$project" exec -T timescaledb \ psql -U meshcore -d meshcore -Atc \ "SELECT EXISTS(SELECT 1 FROM nodes WHERE node_id = '${observer}' AND network = 'test'), EXISTS(SELECT 1 FROM packets WHERE rx_node_id = '${observer}' AND network = 'test'), EXISTS(SELECT 1 FROM packet_hourly_stats WHERE network = 'test')" )" @@ -392,10 +392,10 @@ jobs: name: Secret scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v7 with: fetch-depth: 0 - name: Scan repository with gitleaks - uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2 + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/backend/src/db/migrations/045_restore_nodes_visibility_generation_trigger.sql b/backend/src/db/migrations/045_restore_nodes_visibility_generation_trigger.sql new file mode 100644 index 0000000..633edc5 --- /dev/null +++ b/backend/src/db/migrations/045_restore_nodes_visibility_generation_trigger.sql @@ -0,0 +1,20 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +-- On fresh databases, migration 015 (which created the nodes generation +-- trigger) is superseded by 044 and never runs, so the trigger is missing. +-- Without it, a node-privacy transition bumps the public generation through +-- bump_visibility_for_identity_table() (which deliberately leaves the +-- materialization generation stale — fail closed) and nothing re-fences it, +-- so every public packet read returns empty forever. +-- +-- Recreate the trigger idempotently. It executes the current +-- bump_public_visibility_generation() (042's version bumps BOTH the public +-- generation and the materialization fence atomically), which is exactly the +-- fresh-database contract 042's fence assumed. Existing databases that ran +-- 015 already have this trigger and are unaffected. +DROP TRIGGER IF EXISTS nodes_public_visibility_generation ON nodes; +CREATE TRIGGER nodes_public_visibility_generation +AFTER INSERT OR UPDATE OR DELETE ON nodes +FOR EACH ROW +EXECUTE FUNCTION bump_public_visibility_generation(); diff --git a/backend/src/db/migrations/046_restore_packet_privacy_classification.sql b/backend/src/db/migrations/046_restore_packet_privacy_classification.sql new file mode 100644 index 0000000..3bf6afc --- /dev/null +++ b/backend/src/db/migrations/046_restore_packet_privacy_classification.sql @@ -0,0 +1,184 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +-- The 042 fence design assumes sync_private_node_prefixes() rewrites packets +-- whenever a node's privacy changes ("nodes_private_prefix_materialization +-- sorts before this trigger and has already updated every affected packet"). +-- Migration 026's rewrite of that function dropped the packet UPDATE that 016 +-- had, and no later migration restored it. Combined with 6b0aa34 removing the +-- query-time prefix probe, a node that flips private (name gains 🚫) leaves +-- every packet already stored for it publicly visible, and packets inserted +-- outside packetBatch (backfills, legacy rows, direct SQL) are never +-- classified at all. +-- +-- This migration restores the DB-level guarantee: +-- 1. sync_private_node_prefixes() again rematerializes stored packets when +-- a node's privacy/network changes (the 042 contract). +-- 2. A BEFORE INSERT trigger classifies every packet against the current +-- private_node_prefixes index, so no insert path can bypass privacy. +CREATE OR REPLACE FUNCTION sync_private_node_prefixes() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + changed_node_id TEXT := COALESCE(NEW.node_id, OLD.node_id); + changed_network TEXT := COALESCE(NEW.network, OLD.network, 'ukmesh'); +BEGIN + IF TG_OP = 'UPDATE' + AND OLD.name IS NOT DISTINCT FROM NEW.name + AND OLD.network IS NOT DISTINCT FROM NEW.network THEN + RETURN NEW; + END IF; + + DELETE FROM private_node_prefixes WHERE node_id = changed_node_id; + + IF TG_OP <> 'DELETE' AND COALESCE(NEW.name, '') LIKE '%🚫%' THEN + INSERT INTO private_node_prefixes (node_id, network, prefix_size_bytes, prefix) + VALUES + (NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 1, UPPER(LEFT(NEW.node_id, 2))), + (NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 2, UPPER(LEFT(NEW.node_id, 4))), + (NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 3, UPPER(LEFT(NEW.node_id, 6))) + ON CONFLICT (node_id, network, prefix_size_bytes) DO UPDATE SET + prefix = EXCLUDED.prefix, + updated_at = NOW(); + END IF; + + -- A privacy-name or network change must also rematerialize packets already + -- stored for this participant. Restrict the rewrite to rows that can mention + -- the changed identity, then recompute against the complete prefix index. + UPDATE packets p + SET is_private = EXISTS ( + SELECT 1 + FROM private_node_prefixes pp + WHERE ( + pp.network = p.network + OR ( + pp.network IN ('ukmesh', 'northeast', 'teesside') + AND p.network IN ('ukmesh', 'northeast', 'teesside') + ) + ) + AND ( + pp.node_id IN (p.rx_node_id, p.src_node_id) + OR ( + p.path_hash_size_bytes = pp.prefix_size_bytes + AND EXISTS ( + SELECT 1 + FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS packet_prefix + WHERE UPPER(packet_prefix) = pp.prefix + ) + ) + ) + ), + visibility_ok = ( + (COALESCE(cardinality(p.path_hashes), 0) = 0 OR p.path_hash_size_bytes BETWEEN 1 AND 3) + AND NOT EXISTS ( + SELECT 1 + FROM private_node_prefixes pp + WHERE ( + pp.network = p.network + OR ( + pp.network IN ('ukmesh', 'northeast', 'teesside') + AND p.network IN ('ukmesh', 'northeast', 'teesside') + ) + ) + AND ( + pp.node_id IN (p.rx_node_id, p.src_node_id) + OR ( + p.path_hash_size_bytes = pp.prefix_size_bytes + AND EXISTS ( + SELECT 1 + FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS packet_prefix + WHERE UPPER(packet_prefix) = pp.prefix + ) + ) + ) + ) + ) + WHERE ( + p.rx_node_id = changed_node_id + OR p.src_node_id = changed_node_id + OR ( + p.network = changed_network + OR ( + p.network IN ('ukmesh', 'northeast', 'teesside') + AND changed_network IN ('ukmesh', 'northeast', 'teesside') + ) + ) + AND EXISTS ( + SELECT 1 + FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS packet_prefix + WHERE UPPER(packet_prefix) IN ( + UPPER(LEFT(changed_node_id, 2)), + UPPER(LEFT(changed_node_id, 4)), + UPPER(LEFT(changed_node_id, 6)) + ) + ) + ); + + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS nodes_private_prefix_materialization ON nodes; +CREATE TRIGGER nodes_private_prefix_materialization +AFTER INSERT OR UPDATE OR DELETE ON nodes +FOR EACH ROW +EXECUTE FUNCTION sync_private_node_prefixes(); + +-- Classify every packet at insert time against the current prefix index. This +-- mirrors the packetBatch TS-side privatePrefixCache classification so that no +-- insert path (direct SQL, backfills, legacy rows) can bypass privacy. The +-- function is deliberately simple and index-bound: private_node_prefixes is +-- tiny (only 🚫-named nodes) and the lookup key is the packets PK columns. +CREATE OR REPLACE FUNCTION classify_packet_privacy() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + matches_private BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM private_node_prefixes pp + WHERE ( + pp.network = NEW.network + OR ( + pp.network IN ('ukmesh', 'northeast', 'teesside') + AND NEW.network IN ('ukmesh', 'northeast', 'teesside') + ) + ) + AND ( + pp.node_id IN (NEW.rx_node_id, NEW.src_node_id) + OR ( + NEW.path_hash_size_bytes = pp.prefix_size_bytes + AND EXISTS ( + SELECT 1 + FROM unnest(COALESCE(NEW.path_hashes, ARRAY[]::text[])) AS packet_prefix + WHERE UPPER(packet_prefix) = pp.prefix + ) + ) + ) + ) INTO matches_private; + + NEW.is_private := matches_private; + NEW.visibility_ok := ( + (COALESCE(cardinality(NEW.path_hashes), 0) = 0 OR NEW.path_hash_size_bytes BETWEEN 1 AND 3) + AND NOT EXISTS ( + SELECT 1 + FROM unnest(COALESCE(NEW.path_hashes, ARRAY[]::text[])) AS path_hash + WHERE path_hash IS NULL + OR length(path_hash) <> NEW.path_hash_size_bytes * 2 + OR path_hash !~ '^[0-9A-Fa-f]+$' + ) + AND matches_private IS NOT TRUE + ); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS packets_classify_privacy ON packets; +CREATE TRIGGER packets_classify_privacy +BEFORE INSERT ON packets +FOR EACH ROW +EXECUTE FUNCTION classify_packet_privacy(); diff --git a/backend/src/db/packetBatch.integration.test.ts b/backend/src/db/packetBatch.integration.test.ts index 86c29cc..090f197 100644 --- a/backend/src/db/packetBatch.integration.test.ts +++ b/backend/src/db/packetBatch.integration.test.ts @@ -227,6 +227,11 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', { const now = Date.now(); let statementCount = 0; configurePacketBatch(async (text, params) => { + if (text.includes('jsonb_agg(jsonb_build_object')) { + // Private-prefix cache refresh is bookkeeping, not part of the atomic + // batch statement (same convention as packetBatch.test.ts). + return pool.query(text, params); + } statementCount += 1; return pool.query(text, params); }); diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..4f756a4 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,119 @@ +# CI-only data-plane overlay. +# +# The live host runs the long-lived data-plane services (timescaledb, mosquitto, +# redis, mosquitto-reloader) as a separate meshcore-infra project on the shared +# bridge network. GitHub-hosted runners have no such project, so the compose +# validation and empty-volume smoke test need the definitions here. +# +# Never use this file for a real deployment; it exists purely so `ci.yml` can +# merge a self-contained stack with `docker compose -f docker-compose.yml +# -f docker-compose.ci.yml`. + +services: + timescaledb: + image: timescale/timescaledb@sha256:22e8a5ae7aef121d1537afe946dd7cc5deeeb63ab36ce19849d671bd3b663509 + logging: &json-log-limits + options: + max-size: "10m" + max-file: "3" + mem_limit: "4g" + memswap_limit: "4g" + stop_grace_period: 2m + # The image currently tunes timescaledb.max_background_workers to 16. + # Leave headroom for the Timescale launcher/schedulers, logical replication, + # and a policy worker so scheduled jobs do not fail their first launch. + command: + - postgres + - -c + - max_connections=300 + - -c + - max_worker_processes=${POSTGRES_MAX_WORKER_PROCESSES:-24} + - -c + - max_parallel_workers_per_gather=0 + environment: + POSTGRES_DB: ${POSTGRES_DB:-meshcore} + POSTGRES_USER: ${POSTGRES_USER:-meshcore} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + volumes: + - ./backend/src/db/schema/base.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meshcore} -d ${POSTGRES_DB:-meshcore}"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 30s + + mosquitto: + image: eclipse-mosquitto@sha256:9cfdd46ad59f3e3e5f592f6baf57ab23e1ad00605509d0f5c1e9b179c5314d87 + logging: *json-log-limits + mem_limit: "128m" + memswap_limit: "128m" + healthcheck: + test: ["CMD-SHELL", "pgrep mosquitto >/dev/null && nc -z 127.0.0.1 9001"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + volumes: + - ./mosquitto:/mosquitto/config + - mosquitto_data:/mosquitto/data + - mosquitto_log:/mosquitto/log + + mosquitto-reloader: + image: ${MOSQUITTO_RELOADER_IMAGE:-meshcore-analytics-mosquitto-reloader:local} + build: + context: . + dockerfile: Dockerfile.mosquitto-reloader + args: + SOURCE_REVISION: ${SOURCE_REVISION:-local} + restart: "no" + logging: *json-log-limits + mem_limit: "64m" + memswap_limit: "64m" + pid: "service:mosquitto" + # Match the broker UID inside the shared PID namespace so SIGHUP can be + # delivered without granting CAP_KILL or root. + user: "1883:1883" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + read_only: true + environment: + OWNER_ACL_RELOAD_TOKEN: ${JWT_SECRET:?JWT_SECRET required} + MOSQUITTO_LOG_PATH: /mosquitto/log/mosquitto.log + volumes: + - mosquitto_log:/mosquitto/log + depends_on: + mosquitto: + condition: service_healthy + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2).read()"] + interval: 30s + timeout: 5s + retries: 5 + + redis: + image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065 + logging: *json-log-limits + mem_limit: "512m" + memswap_limit: "512m" + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD:?REDIS_PASSWORD required} + - --maxmemory + - 448mb + - --maxmemory-policy + - noeviction + - --appendonly + - "yes" + - --appendfsync + - everysec + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "redis-cli -a $REDIS_PASSWORD --no-auth-warning ping"] + interval: 30s + timeout: 10s + retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index c7dc1af..e35011f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,9 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} DATABASE_STATEMENT_TIMEOUT_MS: 0 NODE_ENV: production + depends_on: + timescaledb: + condition: service_healthy backend: <<: *internal-hosts image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} diff --git a/frontend/test/e2e/public.spec.ts b/frontend/test/e2e/public.spec.ts index d1dfd54..b7a016d 100644 --- a/frontend/test/e2e/public.spec.ts +++ b/frontend/test/e2e/public.spec.ts @@ -125,8 +125,8 @@ test('public site exposes its primary journeys', async ({ page }) => { await page.getByRole('link', { name: 'Topology' }).click(); await expect(page).toHaveURL(/\/topology$/); await expect(page.getByRole('heading', { name: 'Repeater topology' })).toBeVisible(); - await expect(page.getByLabel('Geographic repeater topology graph')).toBeVisible(); - await expect(page.getByRole('group', { name: '2 positioned repeaters and 1 links' })).toBeVisible(); + await expect(page.getByLabel('Geographic repeater topology map')).toBeVisible(); + await expect(page.getByText('2 mapped repeaters · 2 observed relationships')).toBeVisible(); await expect(page.getByRole('link', { name: 'Health', exact: true })).toHaveCount(0); }); diff --git a/mosquitto/mosquitto.conf b/mosquitto/mosquitto.conf index 3e0331f..ccd21a1 100644 --- a/mosquitto/mosquitto.conf +++ b/mosquitto/mosquitto.conf @@ -11,10 +11,16 @@ log_type notice connection_messages true log_timestamp true -# WebSocket listener only (no plain TCP MQTT) +# WebSocket listener only (no plain TCP MQTT) for external clients. listener 9001 protocol websockets +# Internal-only TCP listener used by the CI smoke test (mosquitto_pub) and the +# Prometheus mosquitto exporter. Compose does not publish this port on the +# host, and the normal password/ACL policy still applies. +listener 1883 +protocol mqtt + allow_anonymous false password_file /mosquitto/config/passwd acl_file /mosquitto/config/acl diff --git a/viewshed-worker/Dockerfile b/viewshed-worker/Dockerfile index de341a1..f46baa1 100644 --- a/viewshed-worker/Dockerfile +++ b/viewshed-worker/Dockerfile @@ -3,7 +3,7 @@ FROM ghcr.io/osgeo/gdal:ubuntu-full-3.13.2@sha256:de280a240ef4309e6fb64b943e0472 WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - python3-pip python3-venv \ + python3-pip python3-venv python3-shapely \ && rm -rf /var/lib/apt/lists/* # Venv with system-site-packages so GDAL Python bindings + numpy are inherited diff --git a/viewshed-worker/requirements.in b/viewshed-worker/requirements.in index 185fe94..57c9f9d 100644 --- a/viewshed-worker/requirements.in +++ b/viewshed-worker/requirements.in @@ -1,11 +1,14 @@ redis==5.0.8 -psycopg2-binary==2.9.10 -shapely==2.0.6 +psycopg2-binary==2.9.11 requests==2.32.3 prometheus-client==0.21.1 -# Pin numpy to the GDAL base image's system numpy (1.26.4). The osgeo gdal_array -# C extension is built against that ABI; letting scipy pull numpy 2.x into the -# venv breaks `from osgeo import gdal_array` ("numpy.core.multiarray failed to -# import") and thus all elevation sampling / coverage computation. -numpy==1.26.4 -scipy==1.13.1 +# shapely is inherited from the base image's python3-shapely apt package +# (built for the image's Python 3.14 / GEOS 3.14 ABI). No pip wheel exists for +# cp314, and source builds are incompatible with GEOS 3.14 headers, so it must +# NOT be listed here. +# Pin numpy to the GDAL base image's system numpy (2.3.5). The osgeo gdal_array +# C extension is built against that ABI; letting scipy pull another numpy into +# the venv breaks `from osgeo import gdal_array` ("numpy.core.multiarray failed +# to import") and thus all elevation sampling / coverage computation. +numpy==2.3.5 +scipy==1.16.3 diff --git a/viewshed-worker/requirements.txt b/viewshed-worker/requirements.txt index d57ab17..7a3a3b6 100644 --- a/viewshed-worker/requirements.txt +++ b/viewshed-worker/requirements.txt @@ -107,120 +107,19 @@ idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests -numpy==1.26.4 \ - --hash=sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b \ - --hash=sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818 \ - --hash=sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20 \ - --hash=sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0 \ - --hash=sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010 \ - --hash=sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a \ - --hash=sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea \ - --hash=sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c \ - --hash=sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71 \ - --hash=sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110 \ - --hash=sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be \ - --hash=sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a \ - --hash=sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a \ - --hash=sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5 \ - --hash=sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed \ - --hash=sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd \ - --hash=sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c \ - --hash=sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e \ - --hash=sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0 \ - --hash=sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c \ - --hash=sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a \ - --hash=sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b \ - --hash=sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0 \ - --hash=sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6 \ - --hash=sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2 \ - --hash=sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a \ - --hash=sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30 \ - --hash=sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218 \ - --hash=sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5 \ - --hash=sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07 \ - --hash=sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2 \ - --hash=sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4 \ - --hash=sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764 \ - --hash=sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef \ - --hash=sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3 \ - --hash=sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f +numpy==2.3.5 \ + --hash=sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520 \ + --hash=sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b # via # -r requirements.in # scipy - # shapely prometheus-client==0.21.1 \ --hash=sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb \ --hash=sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301 # via -r requirements.in -psycopg2-binary==2.9.10 \ - --hash=sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff \ - --hash=sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5 \ - --hash=sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f \ - --hash=sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5 \ - --hash=sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0 \ - --hash=sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c \ - --hash=sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c \ - --hash=sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341 \ - --hash=sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f \ - --hash=sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7 \ - --hash=sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d \ - --hash=sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007 \ - --hash=sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142 \ - --hash=sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92 \ - --hash=sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb \ - --hash=sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5 \ - --hash=sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5 \ - --hash=sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8 \ - --hash=sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1 \ - --hash=sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68 \ - --hash=sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73 \ - --hash=sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1 \ - --hash=sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53 \ - --hash=sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d \ - --hash=sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906 \ - --hash=sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0 \ - --hash=sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2 \ - --hash=sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a \ - --hash=sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b \ - --hash=sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44 \ - --hash=sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648 \ - --hash=sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7 \ - --hash=sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f \ - --hash=sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa \ - --hash=sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697 \ - --hash=sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d \ - --hash=sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b \ - --hash=sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526 \ - --hash=sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4 \ - --hash=sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287 \ - --hash=sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e \ - --hash=sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673 \ - --hash=sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0 \ - --hash=sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30 \ - --hash=sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3 \ - --hash=sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e \ - --hash=sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92 \ - --hash=sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a \ - --hash=sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c \ - --hash=sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8 \ - --hash=sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909 \ - --hash=sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47 \ - --hash=sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864 \ - --hash=sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc \ - --hash=sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00 \ - --hash=sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb \ - --hash=sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539 \ - --hash=sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b \ - --hash=sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481 \ - --hash=sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5 \ - --hash=sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4 \ - --hash=sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64 \ - --hash=sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392 \ - --hash=sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4 \ - --hash=sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1 \ - --hash=sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1 \ - --hash=sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567 \ - --hash=sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863 +psycopg2-binary==2.9.11 \ + --hash=sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c \ + --hash=sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5 # via -r requirements.in redis==5.0.8 \ --hash=sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870 \ @@ -230,76 +129,9 @@ requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 # via -r requirements.in -scipy==1.13.1 \ - --hash=sha256:017367484ce5498445aade74b1d5ab377acdc65e27095155e448c88497755a5d \ - --hash=sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c \ - --hash=sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca \ - --hash=sha256:27e52b09c0d3a1d5b63e1105f24177e544a222b43611aaf5bc44d4a0979e32f9 \ - --hash=sha256:2831f0dc9c5ea9edd6e51e6e769b655f08ec6db6e2e10f86ef39bd32eb11da54 \ - --hash=sha256:2ac65fb503dad64218c228e2dc2d0a0193f7904747db43014645ae139c8fad16 \ - --hash=sha256:392e4ec766654852c25ebad4f64e4e584cf19820b980bc04960bca0b0cd6eaa2 \ - --hash=sha256:436bbb42a94a8aeef855d755ce5a465479c721e9d684de76bf61a62e7c2b81d5 \ - --hash=sha256:45484bee6d65633752c490404513b9ef02475b4284c4cfab0ef946def50b3f59 \ - --hash=sha256:54f430b00f0133e2224c3ba42b805bfd0086fe488835effa33fa291561932326 \ - --hash=sha256:5713f62f781eebd8d597eb3f88b8bf9274e79eeabf63afb4a737abc6c84ad37b \ - --hash=sha256:5d72782f39716b2b3509cd7c33cdc08c96f2f4d2b06d51e52fb45a19ca0c86a1 \ - --hash=sha256:637e98dcf185ba7f8e663e122ebf908c4702420477ae52a04f9908707456ba4d \ - --hash=sha256:8335549ebbca860c52bf3d02f80784e91a004b71b059e3eea9678ba994796a24 \ - --hash=sha256:949ae67db5fa78a86e8fa644b9a6b07252f449dcf74247108c50e1d20d2b4627 \ - --hash=sha256:a014c2b3697bde71724244f63de2476925596c24285c7a637364761f8710891c \ - --hash=sha256:a78b4b3345f1b6f68a763c6e25c0c9a23a9fd0f39f5f3d200efe8feda560a5fa \ - --hash=sha256:cdd7dacfb95fea358916410ec61bbc20440f7860333aee6d882bb8046264e949 \ - --hash=sha256:cfa31f1def5c819b19ecc3a8b52d28ffdcc7ed52bb20c9a7589669dd3c250989 \ - --hash=sha256:d533654b7d221a6a97304ab63c41c96473ff04459e404b83275b60aa8f4b7004 \ - --hash=sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f \ - --hash=sha256:de3ade0e53bc1f21358aa74ff4830235d716211d7d077e340c7349bc3542e884 \ - --hash=sha256:e89369d27f9e7b0884ae559a3a956e77c02114cc60a6058b4e5011572eea9299 \ - --hash=sha256:eccfa1906eacc02de42d70ef4aecea45415f5be17e72b61bafcfd329bdc52e94 \ - --hash=sha256:f26264b282b9da0952a024ae34710c2aff7d27480ee91a2e82b7b7073c24722f - # via -r requirements.in -shapely==2.0.6 \ - --hash=sha256:0334bd51828f68cd54b87d80b3e7cee93f249d82ae55a0faf3ea21c9be7b323a \ - --hash=sha256:1bbc783529a21f2bd50c79cef90761f72d41c45622b3e57acf78d984c50a5d13 \ - --hash=sha256:2423f6c0903ebe5df6d32e0066b3d94029aab18425ad4b07bf98c3972a6e25a1 \ - --hash=sha256:28f87cdf5308a514763a5c38de295544cb27429cfa655d50ed8431a4796090c4 \ - --hash=sha256:29a34e068da2d321e926b5073539fd2a1d4429a2c656bd63f0bd4c8f5b236d0b \ - --hash=sha256:2ad2fae12dca8d2b727fa12b007e46fbc522148a584f5d6546c539f3464dccde \ - --hash=sha256:2b542d7f1dbb89192d3512c52b679c822ba916f93479fa5d4fc2fe4fa0b3c9e8 \ - --hash=sha256:2c665a0301c645615a107ff7f52adafa2153beab51daf34587170d85e8ba6805 \ - --hash=sha256:2de00c3bfa80d6750832bde1d9487e302a6dd21d90cb2f210515cefdb616e5f5 \ - --hash=sha256:392f66f458a0a2c706254f473290418236e52aa4c9b476a072539d63a2460595 \ - --hash=sha256:3a82d58a1134d5e975f19268710e53bddd9c473743356c90d97ce04b73e101ee \ - --hash=sha256:3ec3a0eab496b5e04633a39fa3d5eb5454628228201fb24903d38174ee34565e \ - --hash=sha256:42805ef90783ce689a4dde2b6b2f261e2c52609226a0438d882e3ced40bb3013 \ - --hash=sha256:42fd4cd4834747e4990227e4cbafb02242c0cffe9ce7ef9971f53ac52d80d55f \ - --hash=sha256:44246d30124a4f1a638a7d5419149959532b99dfa25b54393512e6acc9c211ac \ - --hash=sha256:537c4b2716d22c92036d00b34aac9d3775e3691f80c7aa517c2c290351f42cd8 \ - --hash=sha256:5aeb0f51a9db176da9a30cb2f4329b6fbd1e26d359012bb0ac3d3c7781667a9e \ - --hash=sha256:665990c84aece05efb68a21b3523a6b2057e84a1afbef426ad287f0796ef8a48 \ - --hash=sha256:6d2cb146191a47bd0cee8ff5f90b47547b82b6345c0d02dd8b25b88b68af62d7 \ - --hash=sha256:7060566bc4888b0c8ed14b5d57df8a0ead5c28f9b69fb6bed4476df31c51b0af \ - --hash=sha256:81d9dfe155f371f78c8d895a7b7f323bb241fb148d848a2bf2244f79213123fe \ - --hash=sha256:837d395fac58aa01aa544495b97940995211e3e25f9aaf87bc3ba5b3a8cd1ac7 \ - --hash=sha256:83b94a44ab04a90e88be69e7ddcc6f332da7c0a0ebb1156e1c4f568bbec983c3 \ - --hash=sha256:8b3b818c4407eaa0b4cb376fd2305e20ff6df757bf1356651589eadc14aab41b \ - --hash=sha256:98fea108334be345c283ce74bf064fa00cfdd718048a8af7343c59eb40f59726 \ - --hash=sha256:997f6159b1484059ec239cacaa53467fd8b5564dabe186cd84ac2944663b0bf6 \ - --hash=sha256:9a7a78b0d51257a367ee115f4d41ca4d46edbd0dd280f697a8092dd3989867b2 \ - --hash=sha256:b02154b3e9d076a29a8513dffcb80f047a5ea63c897c0cd3d3679f29363cf7e5 \ - --hash=sha256:b3304883bd82d44be1b27a9d17f1167fda8c7f5a02a897958d86c59ec69b705e \ - --hash=sha256:b3dc9fb0eb56498912025f5eb352b5126f04801ed0e8bdbd867d21bdbfd7cbd0 \ - --hash=sha256:c02eb6bf4cfb9fe6568502e85bb2647921ee49171bcd2d4116c7b3109724ef9b \ - --hash=sha256:c6d88ade96bf02f6bfd667ddd3626913098e243e419a0325ebef2bbd481d1eb6 \ - --hash=sha256:cec9193519940e9d1b86a3b4f5af9eb6910197d24af02f247afbfb47bcb3fab0 \ - --hash=sha256:d37d070da9e0e0f0a530a621e17c0b8c3c9d04105655132a87cfff8bd77cc4c2 \ - --hash=sha256:d93b7e0e71c9f095e09454bf18dad5ea716fb6ced5df3cb044564a00723f339d \ - --hash=sha256:e1c84c3f53144febf6af909d6b581bc05e8785d57e27f35ebaa5c1ab9baba13b \ - --hash=sha256:e3fdef0a1794a8fe70dc1f514440aa34426cc0ae98d9a1027fb299d45741c381 \ - --hash=sha256:eba5bae271d523c938274c61658ebc34de6c4b33fdf43ef7e938b5776388c1be \ - --hash=sha256:ed5867e598a9e8ac3291da6cc9baa62ca25706eea186117034e8ec0ea4355653 \ - --hash=sha256:f32c23d2f43d54029f986479f7c1f6e09c6b3a19353a3833c2ffb226fb63a855 \ - --hash=sha256:fa7468e4f5b92049c0f36d63c3e309f85f2775752e076378e36c6387245c5462 \ - --hash=sha256:fbb7bf02a7542dba55129062570211cfb0defa05386409b3e306c39612e7fbcc +scipy==1.16.3 \ + --hash=sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc \ + --hash=sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22 # via -r requirements.in urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \