Compare commits
14 Commits
f86fbd4939
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 46fb5e5b99 | |||
| fafe96ceb3 | |||
| 49172b57d3 | |||
| e98eb6d68f | |||
| f37fe8c4cb | |||
| 3d7e18a238 | |||
| f6a2d3a593 | |||
| f7f80fe684 | |||
| 0d6bbec9f3 | |||
| 7d5454a729 | |||
| cf68abf642 | |||
| 001f13209f | |||
| b5c033426e | |||
| 3d772f748c |
@@ -39,14 +39,36 @@ RETAIN_RUNS=100
|
|||||||
# TAL/TA input mode:
|
# TAL/TA input mode:
|
||||||
# file-with-ta: use packaged fixture TAL + TA only.
|
# file-with-ta: use packaged fixture TAL + TA only.
|
||||||
# file-live-ta: use packaged fixture TAL; snapshot waits for live TA refresh, delta refreshes TA in background.
|
# file-live-ta: use packaged fixture TAL; snapshot waits for live TA refresh, delta refreshes TA in background.
|
||||||
|
# custom-file-with-ta: use the custom TAL/TA below (requires exactly RIRS=custom);
|
||||||
|
# for the local test repo compose component or a third-party test TA.
|
||||||
# url: pass TAL URL to child process.
|
# url: pass TAL URL to child process.
|
||||||
TAL_INPUT_MODE=file-live-ta
|
TAL_INPUT_MODE=file-live-ta
|
||||||
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
|
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
|
||||||
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
|
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
|
||||||
LIVE_TA_REFRESH_MAX_TIME_SECS=120
|
LIVE_TA_REFRESH_MAX_TIME_SECS=120
|
||||||
|
|
||||||
|
# Custom TAL/TA mode (only active with RIRS=custom + TAL_INPUT_MODE=custom-file-with-ta).
|
||||||
|
# CUSTOM_FIXTURE_HOST_DIR is the host directory bind-mounted to /opt/ours-rp/custom-fixtures.
|
||||||
|
# CUSTOM_TAL_URI is recorded in CIR as the TAL origin URI.
|
||||||
|
# HTTP_ROOT_CERT_PATHS lists HTTPS TLS root certificates (space-separated, container
|
||||||
|
# paths) for self-signed RRDP/TAL servers; it is independent of the RPKI TA.
|
||||||
|
#RIRS=custom
|
||||||
|
#TAL_INPUT_MODE=custom-file-with-ta
|
||||||
|
#CUSTOM_FIXTURE_HOST_DIR=../custom-fixtures
|
||||||
|
#CUSTOM_TAL_PATH=/opt/ours-rp/custom-fixtures/tal/custom.tal
|
||||||
|
#CUSTOM_TA_PATH=/opt/ours-rp/custom-fixtures/ta/custom-ta.cer
|
||||||
|
#CUSTOM_TAL_URI=https://rpki-local-rrdp:8443/tal/custom.tal
|
||||||
|
#HTTP_ROOT_CERT_PATHS=/opt/ours-rp/custom-fixtures/certs/rrdp-ca.pem
|
||||||
|
# Optional shared docker network between the soak container and the stack local
|
||||||
|
# test repository component. When set, scripts/start.sh attaches the soak
|
||||||
|
# container to this external network before starting it.
|
||||||
|
#CUSTOM_REPO_NETWORK=rpki-local-repo-net
|
||||||
|
|
||||||
# Sync and runtime behavior.
|
# Sync and runtime behavior.
|
||||||
RSYNC_SCOPE=module-root
|
RSYNC_SCOPE=module-root
|
||||||
|
# HTTP User-Agent for all outgoing HTTP requests (RRDP / TAL / TA / dead-repo probes).
|
||||||
|
# Unset, empty, or header-illegal values fall back to panda-rpki/0.2.
|
||||||
|
#RPKI_HTTP_USER_AGENT=panda-rpki/0.2
|
||||||
DISABLE_COMPETING_RPS=0
|
DISABLE_COMPETING_RPS=0
|
||||||
RUN_ROOT=/var/lib/ours-rp
|
RUN_ROOT=/var/lib/ours-rp
|
||||||
DB_DIR=/var/lib/ours-rp/state/db
|
DB_DIR=/var/lib/ours-rp/state/db
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
RUN_ROOT: /var/lib/ours-rp
|
RUN_ROOT: /var/lib/ours-rp
|
||||||
BIN_DIR: /opt/ours-rp/bin
|
BIN_DIR: /opt/ours-rp/bin
|
||||||
FIXTURE_DIR: /opt/ours-rp/fixtures
|
FIXTURE_DIR: /opt/ours-rp/fixtures
|
||||||
|
CUSTOM_FIXTURE_DIR: /opt/ours-rp/custom-fixtures
|
||||||
volumes:
|
volumes:
|
||||||
- ../.env:/opt/ours-rp/.env:ro
|
- ../.env:/opt/ours-rp/.env:ro
|
||||||
|
- ${CUSTOM_FIXTURE_HOST_DIR:-../custom-fixtures}:/opt/ours-rp/custom-fixtures:ro
|
||||||
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state
|
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state
|
||||||
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs
|
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs
|
||||||
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs
|
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs
|
||||||
|
|||||||
19
deploy/docker-installer/custom-fixtures/README.md
Normal file
19
deploy/docker-installer/custom-fixtures/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# custom-fixtures 占位目录
|
||||||
|
|
||||||
|
默认空目录,仅作为 `compose/docker-compose.yml` 中
|
||||||
|
`${CUSTOM_FIXTURE_HOST_DIR:-../custom-fixtures}` 挂载的缺省目标,保证
|
||||||
|
RIR 默认模式下 compose 不因缺目录而失败。
|
||||||
|
|
||||||
|
启用 custom TAL 模式(`RIRS=custom` + `TAL_INPUT_MODE=custom-file-with-ta`)时,
|
||||||
|
把 `CUSTOM_FIXTURE_HOST_DIR` 指向包含以下结构的主机目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<fixture-root>/
|
||||||
|
├── tal/custom.tal # 自定义 TAL
|
||||||
|
├── ta/custom-ta.cer # 自定义 TA DER
|
||||||
|
└── certs/rrdp-ca.pem # 自签 HTTPS 服务的 TLS root cert(如需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
本仓库的 `rpki_local_repo_installer` 组件会在其 `$HOST_DATA_DIR/fixtures`
|
||||||
|
下生成完全兼容的目录结构;stack installer 的 `STACK_TA_MODE=local` 会自动
|
||||||
|
完成该指向。
|
||||||
@@ -54,6 +54,12 @@ Defaults:
|
|||||||
- `ALLOW_CROSS_ARCH=0`
|
- `ALLOW_CROSS_ARCH=0`
|
||||||
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; after deploying a separate RTR service, register its same-host report directory through `scripts/register_rtr_monitor.sh`
|
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; after deploying a separate RTR service, register its same-host report directory through `scripts/register_rtr_monitor.sh`
|
||||||
|
|
||||||
|
To integrate a local or third-party test TA, use the custom TAL mode
|
||||||
|
(`RIRS=custom` + `TAL_INPUT_MODE=custom-file-with-ta` together with
|
||||||
|
`CUSTOM_TAL_PATH` / `CUSTOM_TA_PATH` / `CUSTOM_TAL_URI` /
|
||||||
|
`HTTP_ROOT_CERT_PATHS` and the `CUSTOM_FIXTURE_HOST_DIR` mount); see the
|
||||||
|
commented section in `.env.example` for details.
|
||||||
|
|
||||||
## First Start Semantics
|
## First Start Semantics
|
||||||
|
|
||||||
If there is no successful run under `HOST_DATA_DIR/runs`, `start.sh` starts the core `ours-rp-soak` service first and waits for the first snapshot to succeed before starting metrics, Prometheus and Grafana.
|
If there is no successful run under `HOST_DATA_DIR/runs`, `start.sh` starts the core `ours-rp-soak` service first and waits for the first snapshot to succeed before starting metrics, Prometheus and Grafana.
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ vim .env
|
|||||||
- `ALLOW_CROSS_ARCH=0`
|
- `ALLOW_CROSS_ARCH=0`
|
||||||
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
|
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
|
||||||
|
|
||||||
|
如需对接本地或第三方测试 TA,可使用 custom TAL 模式(`RIRS=custom` +
|
||||||
|
`TAL_INPUT_MODE=custom-file-with-ta`,配合 `CUSTOM_TAL_PATH` / `CUSTOM_TA_PATH` /
|
||||||
|
`CUSTOM_TAL_URI` / `HTTP_ROOT_CERT_PATHS` 与 `CUSTOM_FIXTURE_HOST_DIR` 挂载),
|
||||||
|
配置项说明见 `.env.example` 中的注释段。
|
||||||
|
|
||||||
## 首次启动语义
|
## 首次启动语义
|
||||||
|
|
||||||
如果 `HOST_DATA_DIR/runs` 下没有成功 run,`start.sh` 会先启动核心 `ours-rp-soak`,等待第一轮 snapshot 成功后再启动 metrics、Prometheus 和 Grafana。
|
如果 `HOST_DATA_DIR/runs` 下没有成功 run,`start.sh` 会先启动核心 `ours-rp-soak`,等待第一轮 snapshot 成功后再启动 metrics、Prometheus 和 Grafana。
|
||||||
|
|||||||
@@ -47,7 +47,21 @@ if has_success_run; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "starting core soak service"
|
log "starting core soak service"
|
||||||
compose_cmd --profile core up -d ours-rp-soak
|
if [[ -n "${CUSTOM_REPO_NETWORK:-}" ]]; then
|
||||||
|
# Custom TAL mode against the stack local test repository: create the soak
|
||||||
|
# container without starting it, attach the shared external network first,
|
||||||
|
# then start it. This avoids the first snapshot racing the network connect.
|
||||||
|
compose_cmd --profile core up -d --no-start ours-rp-soak 2>/dev/null \
|
||||||
|
|| compose_cmd --profile core create ours-rp-soak
|
||||||
|
soak_container="${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-soak"
|
||||||
|
if ! docker inspect -f '{{json .NetworkSettings.Networks}}' "$soak_container" | grep -q "\"$CUSTOM_REPO_NETWORK\""; then
|
||||||
|
log "attaching soak container to custom repo network: $CUSTOM_REPO_NETWORK"
|
||||||
|
docker network connect "$CUSTOM_REPO_NETWORK" "$soak_container"
|
||||||
|
fi
|
||||||
|
compose_cmd --profile core start ours-rp-soak
|
||||||
|
else
|
||||||
|
compose_cmd --profile core up -d ours-rp-soak
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$had_success" == "0" && "$WAIT_FIRST_RUN" == "1" ]]; then
|
if [[ "$had_success" == "0" && "$WAIT_FIRST_RUN" == "1" ]]; then
|
||||||
log "no previous successful run found; waiting for first run timeout=${timeout_secs}s"
|
log "no previous successful run found; waiting for first run timeout=${timeout_secs}s"
|
||||||
|
|||||||
22
deploy/rpki-explorer/.env.example
Normal file
22
deploy/rpki-explorer/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# rpki-explorer standalone stack configuration.
|
||||||
|
# Copy to .env and adjust paths for the target host.
|
||||||
|
|
||||||
|
# Soak outputs on the host (mounted read-only).
|
||||||
|
SOAK_RUNS_DIR=/root/rpki096_x86_rtr_installer/data/runs
|
||||||
|
SOAK_DB_DIR=/root/rpki096_x86_rtr_installer/data/state/db
|
||||||
|
|
||||||
|
# Host directory where the query-db index is persisted (reused across restarts).
|
||||||
|
QUERY_DB_DIR=/root/rpki128_explorer/query-db
|
||||||
|
|
||||||
|
# Host loopback port the explorer is published on (SSH port-forward access).
|
||||||
|
LISTEN_PORT=9517
|
||||||
|
|
||||||
|
# Keep only the latest N indexed runs; first boot backfills the latest N runs.
|
||||||
|
RETAIN_RUNS=10
|
||||||
|
|
||||||
|
# Watch poll interval for newly completed runs.
|
||||||
|
WATCH_INTERVAL_SECS=10
|
||||||
|
|
||||||
|
# Image coordinates (built locally, transferred via docker save/load).
|
||||||
|
IMAGE_NAME=rpki-explorer
|
||||||
|
IMAGE_TAG=local
|
||||||
68
deploy/rpki-explorer/Dockerfile
Normal file
68
deploy/rpki-explorer/Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# rpki-explorer all-in-one image:
|
||||||
|
# - rpki_query_service (watch mode) + rpki_query_indexer
|
||||||
|
# - nginx serving the explorer SPA and proxying /api/v1/ to the query service
|
||||||
|
#
|
||||||
|
# Build context must be the repository root (rpki_2/rpki):
|
||||||
|
# docker build -f deploy/rpki-explorer/Dockerfile -t rpki-explorer:<tag> .
|
||||||
|
#
|
||||||
|
# Note: no `# syntax=` directive on purpose — the default images below are all
|
||||||
|
# expected to be present locally, and pulling the dockerfile frontend from
|
||||||
|
# docker.io may not be possible on offline build hosts.
|
||||||
|
|
||||||
|
ARG RUST_BUILDER_IMAGE=rust:1-bookworm
|
||||||
|
ARG NODE_BUILDER_IMAGE=node:slim
|
||||||
|
ARG RUNTIME_IMAGE=nginx:1.27-bookworm
|
||||||
|
|
||||||
|
FROM ${RUST_BUILDER_IMAGE} AS rust-builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
clang \
|
||||||
|
cmake \
|
||||||
|
git \
|
||||||
|
libclang-dev \
|
||||||
|
make \
|
||||||
|
perl \
|
||||||
|
pkg-config \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/src/target \
|
||||||
|
cargo build --release --bin rpki_query_service --bin rpki_query_indexer \
|
||||||
|
&& cp target/release/rpki_query_service target/release/rpki_query_indexer /usr/local/bin/
|
||||||
|
|
||||||
|
FROM ${NODE_BUILDER_IMAGE} AS ui-builder
|
||||||
|
|
||||||
|
WORKDIR /ui
|
||||||
|
|
||||||
|
COPY ui/rpki-explorer/package.json ui/rpki-explorer/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY ui/rpki-explorer/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
|
||||||
|
ARG GIT_REV=unknown
|
||||||
|
LABEL org.opencontainers.image.title="rpki-explorer" \
|
||||||
|
org.opencontainers.image.revision="${GIT_REV}"
|
||||||
|
|
||||||
|
COPY --from=rust-builder /usr/local/bin/rpki_query_service /usr/local/bin/rpki_query_service
|
||||||
|
COPY --from=rust-builder /usr/local/bin/rpki_query_indexer /usr/local/bin/rpki_query_indexer
|
||||||
|
COPY --from=ui-builder /ui/dist /usr/share/nginx/html
|
||||||
|
COPY deploy/rpki-explorer/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY deploy/rpki-explorer/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh && mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
76
deploy/rpki-explorer/README.md
Normal file
76
deploy/rpki-explorer/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# rpki-explorer standalone stack
|
||||||
|
|
||||||
|
One container running the RPKI Explorer web UI plus the query backend
|
||||||
|
(`rpki_query_service` in watch mode, spawning `rpki_query_indexer`), deployed
|
||||||
|
next to a running ours-RP soak. It mounts the soak outputs **read-only** and
|
||||||
|
keeps only the latest `RETAIN_RUNS` indexed runs.
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser ──ssh -L──► 127.0.0.1:9517 ──► container
|
||||||
|
├─ nginx :8080 (dist + /api/v1/ proxy)
|
||||||
|
└─ rpki_query_service :9557
|
||||||
|
├─ /data/query-db (host bind mount, rw, reused on restart)
|
||||||
|
├─ /soak/db/repo-bytes.db (ro)
|
||||||
|
└─ /soak/runs (ro, watched)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker with the compose plugin (`docker compose version`) on the target host.
|
||||||
|
- A running soak whose `runs/` directories contain `report.json` (and
|
||||||
|
`run-summary.json` once finished) — only completed runs get indexed.
|
||||||
|
|
||||||
|
## Build (local machine, same arch as the target)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deploy/rpki-explorer/build_image.sh
|
||||||
|
# tarball + provenance land in target/rpki-explorer-image/
|
||||||
|
```
|
||||||
|
|
||||||
|
The build runs entirely inside Docker (rust release binaries + vite build),
|
||||||
|
so no local toolchain version requirements apply.
|
||||||
|
|
||||||
|
## Transfer and deploy (remote host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat target/rpki-explorer-image/rpki-explorer-<tag>.tar.gz | ssh root@<host> 'gunzip | docker load'
|
||||||
|
|
||||||
|
ssh root@<host>
|
||||||
|
mkdir -p /root/rpki128_explorer
|
||||||
|
cd /root/rpki128_explorer
|
||||||
|
# place docker-compose.yml and .env here (see .env.example)
|
||||||
|
docker compose up -d
|
||||||
|
docker logs -f rpki-explorer
|
||||||
|
```
|
||||||
|
|
||||||
|
On first boot the entrypoint computes
|
||||||
|
`--watch-min-run-seq = latest_completed - RETAIN_RUNS + 1` so exactly the
|
||||||
|
latest `RETAIN_RUNS` completed runs are backfilled. On later boots the
|
||||||
|
existing query-db drives continuation — the index built earlier is reused, no
|
||||||
|
re-backfill happens.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 9517:127.0.0.1:9517 root@<host>
|
||||||
|
# open http://127.0.0.1:9517
|
||||||
|
```
|
||||||
|
|
||||||
|
The stack binds loopback only; there is no authentication or TLS.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Rebuild the image with a new tag, transfer and `docker load`, update
|
||||||
|
`IMAGE_TAG` in `.env`, then `docker compose up -d`. The query-db on the host
|
||||||
|
is preserved.
|
||||||
|
|
||||||
|
## Rollback / removal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
# keep /root/rpki128_explorer/query-db to reuse the index later, or delete it
|
||||||
|
# to force a fresh backfill of the latest RETAIN_RUNS runs.
|
||||||
|
```
|
||||||
|
|
||||||
|
The soak stack is never touched; removing the explorer stack has no effect on
|
||||||
|
it.
|
||||||
37
deploy/rpki-explorer/build_image.sh
Executable file
37
deploy/rpki-explorer/build_image.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the rpki-explorer all-in-one image locally and export it as a tarball
|
||||||
|
# ready to transfer to a remote host (docker load on the other side).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/rpki-explorer-image}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-rpki-explorer}"
|
||||||
|
GIT_REV="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-$GIT_REV}"
|
||||||
|
|
||||||
|
dirty_count="$(git -C "$REPO_ROOT" status --porcelain | wc -l)"
|
||||||
|
|
||||||
|
echo "[build] repo=$REPO_ROOT rev=$GIT_REV dirty_files=$dirty_count tag=$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--file "$REPO_ROOT/deploy/rpki-explorer/Dockerfile" \
|
||||||
|
--build-arg "GIT_REV=$GIT_REV" \
|
||||||
|
--tag "$IMAGE_NAME:$IMAGE_TAG" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
TARBALL="$OUT_DIR/rpki-explorer-$IMAGE_TAG.tar.gz"
|
||||||
|
docker save "$IMAGE_NAME:$IMAGE_TAG" | gzip > "$TARBALL"
|
||||||
|
|
||||||
|
IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$IMAGE_NAME:$IMAGE_TAG")"
|
||||||
|
cat > "$OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt" <<EOF
|
||||||
|
git_rev=$GIT_REV
|
||||||
|
dirty_files=$dirty_count
|
||||||
|
build_time_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
image=$IMAGE_NAME:$IMAGE_TAG
|
||||||
|
image_id=$IMAGE_ID
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[build] image tarball: $TARBALL"
|
||||||
|
echo "[build] provenance: $OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt"
|
||||||
|
echo "[build] transfer: cat '$TARBALL' | ssh root@<host> 'gunzip | docker load'"
|
||||||
34
deploy/rpki-explorer/docker-compose.yml
Normal file
34
deploy/rpki-explorer/docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: rpki-explorer
|
||||||
|
|
||||||
|
# Standalone explorer stack: one container with nginx + rpki_query_service
|
||||||
|
# (+ rpki_query_indexer spawned by the service watcher). It mounts the soak
|
||||||
|
# run root and state db read-only and persists its query-db on the host so
|
||||||
|
# container/host restarts reuse the already-built index.
|
||||||
|
|
||||||
|
services:
|
||||||
|
rpki-explorer:
|
||||||
|
image: ${IMAGE_NAME:-rpki-explorer}:${IMAGE_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/rpki-explorer/Dockerfile
|
||||||
|
args:
|
||||||
|
GIT_REV: ${GIT_REV:-unknown}
|
||||||
|
container_name: rpki-explorer
|
||||||
|
restart: unless-stopped
|
||||||
|
# RocksDB keeps table files open across 13 column families; the default
|
||||||
|
# 1024 nofile limit is too tight under concurrent reads.
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 65536
|
||||||
|
hard: 65536
|
||||||
|
ports:
|
||||||
|
# Loopback only: access via SSH port forwarding, e.g.
|
||||||
|
# ssh -L 9517:127.0.0.1:9517 root@<host>
|
||||||
|
- "127.0.0.1:${LISTEN_PORT:-9517}:8080"
|
||||||
|
environment:
|
||||||
|
RETAIN_RUNS: "${RETAIN_RUNS:-10}"
|
||||||
|
WATCH_INTERVAL_SECS: "${WATCH_INTERVAL_SECS:-10}"
|
||||||
|
volumes:
|
||||||
|
- "${SOAK_RUNS_DIR:?set SOAK_RUNS_DIR in .env}:/soak/runs:ro"
|
||||||
|
- "${SOAK_DB_DIR:?set SOAK_DB_DIR in .env}:/soak/db:ro"
|
||||||
|
- "${QUERY_DB_DIR:-./query-db}:/data"
|
||||||
92
deploy/rpki-explorer/entrypoint.sh
Executable file
92
deploy/rpki-explorer/entrypoint.sh
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Container entrypoint: run rpki_query_service (watch mode) and nginx in one
|
||||||
|
# container. nginx serves the explorer SPA and proxies /api/v1/ to the query
|
||||||
|
# service. If either process exits, the container exits and the compose
|
||||||
|
# restart policy brings it back.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RUNS_DIR="${RUNS_DIR:-/soak/runs}"
|
||||||
|
REPO_BYTES_DB="${REPO_BYTES_DB:-/soak/db/repo-bytes.db}"
|
||||||
|
QUERY_DB="${QUERY_DB:-/data/query-db}"
|
||||||
|
RETAIN_RUNS="${RETAIN_RUNS:-10}"
|
||||||
|
WATCH_INTERVAL_SECS="${WATCH_INTERVAL_SECS:-10}"
|
||||||
|
QUERY_LISTEN="${QUERY_LISTEN:-127.0.0.1:9557}"
|
||||||
|
|
||||||
|
log() { echo "[entrypoint] $*"; }
|
||||||
|
|
||||||
|
latest_completed_seq() {
|
||||||
|
local latest=0 dir base n
|
||||||
|
shopt -s nullglob
|
||||||
|
for dir in "$RUNS_DIR"/run_*/; do
|
||||||
|
[ -f "${dir}report.json" ] || continue
|
||||||
|
base="${dir%/}"
|
||||||
|
n="${base##*/run_}"
|
||||||
|
case "$n" in
|
||||||
|
*[!0-9]* | "") continue ;;
|
||||||
|
esac
|
||||||
|
if [ "$n" -gt "$latest" ]; then
|
||||||
|
latest="$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$QUERY_DB")"
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
--query-db "$QUERY_DB"
|
||||||
|
--listen "$QUERY_LISTEN"
|
||||||
|
--watch-run-root "$RUNS_DIR"
|
||||||
|
--watch-interval-secs "$WATCH_INTERVAL_SECS"
|
||||||
|
--retain-indexed-runs "$RETAIN_RUNS"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -e "$REPO_BYTES_DB" ]; then
|
||||||
|
ARGS+=(--repo-bytes-db "$REPO_BYTES_DB")
|
||||||
|
else
|
||||||
|
log "WARNING: repo-bytes db not found at $REPO_BYTES_DB; parsed/raw/export features will be unavailable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Decide the watcher start point:
|
||||||
|
# - explicit WATCH_MIN_RUN_SEQ always wins;
|
||||||
|
# - on first boot (query-db does not exist yet) backfill the latest
|
||||||
|
# RETAIN_RUNS completed runs;
|
||||||
|
# - on later boots the existing query-db drives continuation
|
||||||
|
# (max(min_run_seq, latest_ready+1)), so no flag is needed.
|
||||||
|
if [ -n "${WATCH_MIN_RUN_SEQ:-}" ]; then
|
||||||
|
ARGS+=(--watch-min-run-seq "$WATCH_MIN_RUN_SEQ")
|
||||||
|
log "using explicit WATCH_MIN_RUN_SEQ=$WATCH_MIN_RUN_SEQ"
|
||||||
|
elif [ ! -e "$QUERY_DB" ]; then
|
||||||
|
latest="$(latest_completed_seq)"
|
||||||
|
if [ "$latest" -gt "$RETAIN_RUNS" ]; then
|
||||||
|
min_seq=$((latest - RETAIN_RUNS + 1))
|
||||||
|
else
|
||||||
|
min_seq=1
|
||||||
|
fi
|
||||||
|
log "first boot: latest completed run seq=$latest -> --watch-min-run-seq $min_seq (backfill $RETAIN_RUNS runs)"
|
||||||
|
ARGS+=(--watch-min-run-seq "$min_seq")
|
||||||
|
else
|
||||||
|
log "existing query-db at $QUERY_DB; resuming from indexed state"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "starting rpki_query_service ${ARGS[*]}"
|
||||||
|
rpki_query_service "${ARGS[@]}" &
|
||||||
|
QS_PID=$!
|
||||||
|
|
||||||
|
log "starting nginx"
|
||||||
|
nginx -g 'daemon off;' &
|
||||||
|
NGINX_PID=$!
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
log "shutting down (qs=$QS_PID nginx=$NGINX_PID)"
|
||||||
|
kill -TERM "$QS_PID" 2>/dev/null || true
|
||||||
|
kill -TERM "$NGINX_PID" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap shutdown TERM INT
|
||||||
|
|
||||||
|
wait -n "$QS_PID" "$NGINX_PID"
|
||||||
|
EXIT_CODE=$?
|
||||||
|
log "a process exited (code=$EXIT_CODE); stopping the remaining one"
|
||||||
|
shutdown
|
||||||
|
wait "$QS_PID" "$NGINX_PID" 2>/dev/null || true
|
||||||
|
exit "$EXIT_CODE"
|
||||||
48
deploy/rpki-explorer/nginx.conf
Normal file
48
deploy/rpki-explorer/nginx.conf
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
error_log /dev/stderr;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Keep API/proxy redirects relative so port-forwarded access (ssh -L) is
|
||||||
|
# not bounced to an unreachable absolute URL.
|
||||||
|
absolute_redirect off;
|
||||||
|
port_in_redirect off;
|
||||||
|
|
||||||
|
# Hand-typed API URLs that fail to parse (e.g. raw % in a path segment)
|
||||||
|
# should land on the SPA instead of nginx's default 400 page.
|
||||||
|
error_page 400 /index.html;
|
||||||
|
|
||||||
|
# `/api/v1` without trailing slash must reach the service info endpoint
|
||||||
|
# too, not the SPA fallback.
|
||||||
|
location = /api/v1 {
|
||||||
|
proxy_pass http://127.0.0.1:9557;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# query service has no CORS headers and no authentication: the API must be
|
||||||
|
# served same-origin with the UI.
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://127.0.0.1:9557;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: routing is client-side.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ ARG SOURCE_DIRTY=unknown
|
|||||||
ARG BUILD_TIMESTAMP_UTC=unknown
|
ARG BUILD_TIMESTAMP_UTC=unknown
|
||||||
ARG RPKI_CLIENT_VERSION=9.8
|
ARG RPKI_CLIENT_VERSION=9.8
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="ours-rp-metrics" \
|
LABEL org.opencontainers.image.title="panda-rpki-metrics" \
|
||||||
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
|
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
|
||||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||||
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ ARG SOURCE_COMMIT=unknown
|
|||||||
ARG SOURCE_DIRTY=unknown
|
ARG SOURCE_DIRTY=unknown
|
||||||
ARG BUILD_TIMESTAMP_UTC=unknown
|
ARG BUILD_TIMESTAMP_UTC=unknown
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="ours-rp-runtime" \
|
LABEL org.opencontainers.image.title="panda-rpki-rp" \
|
||||||
org.opencontainers.image.description="Ours RP runtime image for multi-arch Docker Compose deployment" \
|
org.opencontainers.image.description="Ours RP runtime image for multi-arch Docker Compose deployment" \
|
||||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||||
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
TARGET_ARCH="${TARGET_ARCH:-}"
|
TARGET_ARCH="${TARGET_ARCH:-}"
|
||||||
|
VERSION="${VERSION:-}"
|
||||||
IMAGE_TAG=""
|
IMAGE_TAG=""
|
||||||
IMAGE_TAR=""
|
IMAGE_TAR=""
|
||||||
METRICS_IMAGE=""
|
METRICS_IMAGE=""
|
||||||
@@ -25,6 +26,10 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--arch <arch> Target architecture: amd64|arm64
|
--arch <arch> Target architecture: amd64|arm64
|
||||||
|
--version <vX.Y.Z> Component version for image tags and the package name.
|
||||||
|
Default: contents of the VERSION file at the repo root.
|
||||||
|
Release builds (without --allow-dirty) require a git tag
|
||||||
|
matching this version pointing at HEAD.
|
||||||
--prometheus-image <tag>
|
--prometheus-image <tag>
|
||||||
Prometheus image tag to record and package.
|
Prometheus image tag to record and package.
|
||||||
--prometheus-image-tar <path>
|
--prometheus-image-tar <path>
|
||||||
@@ -42,7 +47,7 @@ Options:
|
|||||||
-h, --help Show help.
|
-h, --help Show help.
|
||||||
|
|
||||||
Every package rebuilds runtime and metrics images from the current HEAD. Their
|
Every package rebuilds runtime and metrics images from the current HEAD. Their
|
||||||
tags are ours-rp-runtime-<arch>:<git8> and ours-rp-metrics-<arch>:<git8>.
|
tags are panda-rpki-rp-<arch>:<version> and panda-rpki-metrics-<arch>:<version>.
|
||||||
External runtime/metrics image tags and tar archives are intentionally rejected.
|
External runtime/metrics image tags and tar archives are intentionally rejected.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
@@ -72,17 +77,17 @@ safe_tag_name() {
|
|||||||
runtime_image_tag() {
|
runtime_image_tag() {
|
||||||
local arch="$1"
|
local arch="$1"
|
||||||
local revision="$2"
|
local revision="$2"
|
||||||
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$revision"
|
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$revision"
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics_image_tag() {
|
metrics_image_tag() {
|
||||||
local arch="$1"
|
local arch="$1"
|
||||||
local revision="$2"
|
local revision="$2"
|
||||||
printf 'ours-rp-metrics-%s:%s\n' "$arch" "$revision"
|
printf 'panda-rpki-metrics-%s:%s\n' "$arch" "$revision"
|
||||||
}
|
}
|
||||||
|
|
||||||
default_package_prefix() {
|
default_package_prefix() {
|
||||||
printf 'ours-rp-installer-%s\n' "$1"
|
printf 'panda-rpki-rp-installer-%s\n' "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
default_host_data_dir() {
|
default_host_data_dir() {
|
||||||
@@ -180,6 +185,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
}
|
}
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--version)
|
||||||
|
VERSION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--image|--image-tar|--metrics-image|--metrics-image-tar)
|
--image|--image-tar|--metrics-image|--metrics-image-tar)
|
||||||
echo "$1 is no longer supported: installer packages always rebuild runtime and metrics images from current HEAD" >&2
|
echo "$1 is no longer supported: installer packages always rebuild runtime and metrics images from current HEAD" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -254,8 +263,36 @@ will be explicitly marked dirty.
|
|||||||
EOF
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 版本来源:--version 覆盖,默认读仓库根 VERSION 文件。release 构建要求
|
||||||
|
# HEAD 已打同版 git tag(自研组件打包前打 tag,与容器镜像 tag 保持一致)。
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
[[ -f "$REPO_ROOT/VERSION" ]] || {
|
||||||
|
echo "missing VERSION file at $REPO_ROOT/VERSION; pass --version <vX.Y.Z> or create it" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
|
||||||
|
fi
|
||||||
|
[[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||||
|
echo "invalid version '$VERSION' (expected vX.Y.Z)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
GIT_TAG_AT_HEAD=""
|
||||||
|
if [[ "$ALLOW_DIRTY" != "1" ]]; then
|
||||||
|
GIT_TAG_AT_HEAD="$(git -C "$REPO_ROOT" tag --points-at HEAD | grep -Fx "$VERSION" || true)"
|
||||||
|
[[ -n "$GIT_TAG_AT_HEAD" ]] || {
|
||||||
|
cat >&2 <<EOF
|
||||||
|
refusing to build a release installer: HEAD is not tagged with '$VERSION'.
|
||||||
|
Create the tag first so the git tag matches the container image tag:
|
||||||
|
git -C $REPO_ROOT tag -a $VERSION -m "release $VERSION"
|
||||||
|
Use --allow-dirty only for development verification (skips this check).
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
REVISION_TOKEN="$SOURCE_COMMIT_SHORT"
|
REVISION_TOKEN="$VERSION"
|
||||||
if [[ "$SOURCE_DIRTY" == "true" ]]; then
|
if [[ "$SOURCE_DIRTY" == "true" ]]; then
|
||||||
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
|
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
|
||||||
fi
|
fi
|
||||||
@@ -367,6 +404,8 @@ package_schema_version=2
|
|||||||
package_name=$package_name
|
package_name=$package_name
|
||||||
created_at_utc=$TIMESTAMP
|
created_at_utc=$TIMESTAMP
|
||||||
build_timestamp_utc=$TIMESTAMP
|
build_timestamp_utc=$TIMESTAMP
|
||||||
|
version=$VERSION
|
||||||
|
git_tag=${GIT_TAG_AT_HEAD:-}
|
||||||
source_commit=$SOURCE_COMMIT_FULL
|
source_commit=$SOURCE_COMMIT_FULL
|
||||||
source_commit_short=$SOURCE_COMMIT_SHORT
|
source_commit_short=$SOURCE_COMMIT_SHORT
|
||||||
source_dirty=$SOURCE_DIRTY
|
source_dirty=$SOURCE_DIRTY
|
||||||
@@ -419,6 +458,8 @@ EOF
|
|||||||
|
|
||||||
cat > "$stage/PACKAGE-SUMMARY.txt" <<EOF
|
cat > "$stage/PACKAGE-SUMMARY.txt" <<EOF
|
||||||
package_name: $package_name
|
package_name: $package_name
|
||||||
|
version: $VERSION
|
||||||
|
git_tag: ${GIT_TAG_AT_HEAD:-}
|
||||||
source_commit: $SOURCE_COMMIT_FULL
|
source_commit: $SOURCE_COMMIT_FULL
|
||||||
source_commit_short: $SOURCE_COMMIT_SHORT
|
source_commit_short: $SOURCE_COMMIT_SHORT
|
||||||
source_dirty: $SOURCE_DIRTY
|
source_dirty: $SOURCE_DIRTY
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ if [[ -z "$IMAGE_TAG" ]]; then
|
|||||||
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
|
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
|
||||||
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
|
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
|
||||||
fi
|
fi
|
||||||
IMAGE_TAG="ours-rp-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
|
IMAGE_TAG="panda-rpki-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$dockerfile_explicit" != "1" ]]; then
|
if [[ "$dockerfile_explicit" != "1" ]]; then
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--arch <arch> Target architecture: amd64|arm64
|
--arch <arch> Target architecture: amd64|arm64
|
||||||
--image <tag> Docker image tag (default: ours-rp-runtime-<arch>:<git8>)
|
--image <tag> Docker image tag (default: panda-rpki-rp-<arch>:<git8>)
|
||||||
--out-dir <path> Directory for docker save tar.gz (default: target/<arch>-docker)
|
--out-dir <path> Directory for docker save tar.gz (default: target/<arch>-docker)
|
||||||
--dockerfile <path> Dockerfile path
|
--dockerfile <path> Dockerfile path
|
||||||
--builder <name> buildx builder name
|
--builder <name> buildx builder name
|
||||||
@@ -69,7 +69,7 @@ platform_for_arch() {
|
|||||||
default_image_tag() {
|
default_image_tag() {
|
||||||
local arch="$1"
|
local arch="$1"
|
||||||
local commit_short="$2"
|
local commit_short="$2"
|
||||||
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$commit_short"
|
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$commit_short"
|
||||||
}
|
}
|
||||||
|
|
||||||
require_command() {
|
require_command() {
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
|
|||||||
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
|
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
|
||||||
DB_STATS_EXACT_EVERY=3
|
DB_STATS_EXACT_EVERY=3
|
||||||
|
|
||||||
|
# 死 repo 传输层黑名单(#141,默认关)。设置状态文件路径即开启:
|
||||||
|
# 连续 N 轮传输层抓取失败的 repo 会被拉黑并在后续轮次跳过其传输尝试;
|
||||||
|
# daemon 会按间隔对黑名单条目做健康探针,恢复后自动摘除。
|
||||||
|
# 留空表示完全关闭(行为与旧版本一致)。
|
||||||
|
#DEAD_REPO_BLACKLIST_PATH=/var/lib/ours-rp/state/dead-repo-blacklist.json
|
||||||
|
#DEAD_REPO_BLACKLIST_FAIL_THRESHOLD=3
|
||||||
|
#DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS=600
|
||||||
|
|
||||||
# 是否开启 ours RP progress log。1 表示开启。
|
# 是否开启 ours RP progress log。1 表示开启。
|
||||||
RPKI_PROGRESS_LOG=1
|
RPKI_PROGRESS_LOG=1
|
||||||
|
|
||||||
@@ -94,6 +102,10 @@ ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=0
|
|||||||
# 实验性 transport 预热:RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-roa-validation-cache"
|
# 实验性 transport 预热:RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-roa-validation-cache"
|
||||||
RPKI_EXTRA_ARGS=""
|
RPKI_EXTRA_ARGS=""
|
||||||
|
|
||||||
|
# 所有出站 HTTP 请求(RRDP / TAL / TA / 死 repo 探针)的 User-Agent。
|
||||||
|
# 不设置、为空或含非法 header 字符时回退默认 panda-rpki/0.2。
|
||||||
|
#RPKI_HTTP_USER_AGENT=panda-rpki/0.2
|
||||||
|
|
||||||
# 是否为每轮输出 timing profile 到 runs/run_xxxx/analyze/timing.json。
|
# 是否为每轮输出 timing profile 到 runs/run_xxxx/analyze/timing.json。
|
||||||
# 性能 profile 或打点验证时设置为 1;普通 soak 建议保持 0,避免额外开销。
|
# 性能 profile 或打点验证时设置为 1;普通 soak 建议保持 0,避免额外开销。
|
||||||
RPKI_ANALYZE=0
|
RPKI_ANALYZE=0
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Environment:
|
|||||||
EXPERIMENT_RUN_ROOT shared run root/state root; default PACKAGE_ROOT
|
EXPERIMENT_RUN_ROOT shared run root/state root; default PACKAGE_ROOT
|
||||||
EXPERIMENT_DIR experiment metadata output directory
|
EXPERIMENT_DIR experiment metadata output directory
|
||||||
CASE_RUNS delta runs per case; default 10
|
CASE_RUNS delta runs per case; default 10
|
||||||
EXPERIMENT_CASE_SET default or cache-only; default runs the original 4-case matrix
|
EXPERIMENT_CASE_SET default, cache-only, crypto-sig or sig-pp-compare; default runs the original 4-case matrix
|
||||||
RUN_START_INTERVAL_SECS fixed start cadence for all runs; default 600
|
RUN_START_INTERVAL_SECS fixed start cadence for all runs; default 600
|
||||||
FIRST_RUN_DELAY_SECS delay before the first scheduled run; default 0
|
FIRST_RUN_DELAY_SECS delay before the first scheduled run; default 0
|
||||||
SNAPSHOT_EXTRA_ARGS extra rpki args for snapshot warmup
|
SNAPSHOT_EXTRA_ARGS extra rpki args for snapshot warmup
|
||||||
@@ -95,7 +95,10 @@ case_count() {
|
|||||||
case "$EXPERIMENT_CASE_SET" in
|
case "$EXPERIMENT_CASE_SET" in
|
||||||
default) printf '%s' 4 ;;
|
default) printf '%s' 4 ;;
|
||||||
cache-only) printf '%s' 3 ;;
|
cache-only) printf '%s' 3 ;;
|
||||||
*) die "EXPERIMENT_CASE_SET must be default or cache-only: $EXPERIMENT_CASE_SET" ;;
|
crypto-sig) printf '%s' 4 ;;
|
||||||
|
sig-pp-compare) printf '%s' 2 ;;
|
||||||
|
baseline-combo) printf '%s' 2 ;;
|
||||||
|
*) die "EXPERIMENT_CASE_SET must be default, cache-only, crypto-sig or sig-pp-compare: $EXPERIMENT_CASE_SET" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +111,14 @@ case_id_for_index() {
|
|||||||
cache-only:1) printf '%s' "pp-only" ;;
|
cache-only:1) printf '%s' "pp-only" ;;
|
||||||
cache-only:2) printf '%s' "object-only" ;;
|
cache-only:2) printf '%s' "object-only" ;;
|
||||||
cache-only:3) printf '%s' "pp-object-only" ;;
|
cache-only:3) printf '%s' "pp-object-only" ;;
|
||||||
|
crypto-sig:1) printf '%s' "baseline" ;;
|
||||||
|
crypto-sig:2) printf '%s' "sig-only" ;;
|
||||||
|
crypto-sig:3) printf '%s' "pp-object" ;;
|
||||||
|
crypto-sig:4) printf '%s' "pp-object-sig" ;;
|
||||||
|
sig-pp-compare:1) printf '%s' "sig-only" ;;
|
||||||
|
sig-pp-compare:2) printf '%s' "pp-only" ;;
|
||||||
|
baseline-combo:1) printf '%s' "baseline" ;;
|
||||||
|
baseline-combo:2) printf '%s' "pp-object-sig" ;;
|
||||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -121,6 +132,14 @@ case_name_for_index() {
|
|||||||
cache-only:1) printf '%s' "pp-cache-only" ;;
|
cache-only:1) printf '%s' "pp-cache-only" ;;
|
||||||
cache-only:2) printf '%s' "object-cache-only" ;;
|
cache-only:2) printf '%s' "object-cache-only" ;;
|
||||||
cache-only:3) printf '%s' "pp-cache-object-cache-only" ;;
|
cache-only:3) printf '%s' "pp-cache-object-cache-only" ;;
|
||||||
|
crypto-sig:1) printf '%s' "all-cache-off" ;;
|
||||||
|
crypto-sig:2) printf '%s' "crypto-sig-cache-only" ;;
|
||||||
|
crypto-sig:3) printf '%s' "pp-cache-object-cache" ;;
|
||||||
|
crypto-sig:4) printf '%s' "pp-cache-object-crypto-sig-cache" ;;
|
||||||
|
sig-pp-compare:1) printf '%s' "crypto-sig-cache-only" ;;
|
||||||
|
sig-pp-compare:2) printf '%s' "pp-cache-only" ;;
|
||||||
|
baseline-combo:1) printf '%s' "all-cache-off" ;;
|
||||||
|
baseline-combo:2) printf '%s' "pp-cache-object-crypto-sig-cache" ;;
|
||||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -134,14 +153,22 @@ case_extra_args_for_index() {
|
|||||||
cache-only:1) printf '%s' "--enable-publication-point-validation-cache" ;;
|
cache-only:1) printf '%s' "--enable-publication-point-validation-cache" ;;
|
||||||
cache-only:2) printf '%s' "--enable-roa-validation-cache" ;;
|
cache-only:2) printf '%s' "--enable-roa-validation-cache" ;;
|
||||||
cache-only:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;;
|
cache-only:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;;
|
||||||
|
crypto-sig:1) printf '%s' "" ;;
|
||||||
|
crypto-sig:2) printf '%s' "--enable-crypto-signature-cache" ;;
|
||||||
|
crypto-sig:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;;
|
||||||
|
crypto-sig:4) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;;
|
||||||
|
sig-pp-compare:1) printf '%s' "--enable-crypto-signature-cache" ;;
|
||||||
|
sig-pp-compare:2) printf '%s' "--enable-publication-point-validation-cache" ;;
|
||||||
|
baseline-combo:1) printf '%s' "" ;;
|
||||||
|
baseline-combo:2) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;;
|
||||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
case_child_cert_cache_for_index() {
|
case_child_cert_cache_for_index() {
|
||||||
case "$EXPERIMENT_CASE_SET:$1" in
|
case "$EXPERIMENT_CASE_SET:$1" in
|
||||||
default:4|cache-only:2|cache-only:3) printf '%s' "1" ;;
|
default:4|cache-only:2|cache-only:3|crypto-sig:3|crypto-sig:4|baseline-combo:2) printf '%s' "1" ;;
|
||||||
default:1|default:2|default:3|cache-only:1) printf '%s' "0" ;;
|
default:1|default:2|default:3|cache-only:1|crypto-sig:1|crypto-sig:2|sig-pp-compare:1|sig-pp-compare:2|baseline-combo:1) printf '%s' "0" ;;
|
||||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -163,6 +190,23 @@ elif case_set == "cache-only":
|
|||||||
{"caseId": "object-only", "caseName": "object-cache-only", "extraArgs": "--enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
|
{"caseId": "object-only", "caseName": "object-cache-only", "extraArgs": "--enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
|
||||||
{"caseId": "pp-object-only", "caseName": "pp-cache-object-cache-only", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
|
{"caseId": "pp-object-only", "caseName": "pp-cache-object-cache-only", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
|
||||||
]
|
]
|
||||||
|
elif case_set == "crypto-sig":
|
||||||
|
cases = [
|
||||||
|
{"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False},
|
||||||
|
{"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False},
|
||||||
|
{"caseId": "pp-object", "caseName": "pp-cache-object-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True},
|
||||||
|
{"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True},
|
||||||
|
]
|
||||||
|
elif case_set == "sig-pp-compare":
|
||||||
|
cases = [
|
||||||
|
{"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False},
|
||||||
|
{"caseId": "pp-only", "caseName": "pp-cache-only", "extraArgs": "--enable-publication-point-validation-cache", "enableChildCertificateValidationCache": False},
|
||||||
|
]
|
||||||
|
elif case_set == "baseline-combo":
|
||||||
|
cases = [
|
||||||
|
{"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False},
|
||||||
|
{"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True},
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
raise SystemExit(f"unknown case set: {case_set}")
|
raise SystemExit(f"unknown case set: {case_set}")
|
||||||
print(json.dumps(cases, ensure_ascii=False, indent=8))
|
print(json.dumps(cases, ensure_ascii=False, indent=8))
|
||||||
@@ -345,6 +389,7 @@ else:
|
|||||||
"publication_point_cache_index_load",
|
"publication_point_cache_index_load",
|
||||||
"publication_point_cache_index_refresh",
|
"publication_point_cache_index_refresh",
|
||||||
"roa_validation_cache",
|
"roa_validation_cache",
|
||||||
|
"crypto_signature_cache_observe",
|
||||||
]:
|
]:
|
||||||
if key in stage:
|
if key in stage:
|
||||||
record[key] = stage.get(key)
|
record[key] = stage.get(key)
|
||||||
@@ -360,6 +405,11 @@ else:
|
|||||||
"child_certificate_cache_miss_not_found",
|
"child_certificate_cache_miss_not_found",
|
||||||
"fresh_publication_points",
|
"fresh_publication_points",
|
||||||
"fresh_manifest_files_total",
|
"fresh_manifest_files_total",
|
||||||
|
"crypto_signature_cache_observe_calls",
|
||||||
|
"crypto_signature_cache_observe_would_hit",
|
||||||
|
"crypto_signature_cache_observe_new_keys",
|
||||||
|
"crypto_signature_cache_verify_executed",
|
||||||
|
"crypto_signature_cache_verify_skipped",
|
||||||
]
|
]
|
||||||
record["cacheCounts"] = {k: analysis_counts.get(k) for k in interesting if k in analysis_counts}
|
record["cacheCounts"] = {k: analysis_counts.get(k) for k in interesting if k in analysis_counts}
|
||||||
record["repoSyncStats"] = summary.get("repoSyncStats")
|
record["repoSyncStats"] = summary.get("repoSyncStats")
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ DISABLE_COMPETING_RPS="${DISABLE_COMPETING_RPS:-1}"
|
|||||||
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE="${ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE:-0}"
|
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE="${ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE:-0}"
|
||||||
RPKI_EXTRA_ARGS="${RPKI_EXTRA_ARGS:-}"
|
RPKI_EXTRA_ARGS="${RPKI_EXTRA_ARGS:-}"
|
||||||
RPKI_ANALYZE="${RPKI_ANALYZE:-0}"
|
RPKI_ANALYZE="${RPKI_ANALYZE:-0}"
|
||||||
|
CUSTOM_TAL_PATH="${CUSTOM_TAL_PATH:-$PACKAGE_ROOT/custom-fixtures/tal/custom.tal}"
|
||||||
|
CUSTOM_TA_PATH="${CUSTOM_TA_PATH:-$PACKAGE_ROOT/custom-fixtures/ta/custom-ta.cer}"
|
||||||
|
CUSTOM_TAL_URI="${CUSTOM_TAL_URI:-}"
|
||||||
|
HTTP_ROOT_CERT_PATHS="${HTTP_ROOT_CERT_PATHS:-}"
|
||||||
|
|
||||||
BIN_DIR="${BIN_DIR:-$PACKAGE_ROOT/bin}"
|
BIN_DIR="${BIN_DIR:-$PACKAGE_ROOT/bin}"
|
||||||
FIXTURE_DIR="${FIXTURE_DIR:-$PACKAGE_ROOT/fixtures}"
|
FIXTURE_DIR="${FIXTURE_DIR:-$PACKAGE_ROOT/fixtures}"
|
||||||
@@ -119,10 +123,10 @@ validate_rsync_scope() {
|
|||||||
|
|
||||||
validate_tal_input_mode() {
|
validate_tal_input_mode() {
|
||||||
case "$TAL_INPUT_MODE" in
|
case "$TAL_INPUT_MODE" in
|
||||||
file-with-ta|file-live-ta|url)
|
file-with-ta|file-live-ta|custom-file-with-ta|url)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
die "TAL_INPUT_MODE must be file-with-ta, file-live-ta or url: $TAL_INPUT_MODE"
|
die "TAL_INPUT_MODE must be file-with-ta, file-live-ta, custom-file-with-ta or url: $TAL_INPUT_MODE"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -156,14 +160,46 @@ parse_rirs() {
|
|||||||
afrinic|apnic|arin|lacnic|ripe)
|
afrinic|apnic|arin|lacnic|ripe)
|
||||||
RIR_LIST+=("$normalized")
|
RIR_LIST+=("$normalized")
|
||||||
;;
|
;;
|
||||||
|
custom)
|
||||||
|
[[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]] \
|
||||||
|
|| die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
|
||||||
|
RIR_LIST+=("$normalized")
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe"
|
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe,custom (custom mode only)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR"
|
[[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_custom_inputs() {
|
||||||
|
local root_cert_path
|
||||||
|
local -a root_cert_paths=()
|
||||||
|
|
||||||
|
if [[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]]; then
|
||||||
|
[[ "${#RIR_LIST[@]}" -eq 1 && "${RIR_LIST[0]}" == "custom" ]] \
|
||||||
|
|| die "custom-file-with-ta requires exactly RIRS=custom"
|
||||||
|
[[ -s "$CUSTOM_TAL_PATH" ]] \
|
||||||
|
|| die "missing custom TAL: $CUSTOM_TAL_PATH"
|
||||||
|
[[ -s "$CUSTOM_TA_PATH" ]] \
|
||||||
|
|| die "missing custom TA DER: $CUSTOM_TA_PATH"
|
||||||
|
[[ -n "$CUSTOM_TAL_URI" ]] \
|
||||||
|
|| die "CUSTOM_TAL_URI is required in custom-file-with-ta mode"
|
||||||
|
elif [[ "$RIRS" == *custom* ]]; then
|
||||||
|
die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
|
||||||
|
for root_cert_path in "${root_cert_paths[@]}"; do
|
||||||
|
[[ -s "$root_cert_path" ]] \
|
||||||
|
|| die "missing HTTP root certificate: $root_cert_path"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
tal_file_for_rir() {
|
tal_file_for_rir() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
|
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
|
||||||
@@ -171,6 +207,7 @@ tal_file_for_rir() {
|
|||||||
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
|
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
|
||||||
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
|
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
|
||||||
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
|
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
|
||||||
|
custom) printf '%s' "$CUSTOM_TAL_PATH" ;;
|
||||||
*) die "unknown RIR: $1" ;;
|
*) die "unknown RIR: $1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -182,6 +219,7 @@ ta_file_for_rir() {
|
|||||||
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
|
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
|
||||||
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
|
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
|
||||||
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
|
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
|
||||||
|
custom) printf '%s' "$CUSTOM_TA_PATH" ;;
|
||||||
*) die "unknown RIR: $1" ;;
|
*) die "unknown RIR: $1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -193,6 +231,7 @@ tal_url_for_rir() {
|
|||||||
arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;;
|
arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;;
|
||||||
lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;;
|
lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;;
|
||||||
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
|
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
|
||||||
|
custom) printf '%s' "$CUSTOM_TAL_URI" ;;
|
||||||
*) die "unknown RIR: $1" ;;
|
*) die "unknown RIR: $1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -1058,6 +1097,17 @@ build_child_args() {
|
|||||||
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
|
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local root_cert_path
|
||||||
|
local -a root_cert_paths=()
|
||||||
|
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
|
||||||
|
# HTTPS trust roots for RRDP/TAL fetches (independent of the RPKI TA).
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
|
||||||
|
for root_cert_path in "${root_cert_paths[@]}"; do
|
||||||
|
CHILD_ARGS+=(--http-root-cert "$root_cert_path")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
CHILD_ARGS+=(
|
CHILD_ARGS+=(
|
||||||
--parallel-phase2-ready-batch-size 256
|
--parallel-phase2-ready-batch-size 256
|
||||||
--parallel-phase2-ready-batch-wall-time-budget-ms 100
|
--parallel-phase2-ready-batch-wall-time-budget-ms 100
|
||||||
@@ -1331,6 +1381,17 @@ run_one_round() {
|
|||||||
daemon_args+=(--db-stats-exact-every "$DB_STATS_EXACT_EVERY")
|
daemon_args+=(--db-stats-exact-every "$DB_STATS_EXACT_EVERY")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
# Dead-repo transport blacklist (#141): opt-in via env; the daemon forwards
|
||||||
|
# the enable flag and threshold to the child unless already present there.
|
||||||
|
if [[ -n "${DEAD_REPO_BLACKLIST_PATH:-}" ]]; then
|
||||||
|
daemon_args+=(--dead-repo-blacklist "$DEAD_REPO_BLACKLIST_PATH")
|
||||||
|
if [[ -n "${DEAD_REPO_BLACKLIST_FAIL_THRESHOLD:-}" ]]; then
|
||||||
|
daemon_args+=(--dead-repo-blacklist-fail-threshold "$DEAD_REPO_BLACKLIST_FAIL_THRESHOLD")
|
||||||
|
fi
|
||||||
|
if [[ -n "${DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS:-}" ]]; then
|
||||||
|
daemon_args+=(--dead-repo-health-check-interval-secs "$DEAD_REPO_HEALTH_CHECK_INTERVAL_SECS")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
env \
|
env \
|
||||||
@@ -1406,6 +1467,7 @@ main() {
|
|||||||
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
|
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
|
||||||
fi
|
fi
|
||||||
parse_rirs
|
parse_rirs
|
||||||
|
validate_custom_inputs
|
||||||
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
|
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
|
||||||
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"
|
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
- `vrps.csv`、`vaps.csv`
|
- `vrps.csv`、`vaps.csv`
|
||||||
- `stage-timing.json`、`validation-events.jsonl`
|
- `stage-timing.json`、`validation-events.jsonl`
|
||||||
- `validation-contract.json`、`verification-meta.json`
|
- `validation-contract.json`、`verification-meta.json`
|
||||||
- `compare/ccr-state-compare.json`、`compare/ccr-state-compare.md`
|
- `compare/ccr-state-digest.json`、`compare/ccr-state-digest.md`
|
||||||
- `stdout.log`、`stderr.log`、`process-time.txt`、`wrapper-timing.json`
|
- `stdout.log`、`stderr.log`、`process-time.txt`、`wrapper-timing.json`
|
||||||
|
|
||||||
通过条件是 compare JSON 中 `stateDigestMatch=true`。`producedAt` 不参与比较。
|
通过条件是 compare JSON 中 `stateDigestMatch=true`。`producedAt` 不参与比较。
|
||||||
|
|||||||
563
scripts/verification/observe_remote231_verification_only.sh
Executable file
563
scripts/verification/observe_remote231_verification_only.sh
Executable file
@@ -0,0 +1,563 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Observe a Docker-managed soak from the host without joining its compose project.
|
||||||
|
# The verifier binary must be copied byte-for-byte from the running runtime image;
|
||||||
|
# validation-contract.json rejects a different binary SHA-256.
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage:
|
||||||
|
observe_remote231_verification_only.sh \
|
||||||
|
--runs-root <normal_runs_dir> \
|
||||||
|
--state-root <normal_state_dir> \
|
||||||
|
--rpki-bin <host_side_rpki> \
|
||||||
|
--work-root <observer_root> \
|
||||||
|
--webhook-url-file <0600_file> \
|
||||||
|
[--target-runs 20] [--delay-secs 30] [--poll-secs 5] \
|
||||||
|
[--verification-timeout-secs 480] [--normal-interval-secs 600] \
|
||||||
|
[--normal-run-safety-secs 60] [--verification-object-workers 4] \
|
||||||
|
[--verification-child-path <path>]
|
||||||
|
|
||||||
|
This is a host-side, finite observer. On its first start it records the latest
|
||||||
|
already-terminal normal run and observes only later terminal runs. For each one
|
||||||
|
it waits DELAY seconds, then uses a non-blocking state lock. It never waits for
|
||||||
|
or blocks the Docker soak. Successful normal runs are verified with a copied
|
||||||
|
host-side rpki binary; failed, stale, or lock-busy runs are recorded as skipped.
|
||||||
|
|
||||||
|
The verification timeout is additionally bounded by the source run completion
|
||||||
|
time plus NORMAL_INTERVAL_SECS minus NORMAL_RUN_SAFETY_SECS. This preserves a
|
||||||
|
normal-run safety window even after an observer restart or delayed scan.
|
||||||
|
|
||||||
|
Only compact JSON results are retained under WORK_ROOT/results/. The large
|
||||||
|
verification output and any retained scratch DB are always deleted afterwards.
|
||||||
|
|
||||||
|
When --verification-child-path is supplied, it replaces PATH only for the
|
||||||
|
verification binary. Passing a directory without rsync (for example
|
||||||
|
/nonexistent) is an adversarial acceptance check: an accidental SystemRsync
|
||||||
|
fallback must fail instead of accessing the network.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
RUNS_ROOT=""
|
||||||
|
STATE_ROOT=""
|
||||||
|
RPKI_BIN=""
|
||||||
|
WORK_ROOT=""
|
||||||
|
WEBHOOK_URL_FILE=""
|
||||||
|
TARGET_RUNS=20
|
||||||
|
DELAY_SECS=30
|
||||||
|
POLL_SECS=5
|
||||||
|
VERIFICATION_TIMEOUT_SECS=480
|
||||||
|
NORMAL_INTERVAL_SECS=600
|
||||||
|
NORMAL_RUN_SAFETY_SECS=60
|
||||||
|
VERIFICATION_OBJECT_WORKERS=4
|
||||||
|
VERIFICATION_CHILD_PATH=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--runs-root) RUNS_ROOT="${2:?missing value}"; shift 2 ;;
|
||||||
|
--state-root) STATE_ROOT="${2:?missing value}"; shift 2 ;;
|
||||||
|
--rpki-bin) RPKI_BIN="${2:?missing value}"; shift 2 ;;
|
||||||
|
--work-root) WORK_ROOT="${2:?missing value}"; shift 2 ;;
|
||||||
|
--webhook-url-file) WEBHOOK_URL_FILE="${2:?missing value}"; shift 2 ;;
|
||||||
|
--target-runs) TARGET_RUNS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--delay-secs) DELAY_SECS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--poll-secs) POLL_SECS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--verification-timeout-secs) VERIFICATION_TIMEOUT_SECS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--normal-interval-secs) NORMAL_INTERVAL_SECS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--normal-run-safety-secs) NORMAL_RUN_SAFETY_SECS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--verification-object-workers) VERIFICATION_OBJECT_WORKERS="${2:?missing value}"; shift 2 ;;
|
||||||
|
--verification-child-path) VERIFICATION_CHILD_PATH="${2:?missing value}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for value_name in RUNS_ROOT STATE_ROOT RPKI_BIN WORK_ROOT WEBHOOK_URL_FILE; do
|
||||||
|
[[ -n "${!value_name}" ]] || { echo "$value_name is required" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
for integer_name in TARGET_RUNS DELAY_SECS POLL_SECS VERIFICATION_TIMEOUT_SECS NORMAL_INTERVAL_SECS VERIFICATION_OBJECT_WORKERS; do
|
||||||
|
[[ "${!integer_name}" =~ ^[0-9]+$ ]] && [[ "${!integer_name}" != 0 ]] || {
|
||||||
|
echo "$integer_name must be a positive integer" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
[[ "$NORMAL_RUN_SAFETY_SECS" =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "NORMAL_RUN_SAFETY_SECS must be a non-negative integer" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
(( NORMAL_RUN_SAFETY_SECS < NORMAL_INTERVAL_SECS )) || {
|
||||||
|
echo "NORMAL_RUN_SAFETY_SECS must be smaller than NORMAL_INTERVAL_SECS" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ -d "$RUNS_ROOT" ]] || { echo "runs root is not a directory: $RUNS_ROOT" >&2; exit 2; }
|
||||||
|
[[ -d "$STATE_ROOT" ]] || { echo "state root is not a directory: $STATE_ROOT" >&2; exit 2; }
|
||||||
|
[[ -x "$RPKI_BIN" ]] || { echo "rpki binary is not executable: $RPKI_BIN" >&2; exit 2; }
|
||||||
|
[[ -f "$WEBHOOK_URL_FILE" ]] || { echo "webhook URL file is absent: $WEBHOOK_URL_FILE" >&2; exit 2; }
|
||||||
|
for required_command in curl flock ionice jq nice python3 sha256sum timeout; do
|
||||||
|
command -v "$required_command" >/dev/null || {
|
||||||
|
echo "missing required command: $required_command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$WORK_ROOT/results" "$WORK_ROOT/scratch"
|
||||||
|
STATE_FILE="$WORK_ROOT/observer-state.json"
|
||||||
|
OBSERVER_LOCK="$WORK_ROOT/observer.lock"
|
||||||
|
RESULTS_DIR="$WORK_ROOT/results"
|
||||||
|
SCRATCH_ROOT="$WORK_ROOT/scratch"
|
||||||
|
WEBHOOK_URL="$(tr -d '\r\n' < "$WEBHOOK_URL_FILE")"
|
||||||
|
[[ "$WEBHOOK_URL" == https://open.feishu.cn/open-apis/bot/v2/hook/* ]] || {
|
||||||
|
echo "webhook URL does not have the expected Feishu incoming-webhook form" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
now_ms() { python3 -c 'import time; print(time.time_ns() // 1_000_000)'; }
|
||||||
|
|
||||||
|
remaining_verification_window_secs() {
|
||||||
|
local completed_at="$1"
|
||||||
|
python3 - "$completed_at" "$NORMAL_INTERVAL_SECS" "$NORMAL_RUN_SAFETY_SECS" <<'PY'
|
||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
completed = datetime.datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00"))
|
||||||
|
interval = int(sys.argv[2])
|
||||||
|
safety = int(sys.argv[3])
|
||||||
|
print(int(completed.timestamp()) + interval - safety - int(time.time()))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_replace() {
|
||||||
|
local destination="$1"
|
||||||
|
local temporary="${destination}.tmp.$$"
|
||||||
|
cat > "$temporary"
|
||||||
|
mv "$temporary" "$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal_run_ids() {
|
||||||
|
local run_dir meta
|
||||||
|
for run_dir in "$RUNS_ROOT"/run_[0-9]*; do
|
||||||
|
[[ -d "$run_dir" ]] || continue
|
||||||
|
meta="$run_dir/run-meta.json"
|
||||||
|
[[ -f "$meta" ]] || continue
|
||||||
|
jq -e '(.completed_at_rfc3339_utc? // .completedAt? // null) != null' "$meta" >/dev/null 2>&1 || continue
|
||||||
|
basename "$run_dir"
|
||||||
|
done | sort -t_ -k2,2n
|
||||||
|
}
|
||||||
|
|
||||||
|
run_number() {
|
||||||
|
local run_id="$1"
|
||||||
|
printf '%d\n' "$((10#${run_id#run_}))"
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_terminal_run_id() {
|
||||||
|
terminal_run_ids | tail -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result_path() {
|
||||||
|
printf '%s/%s.json\n' "$RESULTS_DIR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_json_or_empty() {
|
||||||
|
local path="$1"
|
||||||
|
if [[ -f "$path" ]] && jq -e . "$path" >/dev/null 2>&1; then
|
||||||
|
jq -c . "$path"
|
||||||
|
else
|
||||||
|
printf '{}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_cumulative() {
|
||||||
|
local observed normal_success passes mismatches errors skipped pending latest
|
||||||
|
local -a result_files
|
||||||
|
shopt -s nullglob
|
||||||
|
result_files=("$RESULTS_DIR"/run_*.json)
|
||||||
|
shopt -u nullglob
|
||||||
|
observed="${#result_files[@]}"
|
||||||
|
if (( observed == 0 )); then
|
||||||
|
normal_success=0
|
||||||
|
passes=0
|
||||||
|
mismatches=0
|
||||||
|
errors=0
|
||||||
|
skipped=0
|
||||||
|
pending=0
|
||||||
|
else
|
||||||
|
normal_success="$(jq -r 'select(.normalRun.status == "success") | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
passes="$(jq -r 'select(.verification.status == "pass") | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
mismatches="$(jq -r 'select(.verification.status == "mismatch") | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
errors="$(jq -r 'select(.verification.status == "error") | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
skipped="$(jq -r 'select(.verification.status | startswith("skipped_")) | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
pending="$(jq -r 'select(.notification.status != "sent") | 1' "${result_files[@]}" | wc -l)"
|
||||||
|
fi
|
||||||
|
latest="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | sed 's/.json$//' | sort -t_ -k2,2n | tail -n 1)"
|
||||||
|
jq -n \
|
||||||
|
--arg updated_at "$(now_utc)" \
|
||||||
|
--arg initial_run_id "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")" \
|
||||||
|
--arg latest_run_id "$latest" \
|
||||||
|
--argjson target_runs "$TARGET_RUNS" \
|
||||||
|
--argjson observed_runs "$observed" \
|
||||||
|
--argjson normal_success_runs "$normal_success" \
|
||||||
|
--argjson verification_passes "$passes" \
|
||||||
|
--argjson verification_mismatches "$mismatches" \
|
||||||
|
--argjson verification_errors "$errors" \
|
||||||
|
--argjson verification_skipped "$skipped" \
|
||||||
|
--argjson notification_pending "$pending" \
|
||||||
|
'{schemaVersion: 1, updatedAt: $updated_at, initialLatestTerminalRunId: $initial_run_id,
|
||||||
|
latestProcessedRunId: $latest_run_id, targetRuns: $target_runs, observedRuns: $observed_runs,
|
||||||
|
normalSuccessRuns: $normal_success_runs, verificationPasses: $verification_passes,
|
||||||
|
verificationMismatches: $verification_mismatches, verificationErrors: $verification_errors,
|
||||||
|
verificationSkipped: $verification_skipped, notificationPending: $notification_pending}' \
|
||||||
|
| atomic_replace "$RESULTS_DIR/cumulative.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_result() {
|
||||||
|
local path="$1" run_id status observed target passes mismatches errors skipped analysis_available top_phase message payload response
|
||||||
|
run_id="$(jq -r '.runId' "$path")"
|
||||||
|
status="$(jq -r '.verification.status' "$path")"
|
||||||
|
observed="$(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
target="$(jq -r '.targetRuns' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
passes="$(jq -r '.verificationPasses' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
mismatches="$(jq -r '.verificationMismatches' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
errors="$(jq -r '.verificationErrors' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
skipped="$(jq -r '.verificationSkipped' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
analysis_available="$(jq -r '.verification.analysis.available // false' "$path")"
|
||||||
|
top_phase="$(jq -r '(.verification.analysis.phaseTotals // [])[0] | if . == null then "n/a" else "\(.name)=\(.totalMs)ms" end' "$path")"
|
||||||
|
message="From codex: #137 远端231 verification-only 旁路复核\n${run_id}: ${status}\n"
|
||||||
|
message+="CCR state digest match=$(jq -r 'if .verification.comparison | has("stateDigestMatch") then (.verification.comparison.stateDigestMatch | tostring) else "n/a" end' "$path"); fallback replay match=$(jq -r 'if .verification.comparison.fallbackReplaySelection | has("matches") then (.verification.comparison.fallbackReplaySelection.matches | tostring) else "n/a" end' "$path"); verificationProcessMs=$(jq -r '.verification.timing.verificationProcessMs // .verification.wallMs // "n/a"' "$path"); preValidationPreparationMs=$(jq -r '.verification.timing.preValidationPreparationMs // "n/a"' "$path"); lockWaitMs=$(jq -r '.verification.lockWaitMs // "n/a"' "$path")\n"
|
||||||
|
message+="analysisAvailable=${analysis_available}; topPhase=${top_phase}\n"
|
||||||
|
message+="累计 ${observed}/${target}: pass=${passes}, mismatch=${mismatches}, error=${errors}, skipped=${skipped}\n"
|
||||||
|
message+="结果: ${path}"
|
||||||
|
payload="$(jq -n --arg text "$message" '{msg_type: "text", content: {text: $text}}')"
|
||||||
|
if response="$(curl --fail-with-body --silent --show-error --connect-timeout 10 --max-time 30 \
|
||||||
|
-H 'Content-Type: application/json' --data "$payload" "$WEBHOOK_URL" 2>&1)"; then
|
||||||
|
if jq -e '((.code // .StatusCode // 0) == 0)' <<< "$response" >/dev/null 2>&1; then
|
||||||
|
jq --arg sent_at "$(now_utc)" --arg response "$response" \
|
||||||
|
'.notification = {status: "sent", sentAt: $sent_at, response: $response}' "$path" \
|
||||||
|
| atomic_replace "$path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
response="Feishu rejected payload: $response"
|
||||||
|
fi
|
||||||
|
jq --arg attempted_at "$(now_utc)" --arg error "$response" \
|
||||||
|
'.notification = {status: "pending", lastAttemptAt: $attempted_at, lastError: $error}' "$path" \
|
||||||
|
| atomic_replace "$path"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
deliver_pending_notifications() {
|
||||||
|
local path
|
||||||
|
for path in "$RESULTS_DIR"/run_*.json; do
|
||||||
|
[[ -f "$path" ]] || continue
|
||||||
|
[[ "$(jq -r '.notification.status // "pending"' "$path")" == "sent" ]] && continue
|
||||||
|
update_cumulative
|
||||||
|
notify_result "$path" || true
|
||||||
|
done
|
||||||
|
update_cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
write_result() {
|
||||||
|
local run_id="$1" normal_json="$2" verification_json="$3" result
|
||||||
|
result="$(result_path "$run_id")"
|
||||||
|
jq -n \
|
||||||
|
--arg recorded_at "$(now_utc)" \
|
||||||
|
--arg run_id "$run_id" \
|
||||||
|
--argjson normal_run "$normal_json" \
|
||||||
|
--argjson verification "$verification_json" \
|
||||||
|
'{schemaVersion: 1, recordedAt: $recorded_at, runId: $run_id, normalRun: $normal_run,
|
||||||
|
verification: $verification, notification: {status: "pending"}}' \
|
||||||
|
| atomic_replace "$result"
|
||||||
|
update_cumulative
|
||||||
|
notify_result "$result" || true
|
||||||
|
update_cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
normal_run_summary() {
|
||||||
|
local run_dir="$1"
|
||||||
|
if [[ ! -f "$run_dir/run-meta.json" ]]; then
|
||||||
|
printf '{"status":"invalid-meta"}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
jq -cn --slurpfile meta "$run_dir/run-meta.json" --slurpfile summary "$run_dir/run-summary.json" \
|
||||||
|
'{status: ($meta[0].status // "unknown"), syncMode: ($meta[0].sync_mode // $meta[0].syncMode // "unknown"),
|
||||||
|
runIndex: ($meta[0].run_index // $meta[0].runIndex // null),
|
||||||
|
completedAt: ($meta[0].completed_at_rfc3339_utc // $meta[0].completedAt // null),
|
||||||
|
wallMs: ($summary[0].wall_ms // $summary[0].wallMs // null),
|
||||||
|
exitCode: ($summary[0].exit_code // $summary[0].exitCode // $meta[0].daemon_exit_code // null)}' 2>/dev/null \
|
||||||
|
|| printf '{"status":"invalid-meta"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
process_terminal_run() {
|
||||||
|
local run_id="$1" run_dir="$RUNS_ROOT/$run_id" normal_json status temporary_dir state_lock_fd lock_started_ms lock_acquired_ms
|
||||||
|
local binding_run_id binary_sha source_sha run_started_ms run_finished_ms exit_code comparison_json comparison_state_digest_match fallback_replay_comparison_json fallback_replay_selection_match verification_meta_json stage_timing_json analysis_json analysis_summary_json error_tail max_rss_kb verification_status verification_json timeout_secs completed_at
|
||||||
|
local -a verification_child_exec
|
||||||
|
local observer_started_ms delay_finished_ms preparation_started_ms binding_started_ms binding_finished_ms binary_check_started_ms binary_check_finished_ms window_started_ms window_finished_ms scratch_cleanup_started_ms scratch_cleanup_finished_ms post_validation_started_ms post_validation_collection_finished_ms result_cleanup_started_ms result_cleanup_finished_ms
|
||||||
|
normal_json="$(normal_run_summary "$run_dir")"
|
||||||
|
status="$(jq -r '.status' <<< "$normal_json")"
|
||||||
|
observer_started_ms="$(now_ms)"
|
||||||
|
sleep "$DELAY_SECS"
|
||||||
|
delay_finished_ms="$(now_ms)"
|
||||||
|
if [[ "$status" != "success" ]]; then
|
||||||
|
write_result "$run_id" "$normal_json" '{"status":"skipped_normal_run_not_success"}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
preparation_started_ms="$delay_finished_ms"
|
||||||
|
mkdir -p "$STATE_ROOT/locks"
|
||||||
|
exec {state_lock_fd}>"$STATE_ROOT/locks/state-execution.lock"
|
||||||
|
lock_started_ms="$(now_ms)"
|
||||||
|
if ! flock -n "$state_lock_fd"; then
|
||||||
|
exec {state_lock_fd}>&-
|
||||||
|
write_result "$run_id" "$normal_json" '{"status":"skipped_state_lock_busy"}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
lock_acquired_ms="$(now_ms)"
|
||||||
|
binding_started_ms="$(now_ms)"
|
||||||
|
binding_run_id="$(jq -r '.currentRunId // empty' "$STATE_ROOT/meta/current-state-binding.json" 2>/dev/null || true)"
|
||||||
|
binding_finished_ms="$(now_ms)"
|
||||||
|
if [[ "$binding_run_id" != "$run_id" ]]; then
|
||||||
|
flock -u "$state_lock_fd"
|
||||||
|
exec {state_lock_fd}>&-
|
||||||
|
verification_json="$(jq -n --arg bound_run "$binding_run_id" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
|
||||||
|
'{status: "skipped_source_no_longer_current", boundRunId: $bound_run, lockWaitMs: $lock_wait_ms}')"
|
||||||
|
write_result "$run_id" "$normal_json" "$verification_json"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
binary_check_started_ms="$(now_ms)"
|
||||||
|
source_sha="$(jq -r '.binarySha256 // empty' "$run_dir/validation-contract.json" 2>/dev/null || true)"
|
||||||
|
binary_sha="$(sha256sum "$RPKI_BIN" | awk '{print $1}')"
|
||||||
|
binary_check_finished_ms="$(now_ms)"
|
||||||
|
if [[ -z "$source_sha" || "$source_sha" != "$binary_sha" ]]; then
|
||||||
|
flock -u "$state_lock_fd"
|
||||||
|
exec {state_lock_fd}>&-
|
||||||
|
verification_json="$(jq -n --arg expected "$source_sha" --arg actual "$binary_sha" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
|
||||||
|
'{status: "error_binary_sha256_mismatch", expectedBinarySha256: $expected, actualBinarySha256: $actual, lockWaitMs: $lock_wait_ms}')"
|
||||||
|
write_result "$run_id" "$normal_json" "$verification_json"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
window_started_ms="$(now_ms)"
|
||||||
|
completed_at="$(jq -r '.completedAt // empty' <<< "$normal_json")"
|
||||||
|
timeout_secs="$(remaining_verification_window_secs "$completed_at")"
|
||||||
|
window_finished_ms="$(now_ms)"
|
||||||
|
if (( timeout_secs <= 0 )); then
|
||||||
|
flock -u "$state_lock_fd"
|
||||||
|
exec {state_lock_fd}>&-
|
||||||
|
verification_json="$(jq -n --arg completed_at "$completed_at" --argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
|
||||||
|
'{status: "skipped_insufficient_interval", sourceCompletedAt: $completed_at, lockWaitMs: $lock_wait_ms}')"
|
||||||
|
write_result "$run_id" "$normal_json" "$verification_json"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if (( timeout_secs > VERIFICATION_TIMEOUT_SECS )); then
|
||||||
|
timeout_secs="$VERIFICATION_TIMEOUT_SECS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
temporary_dir="$SCRATCH_ROOT/$run_id"
|
||||||
|
scratch_cleanup_started_ms="$(now_ms)"
|
||||||
|
rm -rf "$temporary_dir"
|
||||||
|
scratch_cleanup_finished_ms="$(now_ms)"
|
||||||
|
run_started_ms="$(now_ms)"
|
||||||
|
verification_child_exec=()
|
||||||
|
if [[ -n "$VERIFICATION_CHILD_PATH" ]]; then
|
||||||
|
verification_child_exec=(/usr/bin/env "PATH=$VERIFICATION_CHILD_PATH")
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
RPKI_STATE_EXECUTION_LOCK_HELD=1 /usr/bin/time -v -o "$temporary_dir.process-time" -- \
|
||||||
|
/usr/bin/timeout --foreground --signal=TERM --kill-after=30s "$timeout_secs" \
|
||||||
|
/usr/bin/ionice -c2 -n0 "${verification_child_exec[@]}" "$RPKI_BIN" \
|
||||||
|
--verification-only --verification-source-run "$run_dir" \
|
||||||
|
--verification-source-state "$STATE_ROOT" --verification-out "$temporary_dir" \
|
||||||
|
--analyze --analysis-out "$temporary_dir/analyze" \
|
||||||
|
--parallel-phase2-object-workers "$VERIFICATION_OBJECT_WORKERS" \
|
||||||
|
>"$temporary_dir.stdout" 2>"$temporary_dir.stderr"
|
||||||
|
exit_code=$?
|
||||||
|
set -e
|
||||||
|
run_finished_ms="$(now_ms)"
|
||||||
|
post_validation_started_ms="$run_finished_ms"
|
||||||
|
flock -u "$state_lock_fd"
|
||||||
|
exec {state_lock_fd}>&-
|
||||||
|
|
||||||
|
comparison_json="$(read_json_or_empty "$temporary_dir/compare/ccr-state-digest.json")"
|
||||||
|
fallback_replay_comparison_json="$(read_json_or_empty "$temporary_dir/compare/fallback-replay-selection.json")"
|
||||||
|
verification_meta_json="$(read_json_or_empty "$temporary_dir/verification-meta.json")"
|
||||||
|
stage_timing_json="$(read_json_or_empty "$temporary_dir/stage-timing.json")"
|
||||||
|
analysis_json="$(read_json_or_empty "$temporary_dir/analyze/timing.json")"
|
||||||
|
analysis_summary_json="$(jq -cn --argjson stage "$stage_timing_json" --argjson analysis "$analysis_json" '
|
||||||
|
{enabled: true,
|
||||||
|
available: (($analysis | type) == "object" and ($analysis | length) > 0),
|
||||||
|
stageTiming: {
|
||||||
|
totalMs: ($stage.total_ms // null),
|
||||||
|
validationMs: ($stage.validation_ms // null),
|
||||||
|
repoSyncMsTotal: ($stage.repo_sync_ms_total // null),
|
||||||
|
publicationPointRepoSyncMsTotal: ($stage.publication_point_repo_sync_ms_total // null),
|
||||||
|
rrdpDownloadMsTotal: ($stage.rrdp_download_ms_total // null),
|
||||||
|
rsyncDownloadMsTotal: ($stage.rsync_download_ms_total // null),
|
||||||
|
reportBuildMs: ($stage.report_build_ms // null),
|
||||||
|
reportWriteMs: ($stage.report_write_ms // null),
|
||||||
|
ccrBuildMs: ($stage.ccr_build_ms // null),
|
||||||
|
ccrWriteMs: ($stage.ccr_write_ms // null),
|
||||||
|
cirTotalMs: ($stage.cir_total_ms // null)
|
||||||
|
},
|
||||||
|
phaseTotals: (($analysis.phases // {})
|
||||||
|
| to_entries
|
||||||
|
| map({name: .key, count: (.value.count // 0), totalMs: (((.value.total_nanos // 0) / 1000000) | floor)})
|
||||||
|
| sort_by(-.totalMs)
|
||||||
|
| .[0:20]),
|
||||||
|
topPublicationPoints: (($analysis.top_publication_points // [])[0:10]),
|
||||||
|
topPublicationPointSteps: (($analysis.top_publication_point_steps // [])[0:10]),
|
||||||
|
topRrdpRepos: (($analysis.top_rrdp_repos // [])[0:10]),
|
||||||
|
topRrdpRepoSteps: (($analysis.top_rrdp_repo_steps // [])[0:10])}')"
|
||||||
|
error_tail="$(tail -n 20 "$temporary_dir.stderr" 2>/dev/null || true)"
|
||||||
|
max_rss_kb="$(awk -F: '/Maximum resident set size/{gsub(/^[[:space:]]+/, "", $2); print $2}' "$temporary_dir.process-time" 2>/dev/null || true)"
|
||||||
|
comparison_state_digest_match="$(jq -r 'if has("stateDigestMatch") then (.stateDigestMatch | tostring) else "missing" end' <<< "$comparison_json")"
|
||||||
|
fallback_replay_selection_match="$(jq -r 'if has("matches") then (.matches | tostring) else "missing" end' <<< "$fallback_replay_comparison_json")"
|
||||||
|
if [[ "$comparison_state_digest_match" == "true" && "$fallback_replay_selection_match" == "true" && "$exit_code" -eq 0 ]]; then
|
||||||
|
verification_status="pass"
|
||||||
|
elif [[ "$comparison_state_digest_match" == "false" || "$fallback_replay_selection_match" == "false" ]]; then
|
||||||
|
verification_status="mismatch"
|
||||||
|
else
|
||||||
|
verification_status="error"
|
||||||
|
fi
|
||||||
|
post_validation_collection_finished_ms="$(now_ms)"
|
||||||
|
result_cleanup_started_ms="$(now_ms)"
|
||||||
|
rm -rf "$temporary_dir" "$temporary_dir.stdout" "$temporary_dir.stderr" "$temporary_dir.process-time"
|
||||||
|
result_cleanup_finished_ms="$(now_ms)"
|
||||||
|
verification_json="$(jq -n \
|
||||||
|
--arg status "$verification_status" \
|
||||||
|
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
|
||||||
|
--argjson exit_code "$exit_code" \
|
||||||
|
--argjson wall_ms "$((run_finished_ms - run_started_ms))" \
|
||||||
|
--argjson lock_wait_ms "$((lock_acquired_ms - lock_started_ms))" \
|
||||||
|
--argjson timeout_limit_secs "$timeout_secs" \
|
||||||
|
--argjson object_workers "$VERIFICATION_OBJECT_WORKERS" \
|
||||||
|
--arg max_rss_kb "${max_rss_kb:-}" \
|
||||||
|
--arg error_tail "$error_tail" \
|
||||||
|
--argjson comparison "$comparison_json" \
|
||||||
|
--argjson fallback_replay_comparison "$fallback_replay_comparison_json" \
|
||||||
|
--argjson meta "$verification_meta_json" \
|
||||||
|
--argjson analysis "$analysis_summary_json" \
|
||||||
|
--argjson post_run_delay_ms "$((delay_finished_ms - observer_started_ms))" \
|
||||||
|
--argjson observer_to_verification_start_ms "$((run_started_ms - observer_started_ms))" \
|
||||||
|
--argjson pre_validation_preparation_ms "$((run_started_ms - preparation_started_ms))" \
|
||||||
|
--argjson binding_check_ms "$((binding_finished_ms - binding_started_ms))" \
|
||||||
|
--argjson binary_contract_check_ms "$((binary_check_finished_ms - binary_check_started_ms))" \
|
||||||
|
--argjson interval_window_calculation_ms "$((window_finished_ms - window_started_ms))" \
|
||||||
|
--argjson scratch_preparation_cleanup_ms "$((scratch_cleanup_finished_ms - scratch_cleanup_started_ms))" \
|
||||||
|
--argjson verification_process_ms "$((run_finished_ms - run_started_ms))" \
|
||||||
|
--argjson post_validation_collection_ms "$((post_validation_collection_finished_ms - post_validation_started_ms))" \
|
||||||
|
--argjson result_cleanup_ms "$((result_cleanup_finished_ms - result_cleanup_started_ms))" \
|
||||||
|
'{status: $status, exitCode: $exit_code, wallMs: $wall_ms, lockWaitMs: $lock_wait_ms,
|
||||||
|
timeoutLimitSecs: $timeout_limit_secs, objectWorkers: $object_workers,
|
||||||
|
verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end),
|
||||||
|
timing: {postRunDelayMs: $post_run_delay_ms,
|
||||||
|
observerToVerificationStartMs: $observer_to_verification_start_ms,
|
||||||
|
preValidationPreparationMs: $pre_validation_preparation_ms,
|
||||||
|
preparationBreakdownMs: {stateLockWaitMs: $lock_wait_ms,
|
||||||
|
bindingCheckMs: $binding_check_ms,
|
||||||
|
binaryContractCheckMs: $binary_contract_check_ms,
|
||||||
|
intervalWindowCalculationMs: $interval_window_calculation_ms,
|
||||||
|
scratchPreparationCleanupMs: $scratch_preparation_cleanup_ms},
|
||||||
|
verificationProcessMs: $verification_process_ms,
|
||||||
|
postValidationCollectionMs: $post_validation_collection_ms,
|
||||||
|
resultCleanupMs: $result_cleanup_ms},
|
||||||
|
maxRssKb: (if $max_rss_kb == "" then null else ($max_rss_kb | tonumber) end),
|
||||||
|
comparison: {stateDigestMatch: ($comparison | if has("stateDigestMatch") then .stateDigestMatch else null end),
|
||||||
|
mismatchedStates: ($comparison.mismatchedStates // []),
|
||||||
|
states: (($comparison.states // []) | map({name, sourcePresent, verificationPresent,
|
||||||
|
sourceHashHex, verificationHashHex, matches})),
|
||||||
|
fallbackReplaySelection: {
|
||||||
|
matches: ($fallback_replay_comparison | if has("matches") then .matches else null end),
|
||||||
|
expectedCount: (($fallback_replay_comparison.expected // []) | length),
|
||||||
|
actualCount: (($fallback_replay_comparison.actual // []) | length),
|
||||||
|
missing: ($fallback_replay_comparison.missing // []),
|
||||||
|
unexpected: ($fallback_replay_comparison.unexpected // [])
|
||||||
|
}},
|
||||||
|
verificationMeta: {status: ($meta.status // null), wallMs: ($meta.wallMs // null),
|
||||||
|
repositoryObjectsChecked: ($meta.repositoryObjectsChecked // null),
|
||||||
|
repositoryBytesChecked: ($meta.repositoryBytesChecked // null)},
|
||||||
|
analysis: $analysis,
|
||||||
|
errorTail: $error_tail}')"
|
||||||
|
write_result "$run_id" "$normal_json" "$verification_json"
|
||||||
|
}
|
||||||
|
|
||||||
|
exec {observer_lock_fd}>"$OBSERVER_LOCK"
|
||||||
|
if ! flock -n "$observer_lock_fd"; then
|
||||||
|
echo "another observer already owns $OBSERVER_LOCK" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$SCRATCH_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||||
|
|
||||||
|
if [[ ! -f "$STATE_FILE" ]]; then
|
||||||
|
initial_run_id="$(latest_terminal_run_id)"
|
||||||
|
[[ -n "$initial_run_id" ]] || { echo "no terminal normal run found under $RUNS_ROOT" >&2; exit 2; }
|
||||||
|
jq -n \
|
||||||
|
--arg started_at "$(now_utc)" \
|
||||||
|
--arg initial_run_id "$initial_run_id" \
|
||||||
|
--arg runs_root "$RUNS_ROOT" \
|
||||||
|
--arg state_root "$STATE_ROOT" \
|
||||||
|
--arg rpki_sha256 "$(sha256sum "$RPKI_BIN" | awk '{print $1}')" \
|
||||||
|
--argjson target_runs "$TARGET_RUNS" \
|
||||||
|
--argjson delay_secs "$DELAY_SECS" \
|
||||||
|
--argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \
|
||||||
|
--argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \
|
||||||
|
--argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \
|
||||||
|
--argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \
|
||||||
|
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
|
||||||
|
'{schemaVersion: 1, startedAt: $started_at, initialLatestTerminalRunId: $initial_run_id,
|
||||||
|
runsRoot: $runs_root, stateRoot: $state_root, verifierBinarySha256: $rpki_sha256,
|
||||||
|
targetRuns: $target_runs, delaySecs: $delay_secs, verificationTimeoutSecs: $verification_timeout_secs,
|
||||||
|
normalIntervalSecs: $normal_interval_secs, normalRunSafetySecs: $normal_run_safety_secs,
|
||||||
|
verificationObjectWorkers: $verification_object_workers,
|
||||||
|
verificationChildPath: (if $verification_child_path == "" then null else $verification_child_path end),
|
||||||
|
verificationAnalyze: true}' \
|
||||||
|
| atomic_replace "$STATE_FILE"
|
||||||
|
update_cumulative
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Preserve the original baseline run while recording the live configuration after
|
||||||
|
# an observer restart. This is particularly important for the normal-run safety
|
||||||
|
# window, whose length depends on the soak interval.
|
||||||
|
jq --arg updated_at "$(now_utc)" \
|
||||||
|
--argjson target_runs "$TARGET_RUNS" \
|
||||||
|
--argjson delay_secs "$DELAY_SECS" \
|
||||||
|
--argjson verification_timeout_secs "$VERIFICATION_TIMEOUT_SECS" \
|
||||||
|
--argjson normal_interval_secs "$NORMAL_INTERVAL_SECS" \
|
||||||
|
--argjson normal_run_safety_secs "$NORMAL_RUN_SAFETY_SECS" \
|
||||||
|
--argjson verification_object_workers "$VERIFICATION_OBJECT_WORKERS" \
|
||||||
|
--arg verification_child_path "$VERIFICATION_CHILD_PATH" \
|
||||||
|
'.updatedAt = $updated_at
|
||||||
|
| .targetRuns = $target_runs
|
||||||
|
| .delaySecs = $delay_secs
|
||||||
|
| .verificationTimeoutSecs = $verification_timeout_secs
|
||||||
|
| .normalIntervalSecs = $normal_interval_secs
|
||||||
|
| .normalRunSafetySecs = $normal_run_safety_secs
|
||||||
|
| .verificationObjectWorkers = $verification_object_workers
|
||||||
|
| .verificationChildPath = (if $verification_child_path == "" then null else $verification_child_path end)
|
||||||
|
| .verificationAnalyze = true' "$STATE_FILE" \
|
||||||
|
| atomic_replace "$STATE_FILE"
|
||||||
|
|
||||||
|
initial_run_number="$(run_number "$(jq -r '.initialLatestTerminalRunId' "$STATE_FILE")")"
|
||||||
|
while true; do
|
||||||
|
deliver_pending_notifications
|
||||||
|
observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)"
|
||||||
|
pending_count="$(jq -r '.notificationPending' "$RESULTS_DIR/cumulative.json")"
|
||||||
|
if (( observed_count >= TARGET_RUNS && pending_count == 0 )); then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( observed_count < TARGET_RUNS )); then
|
||||||
|
while IFS= read -r run_id; do
|
||||||
|
[[ -n "$run_id" ]] || continue
|
||||||
|
(( $(run_number "$run_id") > initial_run_number )) || continue
|
||||||
|
[[ -f "$(result_path "$run_id")" ]] && continue
|
||||||
|
process_terminal_run "$run_id"
|
||||||
|
observed_count="$(find "$RESULTS_DIR" -maxdepth 1 -type f -name 'run_*.json' -printf '%f\n' | wc -l)"
|
||||||
|
(( observed_count < TARGET_RUNS )) || break
|
||||||
|
done < <(terminal_run_ids)
|
||||||
|
fi
|
||||||
|
sleep "$POLL_SECS"
|
||||||
|
done
|
||||||
|
|
||||||
|
update_cumulative
|
||||||
|
echo "completed $(jq -r '.observedRuns' "$RESULTS_DIR/cumulative.json") observed runs; results: $RESULTS_DIR/cumulative.json"
|
||||||
@@ -11,7 +11,7 @@ use rpki::query::report_stream::{ObjectFilter, ObjectScope};
|
|||||||
use rpki::query::vrp::VrpLookup;
|
use rpki::query::vrp::VrpLookup;
|
||||||
use rpki::query_db::{
|
use rpki::query_db::{
|
||||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
||||||
QueryDbError, ValidationExplainRecord,
|
QueryDbError, RepoSortOrder, ValidationExplainRecord,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -150,9 +150,20 @@ fn real_main() -> Result<(), String> {
|
|||||||
let db = Arc::new(open_query_db_for_service(&args).map_err(|e| e.to_string())?);
|
let db = Arc::new(open_query_db_for_service(&args).map_err(|e| e.to_string())?);
|
||||||
let export_jobs = Arc::new(Mutex::new(BTreeMap::new()));
|
let export_jobs = Arc::new(Mutex::new(BTreeMap::new()));
|
||||||
let repo_bytes = match args.repo_bytes_db.as_ref() {
|
let repo_bytes = match args.repo_bytes_db.as_ref() {
|
||||||
Some(path) => Some(Arc::new(
|
Some(path) => {
|
||||||
ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?,
|
let opened = if args.watch_run_root.is_some() {
|
||||||
)),
|
// Watch mode tracks a live soak that keeps writing new object
|
||||||
|
// bytes. A plain read-only open is frozen at open time (new
|
||||||
|
// manifests/CRLs/objects become unparsable until restart), so
|
||||||
|
// use a secondary instance and catch up in the watcher loop.
|
||||||
|
let secondary_path = args.query_db.with_extension("repo-bytes.secondary");
|
||||||
|
ExternalRepoBytesDb::open_as_secondary(path, &secondary_path)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
Some(Arc::new(opened))
|
||||||
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
if let Some(run_root) = args.watch_run_root.clone() {
|
if let Some(run_root) = args.watch_run_root.clone() {
|
||||||
@@ -163,6 +174,7 @@ fn real_main() -> Result<(), String> {
|
|||||||
)?;
|
)?;
|
||||||
spawn_indexer_watcher(
|
spawn_indexer_watcher(
|
||||||
Arc::clone(&db),
|
Arc::clone(&db),
|
||||||
|
repo_bytes.clone(),
|
||||||
args.query_db.clone(),
|
args.query_db.clone(),
|
||||||
resolve_indexer_bin(args.indexer_bin.clone())?,
|
resolve_indexer_bin(args.indexer_bin.clone())?,
|
||||||
run_root,
|
run_root,
|
||||||
@@ -229,6 +241,7 @@ fn resolve_effective_watch_min_run_seq(
|
|||||||
|
|
||||||
fn spawn_indexer_watcher(
|
fn spawn_indexer_watcher(
|
||||||
db: Arc<QueryDb>,
|
db: Arc<QueryDb>,
|
||||||
|
repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
|
||||||
query_db_path: PathBuf,
|
query_db_path: PathBuf,
|
||||||
indexer_bin: PathBuf,
|
indexer_bin: PathBuf,
|
||||||
run_root: PathBuf,
|
run_root: PathBuf,
|
||||||
@@ -250,6 +263,9 @@ fn spawn_indexer_watcher(
|
|||||||
) {
|
) {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
let _ = db.try_catch_up_with_primary();
|
let _ = db.try_catch_up_with_primary();
|
||||||
|
if let Some(repo_bytes) = repo_bytes.as_ref() {
|
||||||
|
let _ = repo_bytes.try_catch_up_with_primary();
|
||||||
|
}
|
||||||
eprintln!("query watcher indexed {}: {message}", run_dir.display());
|
eprintln!("query watcher indexed {}: {message}", run_dir.display());
|
||||||
}
|
}
|
||||||
Err(err) => eprintln!(
|
Err(err) => eprintln!(
|
||||||
@@ -260,6 +276,9 @@ fn spawn_indexer_watcher(
|
|||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let _ = db.try_catch_up_with_primary();
|
let _ = db.try_catch_up_with_primary();
|
||||||
|
if let Some(repo_bytes) = repo_bytes.as_ref() {
|
||||||
|
let _ = repo_bytes.try_catch_up_with_primary();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => eprintln!("query watcher index failed: {err}"),
|
Err(err) => eprintln!("query watcher index failed: {err}"),
|
||||||
}
|
}
|
||||||
@@ -523,7 +542,18 @@ impl ApiError {
|
|||||||
|
|
||||||
impl From<QueryDbError> for ApiError {
|
impl From<QueryDbError> for ApiError {
|
||||||
fn from(value: QueryDbError) -> Self {
|
fn from(value: QueryDbError) -> Self {
|
||||||
Self::new(500, value.to_string())
|
match value {
|
||||||
|
// The run is still indexed but its report.json was removed by
|
||||||
|
// retention cleanup: this is a client-visible "gone", not a
|
||||||
|
// server fault.
|
||||||
|
QueryDbError::RunDataGone(run_id) => Self::new(
|
||||||
|
404,
|
||||||
|
format!(
|
||||||
|
"report data for run {run_id} has expired and was cleaned up by retention"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
other => Self::new(500, other.to_string()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,11 +566,10 @@ fn route_request(
|
|||||||
let (path, query) = split_target(target);
|
let (path, query) = split_target(target);
|
||||||
let query = parse_query(query);
|
let query = parse_query(query);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if segments.is_empty() {
|
if segments.is_empty() {
|
||||||
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
||||||
@@ -617,10 +646,11 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "repos"] => {
|
["runs", raw_run_id, "repos"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
page_response(
|
let page = match repo_sort_from_query(&query)? {
|
||||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||||
Some(run_id),
|
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||||
)
|
};
|
||||||
|
page_response(page, Some(run_id))
|
||||||
}
|
}
|
||||||
["runs", raw_run_id, "repos", repo_id] => {
|
["runs", raw_run_id, "repos", repo_id] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
@@ -907,10 +937,11 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "stats", "repos"] => {
|
["runs", raw_run_id, "stats", "repos"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
page_response(
|
let page = match repo_sort_from_query(&query)? {
|
||||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||||
Some(run_id),
|
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||||
)
|
};
|
||||||
|
page_response(page, Some(run_id))
|
||||||
}
|
}
|
||||||
["runs", raw_run_id, "stats", "publication-points"] => {
|
["runs", raw_run_id, "stats", "publication-points"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
@@ -983,11 +1014,10 @@ fn route_post_request(
|
|||||||
) -> Result<Value, ApiError> {
|
) -> Result<Value, ApiError> {
|
||||||
let (path, _) = split_target(target);
|
let (path, _) = split_target(target);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["runs", raw_run_id, "exports"] => {
|
["runs", raw_run_id, "exports"] => {
|
||||||
@@ -1140,11 +1170,10 @@ fn route_raw_request(
|
|||||||
) -> Option<Result<Vec<u8>, ApiError>> {
|
) -> Option<Result<Vec<u8>, ApiError>> {
|
||||||
let (path, _) = split_target(target);
|
let (path, _) = split_target(target);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
|
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
|
||||||
@@ -1205,7 +1234,20 @@ fn export_download_response(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::new(404, "export output path not found"))?;
|
.ok_or_else(|| ApiError::new(404, "export output path not found"))?;
|
||||||
let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?;
|
let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?;
|
||||||
Ok(binary_response(200, "application/x-tar", bytes))
|
let filename = format!(
|
||||||
|
"rpki-export-{}-{}.tar",
|
||||||
|
sanitize_filename_token(&run_id),
|
||||||
|
sanitize_filename_token(job_id)
|
||||||
|
);
|
||||||
|
Ok(binary_response_with_headers(
|
||||||
|
200,
|
||||||
|
"application/x-tar",
|
||||||
|
&[(
|
||||||
|
"Content-Disposition".to_string(),
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
)],
|
||||||
|
bytes,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn export_job_store_key(run_id: &str, job_id: &str) -> String {
|
fn export_job_store_key(run_id: &str, job_id: &str) -> String {
|
||||||
@@ -2079,6 +2121,18 @@ fn hex_value(byte: u8) -> Option<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split an API path into percent-decoded segments. Real-world ids are plain
|
||||||
|
/// hex, so decoding is a no-op for them; it only matters for hand-built URLs
|
||||||
|
/// (e.g. an id containing a literal `%2F`).
|
||||||
|
fn decode_path_segments(path: &str) -> Vec<String> {
|
||||||
|
path.trim_start_matches("/api/v1")
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.split('/')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|segment| percent_decode(segment).unwrap_or_else(|| segment.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn limit(query: &BTreeMap<String, String>) -> usize {
|
fn limit(query: &BTreeMap<String, String>) -> usize {
|
||||||
query
|
query
|
||||||
.get("limit")
|
.get("limit")
|
||||||
@@ -2103,6 +2157,18 @@ fn exports_limit(query: &BTreeMap<String, String>) -> usize {
|
|||||||
.clamp(1, 200)
|
.clamp(1, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repo_sort_from_query(query: &BTreeMap<String, String>) -> Result<Option<RepoSortOrder>, ApiError> {
|
||||||
|
match query.get("sort").map(|value| value.as_str()) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some("objects") => match query.get("order").map(|value| value.as_str()) {
|
||||||
|
None | Some("desc") => Ok(Some(RepoSortOrder::ObjectsDesc)),
|
||||||
|
Some("asc") => Ok(Some(RepoSortOrder::ObjectsAsc)),
|
||||||
|
Some(other) => Err(ApiError::new(400, format!("invalid repos order: {other}"))),
|
||||||
|
},
|
||||||
|
Some(other) => Err(ApiError::new(400, format!("invalid repos sort: {other}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
|
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
|
||||||
let mut filter = ObjectFilter::default();
|
let mut filter = ObjectFilter::default();
|
||||||
if let Some(value) = query.get("type") {
|
if let Some(value) = query.get("type") {
|
||||||
@@ -2229,13 +2295,26 @@ fn json_response(status: u16, value: &Value) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
||||||
|
binary_response_with_headers(status, content_type, &[], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_response_with_headers(
|
||||||
|
status: u16,
|
||||||
|
content_type: &str,
|
||||||
|
extra_headers: &[(String, String)],
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> Vec<u8> {
|
||||||
let reason = match status {
|
let reason = match status {
|
||||||
200 => "OK",
|
200 => "OK",
|
||||||
404 => "Not Found",
|
404 => "Not Found",
|
||||||
_ => "Internal Server Error",
|
_ => "Internal Server Error",
|
||||||
};
|
};
|
||||||
|
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n");
|
||||||
|
for (name, value) in extra_headers {
|
||||||
|
head.push_str(&format!("{name}: {value}\r\n"));
|
||||||
|
}
|
||||||
let mut response = format!(
|
let mut response = format!(
|
||||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
"{head}Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
body.len()
|
body.len()
|
||||||
)
|
)
|
||||||
.into_bytes();
|
.into_bytes();
|
||||||
@@ -2243,6 +2322,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Header-safe token for use inside a `Content-Disposition` filename: strips
|
||||||
|
/// anything that could smuggle CRLF or quotes into the response head.
|
||||||
|
fn sanitize_filename_token(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn is_sha256_hex(value: &str) -> bool {
|
fn is_sha256_hex(value: &str) -> bool {
|
||||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
}
|
}
|
||||||
@@ -2607,6 +2695,94 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn index_sample_run(temp: &tempfile::TempDir) -> QueryDb {
|
||||||
|
let run_dir = temp.path().join("runs/run_0001");
|
||||||
|
fs::create_dir_all(&run_dir).expect("run dir");
|
||||||
|
let object_bytes =
|
||||||
|
fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa")
|
||||||
|
.expect("fixture roa");
|
||||||
|
let object_sha = hex::encode(Sha256::digest(&object_bytes));
|
||||||
|
write_sample_run(&run_dir, &object_sha);
|
||||||
|
let query_db_path = temp.path().join("query-db");
|
||||||
|
index_artifacts(&ArtifactIndexerConfig {
|
||||||
|
query_db_path: query_db_path.clone(),
|
||||||
|
run_root: Some(temp.path().to_path_buf()),
|
||||||
|
run_dir: None,
|
||||||
|
repo_bytes_db_path: None,
|
||||||
|
projection_entry_limit: 5,
|
||||||
|
min_run_seq: None,
|
||||||
|
retain_indexed_runs: None,
|
||||||
|
})
|
||||||
|
.expect("index artifacts");
|
||||||
|
QueryDb::open(&query_db_path).expect("query db")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_run_id_returns_404_instead_of_empty_payload() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let db = index_sample_run(&temp);
|
||||||
|
|
||||||
|
for target in [
|
||||||
|
"/api/v1/runs/run_9999",
|
||||||
|
"/api/v1/runs/run_9999/summary",
|
||||||
|
"/api/v1/runs/run_9999/objects",
|
||||||
|
"/api/v1/runs/run_9999/issues",
|
||||||
|
"/api/v1/runs/run_9999/stats/overview",
|
||||||
|
"/api/v1/runs/run_9999/stats/validation-events?name=manifest",
|
||||||
|
] {
|
||||||
|
let err = match test_route_request(&db, None, target) {
|
||||||
|
Ok(_) => panic!("expected 404 for {target}"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert_eq!(err.status, 404, "{target}");
|
||||||
|
}
|
||||||
|
// Existing runs and "latest" keep working.
|
||||||
|
assert!(
|
||||||
|
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
|
||||||
|
);
|
||||||
|
assert!(test_route_request(&db, None, "/api/v1/runs/latest/objects").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_run_report_returns_404_run_data_gone() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let db = index_sample_run(&temp);
|
||||||
|
assert!(test_route_request(&db, None, "/api/v1/runs/run_0001/objects").is_ok());
|
||||||
|
|
||||||
|
// Retention cleanup removes the run directory while the index record
|
||||||
|
// survives: object routes must report a distinguishable 404.
|
||||||
|
fs::remove_dir_all(temp.path().join("runs/run_0001")).expect("remove run dir");
|
||||||
|
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/objects")
|
||||||
|
.expect_err("run data gone");
|
||||||
|
assert_eq!(err.status, 404);
|
||||||
|
assert!(
|
||||||
|
err.message.contains("expired"),
|
||||||
|
"unexpected message: {}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
let err = test_route_request(&db, None, "/api/v1/runs/run_0001/issues")
|
||||||
|
.expect_err("run data gone");
|
||||||
|
assert_eq!(err.status, 404);
|
||||||
|
// Stats served from the index keep working.
|
||||||
|
assert!(
|
||||||
|
test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_path_segments_percent_decodes_ids() {
|
||||||
|
let segments = decode_path_segments("/api/v1/runs/run_0001/objects/abc%2Fdef");
|
||||||
|
assert_eq!(segments, vec!["runs", "run_0001", "objects", "abc/def"]);
|
||||||
|
assert!(decode_path_segments("/api/v1").is_empty());
|
||||||
|
assert!(decode_path_segments("/api/v1/").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_filename_token_strips_crlf_and_quotes() {
|
||||||
|
assert_eq!(sanitize_filename_token("run_0001"), "run_0001");
|
||||||
|
assert_eq!(sanitize_filename_token("a\r\nb\"c d/e"), "abcde");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
|
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -2794,6 +2970,31 @@ mod tests {
|
|||||||
assert_eq!(job.status, "complete");
|
assert_eq!(job.status, "complete");
|
||||||
assert_eq!(job.object_count, 1);
|
assert_eq!(job.object_count, 1);
|
||||||
assert!(job.bytes_written > 0);
|
assert!(job.bytes_written > 0);
|
||||||
|
|
||||||
|
let download = test_route_raw_request(
|
||||||
|
&db,
|
||||||
|
None,
|
||||||
|
&format!("/api/v1/runs/latest/exports/{job_id}/download"),
|
||||||
|
)
|
||||||
|
.expect("download route")
|
||||||
|
.expect("download response");
|
||||||
|
let head_end = download
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.expect("header separator");
|
||||||
|
let head = String::from_utf8_lossy(&download[..head_end]);
|
||||||
|
assert!(head.starts_with("HTTP/1.1 200 OK"), "head: {head}");
|
||||||
|
assert!(
|
||||||
|
head.contains("Content-Type: application/x-tar\r\n"),
|
||||||
|
"head: {head}"
|
||||||
|
);
|
||||||
|
let expected_disposition = format!(
|
||||||
|
"Content-Disposition: attachment; filename=\"rpki-export-run_0001-{job_id}.tar\""
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
head.contains(&expected_disposition),
|
||||||
|
"head: {head}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ pub struct ExternalRepoBytesDb {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
db: Arc<DB>,
|
db: Arc<DB>,
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
|
secondary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalRawStoreDb {
|
impl ExternalRawStoreDb {
|
||||||
@@ -89,6 +90,7 @@ impl ExternalRawStoreDb {
|
|||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
@@ -186,11 +188,13 @@ impl ExternalRepoBytesDb {
|
|||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
read_only: false,
|
read_only: false,
|
||||||
|
secondary: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,15 +202,60 @@ impl ExternalRepoBytesDb {
|
|||||||
let path = path.into();
|
let path = path.into();
|
||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let db = DB::open_for_read_only(&opts, &path, false)
|
let db = DB::open_for_read_only(&opts, &path, false)
|
||||||
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
read_only: true,
|
read_only: true,
|
||||||
|
secondary: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the repo-bytes DB as a RocksDB secondary instance.
|
||||||
|
///
|
||||||
|
/// Unlike `open_read_only` (a frozen point-in-time view), a secondary
|
||||||
|
/// instance can follow the live primary via `try_catch_up_with_primary`,
|
||||||
|
/// which is required when the soak keeps writing new object bytes while
|
||||||
|
/// the query service is running.
|
||||||
|
pub fn open_as_secondary(
|
||||||
|
path: impl Into<PathBuf>,
|
||||||
|
secondary_path: impl Into<PathBuf>,
|
||||||
|
) -> StorageResult<Self> {
|
||||||
|
let path = path.into();
|
||||||
|
let secondary_path = secondary_path.into();
|
||||||
|
if let Some(parent) = secondary_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
|
}
|
||||||
|
let mut opts = Options::default();
|
||||||
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
|
let db = DB::open_as_secondary(&opts, &path, &secondary_path)
|
||||||
|
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
db: Arc::new(db),
|
||||||
|
read_only: true,
|
||||||
|
secondary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the secondary view up to the primary's current state. No-op for
|
||||||
|
/// non-secondary handles.
|
||||||
|
pub fn try_catch_up_with_primary(&self) -> StorageResult<()> {
|
||||||
|
if self.secondary {
|
||||||
|
self.db
|
||||||
|
.try_catch_up_with_primary()
|
||||||
|
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_secondary(&self) -> bool {
|
||||||
|
self.secondary
|
||||||
|
}
|
||||||
|
|
||||||
pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
|
pub fn put_blob_bytes_batch(&self, blobs: &[(String, Vec<u8>)]) -> StorageResult<()> {
|
||||||
if blobs.is_empty() {
|
if blobs.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -740,4 +789,48 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(repo_bytes.get_blob_bytes("not-a-valid-hash").is_err());
|
assert!(repo_bytes.get_blob_bytes("not-a-valid-hash").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_repo_bytes_db_secondary_catches_up_with_live_primary() {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let primary_path = td.path().join("repo-bytes.db");
|
||||||
|
let secondary_path = td.path().join("repo-bytes.secondary");
|
||||||
|
|
||||||
|
let primary = ExternalRepoBytesDb::open(&primary_path).expect("open primary");
|
||||||
|
let bytes_a = b"repo-bytes-a".to_vec();
|
||||||
|
let hash_a = sha256_hex(&bytes_a);
|
||||||
|
primary
|
||||||
|
.put_blob_bytes_batch(&[(hash_a.clone(), bytes_a.clone())])
|
||||||
|
.expect("put a");
|
||||||
|
|
||||||
|
let secondary =
|
||||||
|
ExternalRepoBytesDb::open_as_secondary(&primary_path, &secondary_path)
|
||||||
|
.expect("open secondary");
|
||||||
|
assert!(secondary.is_secondary());
|
||||||
|
|
||||||
|
// A secondary open does not necessarily see pre-existing data until it
|
||||||
|
// catches up with the primary's manifest.
|
||||||
|
secondary
|
||||||
|
.try_catch_up_with_primary()
|
||||||
|
.expect("initial catch up");
|
||||||
|
assert_eq!(
|
||||||
|
secondary.get_blob_bytes(&hash_a).expect("get a via secondary"),
|
||||||
|
Some(bytes_a)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bytes written by the primary *after* the secondary open become
|
||||||
|
// visible after another catch-up (this is the live-soak scenario).
|
||||||
|
let bytes_b = b"repo-bytes-b".to_vec();
|
||||||
|
let hash_b = sha256_hex(&bytes_b);
|
||||||
|
primary
|
||||||
|
.put_blob_bytes_batch(&[(hash_b.clone(), bytes_b.clone())])
|
||||||
|
.expect("put b after secondary open");
|
||||||
|
secondary
|
||||||
|
.try_catch_up_with_primary()
|
||||||
|
.expect("second catch up");
|
||||||
|
assert_eq!(
|
||||||
|
secondary.get_blob_bytes(&hash_b).expect("get b via secondary"),
|
||||||
|
Some(bytes_b)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ mod tests {
|
|||||||
sha256: manifest_hash,
|
sha256: manifest_hash,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -729,6 +729,7 @@ mod tests {
|
|||||||
sha256: hash,
|
sha256: hash,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
|
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: crate::storage::VcirSummary {
|
summary: crate::storage::VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ mod tests {
|
|||||||
sha256: hash,
|
sha256: hash,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -645,6 +645,7 @@ mod tests {
|
|||||||
sha256: "ff".repeat(32),
|
sha256: "ff".repeat(32),
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -140,6 +140,26 @@ impl CirInputAccumulator {
|
|||||||
rejects.entry(rsync_uri.to_string()).or_insert(reason);
|
rejects.entry(rsync_uri.to_string()).or_insert(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge another accumulator into this one. Object digests conflict-check
|
||||||
|
/// exactly like repeated `insert_object` calls; rejects keep the first
|
||||||
|
/// reason seen, matching sequential submission order when `other` holds
|
||||||
|
/// later publication points than `self`.
|
||||||
|
pub fn merge(&mut self, other: CirInputAccumulator) -> Result<(), CirExportError> {
|
||||||
|
for (rsync_uri, digest) in other.fresh_objects {
|
||||||
|
self.insert_object_digest(CirInputSection::Fresh, &rsync_uri, digest)?;
|
||||||
|
}
|
||||||
|
for (rsync_uri, digest) in other.cached_objects {
|
||||||
|
self.insert_object_digest(CirInputSection::Cached, &rsync_uri, digest)?;
|
||||||
|
}
|
||||||
|
for (rsync_uri, reason) in other.fresh_rejects {
|
||||||
|
self.insert_reject(CirInputSection::Fresh, &rsync_uri, reason);
|
||||||
|
}
|
||||||
|
for (rsync_uri, reason) in other.cached_rejects {
|
||||||
|
self.insert_reject(CirInputSection::Cached, &rsync_uri, reason);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn finalize(self) -> CirInputSnapshot {
|
pub fn finalize(self) -> CirInputSnapshot {
|
||||||
CirInputSnapshot {
|
CirInputSnapshot {
|
||||||
fresh_validated_objects: finalize_objects(self.fresh_objects),
|
fresh_validated_objects: finalize_objects(self.fresh_objects),
|
||||||
@@ -280,4 +300,95 @@ mod tests {
|
|||||||
assert_eq!(merged.cached_validated_objects.len(), 1);
|
assert_eq!(merged.cached_validated_objects.len(), 1);
|
||||||
assert_eq!(merged.fresh_rejected_objects.len(), 1);
|
assert_eq!(merged.fresh_rejected_objects.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accumulator_merge_matches_sequential_submission() {
|
||||||
|
let mut sequential = CirInputAccumulator::default();
|
||||||
|
sequential
|
||||||
|
.submit_audit_entries(
|
||||||
|
CirInputSection::Fresh,
|
||||||
|
&[
|
||||||
|
entry("rsync://example.net/a.roa", 0x11, AuditObjectResult::Ok),
|
||||||
|
entry("rsync://example.net/b.roa", 0x22, AuditObjectResult::Error),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
sequential
|
||||||
|
.submit_audit_entries(
|
||||||
|
CirInputSection::Cached,
|
||||||
|
&[entry(
|
||||||
|
"rsync://example.net/c.roa",
|
||||||
|
0x33,
|
||||||
|
AuditObjectResult::Ok,
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut first = CirInputAccumulator::default();
|
||||||
|
first
|
||||||
|
.submit_audit_entries(
|
||||||
|
CirInputSection::Fresh,
|
||||||
|
&[entry("rsync://example.net/a.roa", 0x11, AuditObjectResult::Ok)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut second = CirInputAccumulator::default();
|
||||||
|
second
|
||||||
|
.submit_audit_entries(
|
||||||
|
CirInputSection::Fresh,
|
||||||
|
&[entry(
|
||||||
|
"rsync://example.net/b.roa",
|
||||||
|
0x22,
|
||||||
|
AuditObjectResult::Error,
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
second
|
||||||
|
.submit_audit_entries(
|
||||||
|
CirInputSection::Cached,
|
||||||
|
&[entry(
|
||||||
|
"rsync://example.net/c.roa",
|
||||||
|
0x33,
|
||||||
|
AuditObjectResult::Ok,
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
first.merge(second).unwrap();
|
||||||
|
assert_eq!(sequential.finalize(), first.finalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accumulator_merge_detects_conflicting_hashes_across_shards() {
|
||||||
|
let mut first = CirInputAccumulator::default();
|
||||||
|
first
|
||||||
|
.insert_object(CirInputSection::Fresh, "rsync://example.net/a.roa", &"11".repeat(32))
|
||||||
|
.unwrap();
|
||||||
|
let mut second = CirInputAccumulator::default();
|
||||||
|
second
|
||||||
|
.insert_object(CirInputSection::Fresh, "rsync://example.net/a.roa", &"22".repeat(32))
|
||||||
|
.unwrap();
|
||||||
|
let err = first.merge(second).unwrap_err();
|
||||||
|
assert!(matches!(err, CirExportError::ConflictingObjectHash { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accumulator_merge_keeps_first_reject_reason() {
|
||||||
|
let mut first = CirInputAccumulator::default();
|
||||||
|
first.insert_reject(
|
||||||
|
CirInputSection::Fresh,
|
||||||
|
"rsync://example.net/a.roa",
|
||||||
|
Some("first".to_string()),
|
||||||
|
);
|
||||||
|
let mut second = CirInputAccumulator::default();
|
||||||
|
second.insert_reject(
|
||||||
|
CirInputSection::Fresh,
|
||||||
|
"rsync://example.net/a.roa",
|
||||||
|
Some("second".to_string()),
|
||||||
|
);
|
||||||
|
first.merge(second).unwrap();
|
||||||
|
let snapshot = first.finalize();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.fresh_rejected_objects[0].reason,
|
||||||
|
Some("first".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
410
src/cli.rs
410
src/cli.rs
@@ -55,6 +55,7 @@ struct RunStageTiming {
|
|||||||
enable_child_certificate_validation_cache: bool,
|
enable_child_certificate_validation_cache: bool,
|
||||||
publication_point_cache_observe_only: bool,
|
publication_point_cache_observe_only: bool,
|
||||||
enable_publication_point_validation_cache: bool,
|
enable_publication_point_validation_cache: bool,
|
||||||
|
crypto_signature_cache_observe: Option<crate::crypto_sig_cache::CryptoSigCacheSummary>,
|
||||||
enable_transport_request_prefetch: bool,
|
enable_transport_request_prefetch: bool,
|
||||||
report_build_ms: u64,
|
report_build_ms: u64,
|
||||||
report_write_ms: Option<u64>,
|
report_write_ms: Option<u64>,
|
||||||
@@ -145,6 +146,8 @@ pub struct CliArgs {
|
|||||||
pub enable_child_certificate_validation_cache: bool,
|
pub enable_child_certificate_validation_cache: bool,
|
||||||
pub publication_point_cache_observe_only: bool,
|
pub publication_point_cache_observe_only: bool,
|
||||||
pub enable_publication_point_validation_cache: bool,
|
pub enable_publication_point_validation_cache: bool,
|
||||||
|
pub crypto_signature_cache_observe_only: bool,
|
||||||
|
pub enable_crypto_signature_cache: bool,
|
||||||
pub enable_transport_request_prefetch: bool,
|
pub enable_transport_request_prefetch: bool,
|
||||||
pub ccr_out_path: Option<PathBuf>,
|
pub ccr_out_path: Option<PathBuf>,
|
||||||
pub vrps_csv_out_path: Option<PathBuf>,
|
pub vrps_csv_out_path: Option<PathBuf>,
|
||||||
@@ -222,6 +225,12 @@ Options:
|
|||||||
Evaluate publication-point cache eligibility without changing results
|
Evaluate publication-point cache eligibility without changing results
|
||||||
--enable-publication-point-validation-cache
|
--enable-publication-point-validation-cache
|
||||||
Experimental: reuse complete publication-point validation projections
|
Experimental: reuse complete publication-point validation projections
|
||||||
|
--crypto-signature-cache-observe-only
|
||||||
|
Measure crypto signature cache key hit rates and verify durations
|
||||||
|
without changing validation behavior (default: off)
|
||||||
|
--enable-crypto-signature-cache
|
||||||
|
Experimental: skip cryptographic signature verification on cache
|
||||||
|
hits (positive conclusions only; default: off)
|
||||||
--enable-transport-request-prefetch
|
--enable-transport-request-prefetch
|
||||||
Experimental: prefetch previous run transport repo requests before tree traversal
|
Experimental: prefetch previous run transport repo requests before tree traversal
|
||||||
--ccr-out <path> Write CCR DER ContentInfo to this path (optional)
|
--ccr-out <path> Write CCR DER ContentInfo to this path (optional)
|
||||||
@@ -267,6 +276,16 @@ Options:
|
|||||||
Legacy Phase 2 scheduler finalize time budget; dedicated finalize worker ignores it (default: 100)
|
Legacy Phase 2 scheduler finalize time budget; dedicated finalize worker ignores it (default: 100)
|
||||||
--parallel-phase2-finalize-queue-capacity <n>
|
--parallel-phase2-finalize-queue-capacity <n>
|
||||||
Phase 2 dedicated finalize worker queue capacity (default: 32768)
|
Phase 2 dedicated finalize worker queue capacity (default: 32768)
|
||||||
|
--control-plane-stage-workers <n>
|
||||||
|
Experimental: Phase 2 ready publication point stage worker count;
|
||||||
|
0 disables the stage pool and keeps inline staging (default: 0)
|
||||||
|
--dead-repo-blacklist <path>
|
||||||
|
Enable the dead-repo transport blacklist persisted at this JSON
|
||||||
|
path (default: disabled). Blacklisted rrdp repos skip straight to
|
||||||
|
rsync; dual-blacklisted repos terminate instantly.
|
||||||
|
--dead-repo-blacklist-fail-threshold <n>
|
||||||
|
Consecutive runs with transport-class fetch failure before a
|
||||||
|
(repo, transport) entry is blacklisted (default: 3)
|
||||||
|
|
||||||
--rsync-local-dir <path> Use LocalDirRsyncFetcher rooted at this directory (offline tests)
|
--rsync-local-dir <path> Use LocalDirRsyncFetcher rooted at this directory (offline tests)
|
||||||
--disable-rrdp Disable RRDP and synchronize only via rsync
|
--disable-rrdp Disable RRDP and synchronize only via rsync
|
||||||
@@ -301,6 +320,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
let mut ta_paths: Vec<PathBuf> = Vec::new();
|
let mut ta_paths: Vec<PathBuf> = Vec::new();
|
||||||
let mut parallel_phase1_cfg = ParallelPhase1Config::default();
|
let mut parallel_phase1_cfg = ParallelPhase1Config::default();
|
||||||
let mut parallel_phase2_cfg = ParallelPhase2Config::default();
|
let mut parallel_phase2_cfg = ParallelPhase2Config::default();
|
||||||
|
let mut dead_repo_blacklist_path: Option<PathBuf> = None;
|
||||||
|
let mut dead_repo_blacklist_fail_threshold: Option<u32> = None;
|
||||||
|
|
||||||
let mut db_path: Option<PathBuf> = None;
|
let mut db_path: Option<PathBuf> = None;
|
||||||
let mut raw_store_db: Option<PathBuf> = None;
|
let mut raw_store_db: Option<PathBuf> = None;
|
||||||
@@ -315,6 +336,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
let mut enable_roa_validation_cache: bool = false;
|
let mut enable_roa_validation_cache: bool = false;
|
||||||
let mut enable_child_certificate_validation_cache: bool = false;
|
let mut enable_child_certificate_validation_cache: bool = false;
|
||||||
let mut publication_point_cache_observe_only: bool = false;
|
let mut publication_point_cache_observe_only: bool = false;
|
||||||
|
let mut crypto_signature_cache_observe_only: bool = false;
|
||||||
|
let mut enable_crypto_signature_cache: bool = false;
|
||||||
let mut enable_publication_point_validation_cache: bool = false;
|
let mut enable_publication_point_validation_cache: bool = false;
|
||||||
let mut enable_transport_request_prefetch: bool = false;
|
let mut enable_transport_request_prefetch: bool = false;
|
||||||
let mut ccr_out_path: Option<PathBuf> = None;
|
let mut ccr_out_path: Option<PathBuf> = None;
|
||||||
@@ -508,6 +531,32 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
format!("invalid --parallel-phase2-finalize-queue-capacity: {v}")
|
format!("invalid --parallel-phase2-finalize-queue-capacity: {v}")
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
"--control-plane-stage-workers" => {
|
||||||
|
i += 1;
|
||||||
|
let v = argv
|
||||||
|
.get(i)
|
||||||
|
.ok_or("--control-plane-stage-workers requires a value")?;
|
||||||
|
parallel_phase2_cfg.stage_workers = v
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("invalid --control-plane-stage-workers: {v}"))?;
|
||||||
|
}
|
||||||
|
"--dead-repo-blacklist" => {
|
||||||
|
i += 1;
|
||||||
|
let v = argv
|
||||||
|
.get(i)
|
||||||
|
.ok_or("--dead-repo-blacklist requires a value")?;
|
||||||
|
dead_repo_blacklist_path = Some(PathBuf::from(v));
|
||||||
|
}
|
||||||
|
"--dead-repo-blacklist-fail-threshold" => {
|
||||||
|
i += 1;
|
||||||
|
let v = argv
|
||||||
|
.get(i)
|
||||||
|
.ok_or("--dead-repo-blacklist-fail-threshold requires a value")?;
|
||||||
|
dead_repo_blacklist_fail_threshold = Some(
|
||||||
|
v.parse::<u32>()
|
||||||
|
.map_err(|_| format!("invalid --dead-repo-blacklist-fail-threshold: {v}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
"--db" => {
|
"--db" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
let v = argv.get(i).ok_or("--db requires a value")?;
|
let v = argv.get(i).ok_or("--db requires a value")?;
|
||||||
@@ -579,6 +628,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
"--enable-publication-point-validation-cache" => {
|
"--enable-publication-point-validation-cache" => {
|
||||||
enable_publication_point_validation_cache = true;
|
enable_publication_point_validation_cache = true;
|
||||||
}
|
}
|
||||||
|
"--crypto-signature-cache-observe-only" => {
|
||||||
|
crypto_signature_cache_observe_only = true;
|
||||||
|
}
|
||||||
|
"--enable-crypto-signature-cache" => {
|
||||||
|
enable_crypto_signature_cache = true;
|
||||||
|
}
|
||||||
"--enable-transport-request-prefetch" => {
|
"--enable-transport-request-prefetch" => {
|
||||||
enable_transport_request_prefetch = true;
|
enable_transport_request_prefetch = true;
|
||||||
}
|
}
|
||||||
@@ -808,6 +863,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
|| enable_child_certificate_validation_cache
|
|| enable_child_certificate_validation_cache
|
||||||
|| publication_point_cache_observe_only
|
|| publication_point_cache_observe_only
|
||||||
|| enable_publication_point_validation_cache
|
|| enable_publication_point_validation_cache
|
||||||
|
|| crypto_signature_cache_observe_only
|
||||||
|
|| enable_crypto_signature_cache
|
||||||
|| enable_transport_request_prefetch
|
|| enable_transport_request_prefetch
|
||||||
|| rsync_local_dir.is_some()
|
|| rsync_local_dir.is_some()
|
||||||
|| rsync_command.is_some()
|
|| rsync_command.is_some()
|
||||||
@@ -1091,6 +1148,25 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dead_repo_blacklist_fail_threshold.is_some() && dead_repo_blacklist_path.is_none() {
|
||||||
|
return Err(
|
||||||
|
"--dead-repo-blacklist-fail-threshold requires --dead-repo-blacklist".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(path) = dead_repo_blacklist_path {
|
||||||
|
let fail_threshold = dead_repo_blacklist_fail_threshold.unwrap_or(3);
|
||||||
|
if fail_threshold == 0 {
|
||||||
|
return Err("--dead-repo-blacklist-fail-threshold must be >= 1".to_string());
|
||||||
|
}
|
||||||
|
parallel_phase1_cfg.dead_repo_blacklist =
|
||||||
|
Some(crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig {
|
||||||
|
path,
|
||||||
|
fail_threshold,
|
||||||
|
capacity:
|
||||||
|
crate::parallel::dead_repo_blacklist::DEAD_REPO_BLACKLIST_DEFAULT_CAPACITY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CliArgs {
|
Ok(CliArgs {
|
||||||
verification_only,
|
verification_only,
|
||||||
verification_source_run_dir,
|
verification_source_run_dir,
|
||||||
@@ -1121,6 +1197,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
|||||||
enable_child_certificate_validation_cache,
|
enable_child_certificate_validation_cache,
|
||||||
publication_point_cache_observe_only,
|
publication_point_cache_observe_only,
|
||||||
enable_publication_point_validation_cache,
|
enable_publication_point_validation_cache,
|
||||||
|
crypto_signature_cache_observe_only,
|
||||||
|
enable_crypto_signature_cache,
|
||||||
enable_transport_request_prefetch,
|
enable_transport_request_prefetch,
|
||||||
ccr_out_path,
|
ccr_out_path,
|
||||||
vrps_csv_out_path,
|
vrps_csv_out_path,
|
||||||
@@ -2152,6 +2230,36 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn policy_for_verification_contract(
|
||||||
|
contract: &crate::verification_only::ValidationContract,
|
||||||
|
) -> crate::policy::Policy {
|
||||||
|
// Preserve every contracted policy field, including sync_preference. The
|
||||||
|
// verification fetchers themselves are network-disabled; changing this
|
||||||
|
// field would change the VCIR reuse-identity fingerprint and falsely make
|
||||||
|
// a contract-selected fallback ineligible.
|
||||||
|
let mut policy = contract.policy.clone();
|
||||||
|
policy.ca_failed_fetch_policy = if contract.fallback_replay_selections.is_empty() {
|
||||||
|
crate::policy::CaFailedFetchPolicy::StopAllOutput
|
||||||
|
} else {
|
||||||
|
crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir
|
||||||
|
};
|
||||||
|
policy.verification_forced_vcir_reuse_manifest_uris = contract
|
||||||
|
.fallback_replay_selections
|
||||||
|
.iter()
|
||||||
|
.map(|selection| selection.manifest_rsync_uri.clone())
|
||||||
|
.collect();
|
||||||
|
policy
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_prefetch_for_verification_contract(
|
||||||
|
contract: &crate::verification_only::ValidationContract,
|
||||||
|
) -> bool {
|
||||||
|
// This only restores the source run's transport scheduling and request
|
||||||
|
// deduplication topology. It does not enable validation-result reuse or
|
||||||
|
// permit network access.
|
||||||
|
contract.cache.transport_prefetch
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(argv: &[String]) -> Result<(), String> {
|
pub fn run(argv: &[String]) -> Result<(), String> {
|
||||||
let mut args = parse_args(argv)?;
|
let mut args = parse_args(argv)?;
|
||||||
let mut prepared_verification = if args.verification_only {
|
let mut prepared_verification = if args.verification_only {
|
||||||
@@ -2176,7 +2284,11 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
args.enable_child_certificate_validation_cache = false;
|
args.enable_child_certificate_validation_cache = false;
|
||||||
args.publication_point_cache_observe_only = false;
|
args.publication_point_cache_observe_only = false;
|
||||||
args.enable_publication_point_validation_cache = false;
|
args.enable_publication_point_validation_cache = false;
|
||||||
args.enable_transport_request_prefetch = false;
|
args.crypto_signature_cache_observe_only = false;
|
||||||
|
args.enable_crypto_signature_cache = false;
|
||||||
|
args.enable_transport_request_prefetch =
|
||||||
|
transport_prefetch_for_verification_contract(&prepared.contract);
|
||||||
|
args.rsync_scope_policy = prepared.contract.rsync_scope_policy;
|
||||||
args.ccr_out_path = Some(prepared.artifacts.result_ccr.clone());
|
args.ccr_out_path = Some(prepared.artifacts.result_ccr.clone());
|
||||||
args.vrps_csv_out_path = Some(prepared.artifacts.vrps_csv.clone());
|
args.vrps_csv_out_path = Some(prepared.artifacts.vrps_csv.clone());
|
||||||
args.vaps_csv_out_path = Some(prepared.artifacts.vaps_csv.clone());
|
args.vaps_csv_out_path = Some(prepared.artifacts.vaps_csv.clone());
|
||||||
@@ -2196,7 +2308,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut policy = if let Some(prepared) = prepared_verification.as_ref() {
|
let mut policy = if let Some(prepared) = prepared_verification.as_ref() {
|
||||||
prepared.contract.policy.clone()
|
policy_for_verification_contract(&prepared.contract)
|
||||||
} else {
|
} else {
|
||||||
read_policy(args.policy_path.as_deref())?
|
read_policy(args.policy_path.as_deref())?
|
||||||
};
|
};
|
||||||
@@ -2210,9 +2322,6 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
if args.disable_rrdp {
|
if args.disable_rrdp {
|
||||||
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
|
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
|
|
||||||
policy.ca_failed_fetch_policy = crate::policy::CaFailedFetchPolicy::StopAllOutput;
|
|
||||||
}
|
}
|
||||||
let validation_time = args
|
let validation_time = args
|
||||||
.validation_time
|
.validation_time
|
||||||
@@ -2220,21 +2329,6 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
let validation_time =
|
let validation_time =
|
||||||
time::OffsetDateTime::from_unix_timestamp(validation_time.unix_timestamp())
|
time::OffsetDateTime::from_unix_timestamp(validation_time.unix_timestamp())
|
||||||
.map_err(|error| format!("normalize validation time failed: {error}"))?;
|
.map_err(|error| format!("normalize validation time failed: {error}"))?;
|
||||||
if let Some(path) = args.validation_contract_out_path.as_deref() {
|
|
||||||
let contract = crate::verification_only::ValidationContract::for_current_binary(
|
|
||||||
validation_time,
|
|
||||||
policy.clone(),
|
|
||||||
args.max_ca_depth,
|
|
||||||
args.max_instances,
|
|
||||||
crate::verification_only::ValidationCacheContract {
|
|
||||||
publication_point: args.enable_publication_point_validation_cache,
|
|
||||||
roa: args.enable_roa_validation_cache,
|
|
||||||
child_certificate: args.enable_child_certificate_validation_cache,
|
|
||||||
transport_prefetch: args.enable_transport_request_prefetch,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
crate::verification_only::write_validation_contract(path, &contract)?;
|
|
||||||
}
|
|
||||||
let http_root_certificates_pem = args
|
let http_root_certificates_pem = args
|
||||||
.http_root_cert_paths
|
.http_root_cert_paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2351,11 +2445,24 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
);
|
);
|
||||||
let validation_started = std::time::Instant::now();
|
let validation_started = std::time::Instant::now();
|
||||||
|
let crypto_sig_cache =
|
||||||
|
if args.crypto_signature_cache_observe_only || args.enable_crypto_signature_cache {
|
||||||
|
let cache_file = crate::crypto_sig_cache::default_cache_file_path(&args.db_path);
|
||||||
|
let cache = Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild(
|
||||||
|
cache_file,
|
||||||
|
args.enable_crypto_signature_cache,
|
||||||
|
));
|
||||||
|
crate::crypto_sig_cache::install_global(Arc::clone(&cache));
|
||||||
|
Some(cache)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let collect_current_repo_objects = false;
|
let collect_current_repo_objects = false;
|
||||||
let out = if args.verification_only {
|
let out = if args.verification_only {
|
||||||
let http = crate::verification_only::NetworkDisabledHttpFetcher;
|
let http = crate::verification_only::NetworkDisabledHttpFetcher;
|
||||||
let rsync = crate::fetch::current_repository::CurrentRepositoryViewRsyncFetcher::new(
|
let rsync = crate::fetch::current_repository::CurrentRepositoryViewRsyncFetcher::new(
|
||||||
Arc::clone(&store),
|
Arc::clone(&store),
|
||||||
|
args.rsync_scope_policy,
|
||||||
);
|
);
|
||||||
run_online_validation_with_fetchers(
|
run_online_validation_with_fetchers(
|
||||||
Arc::clone(&store),
|
Arc::clone(&store),
|
||||||
@@ -2616,48 +2723,129 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
ReportJsonFormat::Pretty
|
ReportJsonFormat::Pretty
|
||||||
};
|
};
|
||||||
let ccr_produced_at = time::OffsetDateTime::now_utc();
|
let ccr_produced_at = time::OffsetDateTime::now_utc();
|
||||||
let (report_result, ccr_result) = if args.skip_report_build {
|
let compare_view_trust_anchor = args
|
||||||
(
|
.compare_view_trust_anchor
|
||||||
Ok(ReportTaskOutput::skipped()),
|
.as_deref()
|
||||||
run_ccr_task(
|
.unwrap_or("unknown");
|
||||||
store.as_ref(),
|
let cir_tal_uris = if args.cir_enabled {
|
||||||
|
Some(effective_cir_tal_uris_for_discoveries(
|
||||||
|
&args,
|
||||||
&shared,
|
&shared,
|
||||||
args.ccr_out_path.as_deref(),
|
resolve_cir_export_tal_uris(&args)?,
|
||||||
ccr_produced_at,
|
)?)
|
||||||
),
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let cir_out_path = if args.cir_enabled {
|
||||||
|
Some(
|
||||||
|
args.cir_out_path
|
||||||
|
.as_deref()
|
||||||
|
.expect("validated by parse_args for cir"),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// Take the CIR input snapshot before the output stage so the CIR export can
|
||||||
|
// run inside the same scoped-thread group as report/ccr/compare_view; no
|
||||||
|
// other output task reads `shared.cir_input`.
|
||||||
|
let cir_input_owned = args
|
||||||
|
.cir_enabled
|
||||||
|
.then(|| std::mem::take(&mut shared.cir_input));
|
||||||
|
let (report_result, ccr_result, compare_view_result, cir_result) =
|
||||||
std::thread::scope(|scope| {
|
std::thread::scope(|scope| {
|
||||||
let report_handle = scope.spawn(|| {
|
// Reborrow `shared` as a plain reference so the scoped output tasks
|
||||||
|
// (incl. the `move` CIR task) capture the reference instead of
|
||||||
|
// moving fields out of the owned value.
|
||||||
|
let shared = &shared;
|
||||||
|
let report_handle = if args.skip_report_build {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(scope.spawn(|| {
|
||||||
run_report_task(
|
run_report_task(
|
||||||
&policy,
|
&policy,
|
||||||
validation_time,
|
validation_time,
|
||||||
&shared,
|
shared,
|
||||||
args.report_json_path.as_deref(),
|
args.report_json_path.as_deref(),
|
||||||
report_json_format,
|
report_json_format,
|
||||||
)
|
)
|
||||||
});
|
}))
|
||||||
|
};
|
||||||
let ccr_handle = scope.spawn(|| {
|
let ccr_handle = scope.spawn(|| {
|
||||||
run_ccr_task(
|
run_ccr_task(
|
||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
&shared,
|
shared,
|
||||||
args.ccr_out_path.as_deref(),
|
args.ccr_out_path.as_deref(),
|
||||||
ccr_produced_at,
|
ccr_produced_at,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let report_result = report_handle
|
let compare_view_handle = scope.spawn(|| {
|
||||||
|
run_compare_view_task(
|
||||||
|
shared,
|
||||||
|
args.vrps_csv_out_path.as_deref(),
|
||||||
|
args.vaps_csv_out_path.as_deref(),
|
||||||
|
compare_view_trust_anchor,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let cir_handle = match (cir_tal_uris.as_ref(), cir_out_path, cir_input_owned) {
|
||||||
|
(Some(cir_tal_uris), Some(cir_out_path), Some(cir_input)) => {
|
||||||
|
Some(scope.spawn(move || {
|
||||||
|
if cir_tal_uris.len() != shared.discoveries.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"CIR export TAL URI count ({}) does not match discovery count ({})",
|
||||||
|
cir_tal_uris.len(),
|
||||||
|
shared.discoveries.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let tal_bindings = shared
|
||||||
|
.discoveries
|
||||||
|
.iter()
|
||||||
|
.zip(cir_tal_uris.iter())
|
||||||
|
.map(|(discovery, tal_uri)| CirTrustAnchorBinding {
|
||||||
|
trust_anchor: &discovery.trust_anchor,
|
||||||
|
tal_uri: tal_uri.as_str(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
export_cir_from_input_snapshot_multi(
|
||||||
|
&tal_bindings,
|
||||||
|
validation_time,
|
||||||
|
cir_input,
|
||||||
|
cir_out_path,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let report_result = match report_handle {
|
||||||
|
Some(handle) => handle
|
||||||
.join()
|
.join()
|
||||||
.map_err(|_| "report task panicked".to_string())
|
.map_err(|_| "report task panicked".to_string())
|
||||||
.and_then(|result| result);
|
.and_then(|result| result),
|
||||||
|
None => Ok(ReportTaskOutput::skipped()),
|
||||||
|
};
|
||||||
let ccr_result = ccr_handle
|
let ccr_result = ccr_handle
|
||||||
.join()
|
.join()
|
||||||
.map_err(|_| "ccr task panicked".to_string())
|
.map_err(|_| "ccr task panicked".to_string())
|
||||||
.and_then(|result| result);
|
.and_then(|result| result);
|
||||||
(report_result, ccr_result)
|
let compare_view_result = compare_view_handle
|
||||||
})
|
.join()
|
||||||
};
|
.map_err(|_| "compare view task panicked".to_string())
|
||||||
|
.and_then(|result| result);
|
||||||
|
let cir_result = cir_handle.map(|handle| {
|
||||||
|
handle
|
||||||
|
.join()
|
||||||
|
.map_err(|_| "cir task panicked".to_string())
|
||||||
|
.and_then(|result| result)
|
||||||
|
});
|
||||||
|
(report_result, ccr_result, compare_view_result, cir_result)
|
||||||
|
});
|
||||||
let report_output = report_result?;
|
let report_output = report_result?;
|
||||||
let ccr_output = ccr_result?;
|
let ccr_output = ccr_result?;
|
||||||
|
let compare_view_output = compare_view_result?;
|
||||||
|
let cir_summary = match cir_result {
|
||||||
|
Some(result) => Some(result?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
record_memory_checkpoint(
|
record_memory_checkpoint(
|
||||||
&mut memory_checkpoints,
|
&mut memory_checkpoints,
|
||||||
"after_report_and_ccr",
|
"after_report_and_ccr",
|
||||||
@@ -2678,16 +2866,6 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
let ccr_build_ms = ccr_output.ccr_build_ms;
|
let ccr_build_ms = ccr_output.ccr_build_ms;
|
||||||
let ccr_build_breakdown = ccr_output.ccr_build_breakdown;
|
let ccr_build_breakdown = ccr_output.ccr_build_breakdown;
|
||||||
let ccr_write_ms = ccr_output.ccr_write_ms;
|
let ccr_write_ms = ccr_output.ccr_write_ms;
|
||||||
let compare_view_trust_anchor = args
|
|
||||||
.compare_view_trust_anchor
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let compare_view_output = run_compare_view_task(
|
|
||||||
&shared,
|
|
||||||
args.vrps_csv_out_path.as_deref(),
|
|
||||||
args.vaps_csv_out_path.as_deref(),
|
|
||||||
compare_view_trust_anchor,
|
|
||||||
)?;
|
|
||||||
let compare_view_build_ms = compare_view_output.build_ms;
|
let compare_view_build_ms = compare_view_output.build_ms;
|
||||||
let compare_view_write_ms = compare_view_output.write_ms;
|
let compare_view_write_ms = compare_view_output.write_ms;
|
||||||
record_memory_checkpoint(
|
record_memory_checkpoint(
|
||||||
@@ -2700,45 +2878,13 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
let mut cir_build_cir_ms = None;
|
let mut cir_build_cir_ms = None;
|
||||||
let mut cir_write_cir_ms = None;
|
let mut cir_write_cir_ms = None;
|
||||||
let mut cir_total_ms = None;
|
let mut cir_total_ms = None;
|
||||||
if args.cir_enabled {
|
if let Some(summary) = cir_summary {
|
||||||
let cir_tal_uris = effective_cir_tal_uris_for_discoveries(
|
|
||||||
&args,
|
|
||||||
&shared,
|
|
||||||
resolve_cir_export_tal_uris(&args)?,
|
|
||||||
)?;
|
|
||||||
if cir_tal_uris.len() != shared.discoveries.len() {
|
|
||||||
return Err(format!(
|
|
||||||
"CIR export TAL URI count ({}) does not match discovery count ({})",
|
|
||||||
cir_tal_uris.len(),
|
|
||||||
shared.discoveries.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let cir_out_path = args
|
|
||||||
.cir_out_path
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated by parse_args for cir");
|
|
||||||
let tal_bindings = shared
|
|
||||||
.discoveries
|
|
||||||
.iter()
|
|
||||||
.zip(cir_tal_uris.iter())
|
|
||||||
.map(|(discovery, tal_uri)| CirTrustAnchorBinding {
|
|
||||||
trust_anchor: &discovery.trust_anchor,
|
|
||||||
tal_uri: tal_uri.as_str(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let summary = export_cir_from_input_snapshot_multi(
|
|
||||||
&tal_bindings,
|
|
||||||
validation_time,
|
|
||||||
std::mem::take(&mut shared.cir_input),
|
|
||||||
cir_out_path,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
cir_build_cir_ms = Some(summary.timing.build_cir_ms);
|
cir_build_cir_ms = Some(summary.timing.build_cir_ms);
|
||||||
cir_write_cir_ms = Some(summary.timing.write_cir_ms);
|
cir_write_cir_ms = Some(summary.timing.write_cir_ms);
|
||||||
cir_total_ms = Some(summary.timing.total_ms);
|
cir_total_ms = Some(summary.timing.total_ms);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"wrote CIR: {} (objects={}, trust_anchors={}, build_cir_ms={}, write_cir_ms={}, total_ms={})",
|
"wrote CIR: {} (objects={}, trust_anchors={}, build_cir_ms={}, write_cir_ms={}, total_ms={})",
|
||||||
cir_out_path.display(),
|
cir_out_path.expect("cir path present when cir enabled").display(),
|
||||||
summary.object_count,
|
summary.object_count,
|
||||||
summary.trust_anchor_count,
|
summary.trust_anchor_count,
|
||||||
summary.timing.build_cir_ms,
|
summary.timing.build_cir_ms,
|
||||||
@@ -2778,6 +2924,37 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let publication_point_cache_index_load = store.publication_point_cache_mmap_index_load_stats();
|
let publication_point_cache_index_load = store.publication_point_cache_mmap_index_load_stats();
|
||||||
|
let crypto_signature_cache_observe = crypto_sig_cache.as_ref().map(|cache| {
|
||||||
|
if let Err(e) = cache.persist() {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"crypto_signature_cache_persist",
|
||||||
|
serde_json::json!({
|
||||||
|
"state": "failed",
|
||||||
|
"error": e,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
crate::crypto_sig_cache::clear_global();
|
||||||
|
cache.summary()
|
||||||
|
});
|
||||||
|
if let (Some((_, t)), Some(summary)) =
|
||||||
|
(timing.as_ref(), crypto_signature_cache_observe.as_ref())
|
||||||
|
{
|
||||||
|
let (mut calls, mut would_hit, mut new_keys, mut executed, mut skipped) =
|
||||||
|
(0u64, 0u64, 0u64, 0u64, 0u64);
|
||||||
|
for stats in summary.per_point.values() {
|
||||||
|
calls += stats.calls;
|
||||||
|
would_hit += stats.would_hit;
|
||||||
|
new_keys += stats.new_keys;
|
||||||
|
executed += stats.verify_executed;
|
||||||
|
skipped += stats.verify_skipped;
|
||||||
|
}
|
||||||
|
t.record_count("crypto_signature_cache_observe_calls", calls);
|
||||||
|
t.record_count("crypto_signature_cache_observe_would_hit", would_hit);
|
||||||
|
t.record_count("crypto_signature_cache_observe_new_keys", new_keys);
|
||||||
|
t.record_count("crypto_signature_cache_verify_executed", executed);
|
||||||
|
t.record_count("crypto_signature_cache_verify_skipped", skipped);
|
||||||
|
}
|
||||||
let timing_report_snapshot = timing
|
let timing_report_snapshot = timing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(_, handle)| handle.report_snapshot(50));
|
.map(|(_, handle)| handle.report_snapshot(50));
|
||||||
@@ -2787,6 +2964,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
enable_child_certificate_validation_cache: args.enable_child_certificate_validation_cache,
|
enable_child_certificate_validation_cache: args.enable_child_certificate_validation_cache,
|
||||||
publication_point_cache_observe_only: args.publication_point_cache_observe_only,
|
publication_point_cache_observe_only: args.publication_point_cache_observe_only,
|
||||||
enable_publication_point_validation_cache: args.enable_publication_point_validation_cache,
|
enable_publication_point_validation_cache: args.enable_publication_point_validation_cache,
|
||||||
|
crypto_signature_cache_observe,
|
||||||
enable_transport_request_prefetch: args.enable_transport_request_prefetch,
|
enable_transport_request_prefetch: args.enable_transport_request_prefetch,
|
||||||
report_build_ms,
|
report_build_ms,
|
||||||
report_write_ms,
|
report_write_ms,
|
||||||
@@ -2851,6 +3029,36 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
.or(args.vrps_csv_out_path.as_deref());
|
.or(args.vrps_csv_out_path.as_deref());
|
||||||
write_stage_timing(stage_timing_anchor_path, &stage_timing)?;
|
write_stage_timing(stage_timing_anchor_path, &stage_timing)?;
|
||||||
|
|
||||||
|
// A normal run's contract is intentionally finalized only after its
|
||||||
|
// validation outputs have all been produced successfully. This prevents a
|
||||||
|
// failed/incomplete run from leaving a contract that appears eligible for
|
||||||
|
// verification-only, and captures the fallback set actually exercised by
|
||||||
|
// the completed traversal rather than a prediction made before it starts.
|
||||||
|
if !args.verification_only {
|
||||||
|
if let Some(path) = args.validation_contract_out_path.as_deref() {
|
||||||
|
let mut contract = crate::verification_only::ValidationContract::for_current_binary(
|
||||||
|
validation_time,
|
||||||
|
policy.clone(),
|
||||||
|
args.max_ca_depth,
|
||||||
|
args.max_instances,
|
||||||
|
crate::verification_only::ValidationCacheContract {
|
||||||
|
publication_point: args.enable_publication_point_validation_cache,
|
||||||
|
roa: args.enable_roa_validation_cache,
|
||||||
|
child_certificate: args.enable_child_certificate_validation_cache,
|
||||||
|
transport_prefetch: args.enable_transport_request_prefetch,
|
||||||
|
crypto_signature: args.enable_crypto_signature_cache,
|
||||||
|
},
|
||||||
|
args.rsync_scope_policy,
|
||||||
|
)?;
|
||||||
|
contract.fallback_replay_selections =
|
||||||
|
crate::verification_only::collect_fallback_replay_selections(
|
||||||
|
store.as_ref(),
|
||||||
|
shared.publication_points.as_ref(),
|
||||||
|
)?;
|
||||||
|
crate::verification_only::write_validation_contract(path, &contract)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some((out_dir, t)) = timing.as_ref() {
|
if let Some((out_dir, t)) = timing.as_ref() {
|
||||||
t.record_count("vrps", shared.vrps.len() as u64);
|
t.record_count("vrps", shared.vrps.len() as u64);
|
||||||
t.record_count("aspas", shared.aspas.len() as u64);
|
t.record_count("aspas", shared.aspas.len() as u64);
|
||||||
@@ -2913,7 +3121,26 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
Some(&prepared.artifacts.compare_dir.join("ccr-state-digest.md")),
|
Some(&prepared.artifacts.compare_dir.join("ccr-state-digest.md")),
|
||||||
&comparison,
|
&comparison,
|
||||||
)?;
|
)?;
|
||||||
let scratch_retained = !comparison.state_digest_match || args.keep_verification_scratch;
|
let actual_fallback_replay_selections =
|
||||||
|
crate::verification_only::collect_fallback_replay_selections(
|
||||||
|
store.as_ref(),
|
||||||
|
shared.publication_points.as_ref(),
|
||||||
|
)?;
|
||||||
|
let fallback_replay_selection_comparison =
|
||||||
|
crate::verification_only::compare_fallback_replay_selections(
|
||||||
|
&prepared.contract.fallback_replay_selections,
|
||||||
|
&actual_fallback_replay_selections,
|
||||||
|
);
|
||||||
|
crate::verification_only::write_fallback_replay_selection_comparison(
|
||||||
|
&prepared
|
||||||
|
.artifacts
|
||||||
|
.compare_dir
|
||||||
|
.join("fallback-replay-selection.json"),
|
||||||
|
&fallback_replay_selection_comparison,
|
||||||
|
)?;
|
||||||
|
let verification_match =
|
||||||
|
comparison.state_digest_match && fallback_replay_selection_comparison.matches;
|
||||||
|
let scratch_retained = !verification_match || args.keep_verification_scratch;
|
||||||
let source_run_id = prepared
|
let source_run_id = prepared
|
||||||
.artifacts
|
.artifacts
|
||||||
.source_run_dir
|
.source_run_dir
|
||||||
@@ -2925,7 +3152,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
&prepared.artifacts.verification_meta,
|
&prepared.artifacts.verification_meta,
|
||||||
&crate::verification_only::VerificationRunMeta {
|
&crate::verification_only::VerificationRunMeta {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
status: if comparison.state_digest_match {
|
status: if verification_match {
|
||||||
"success".to_string()
|
"success".to_string()
|
||||||
} else {
|
} else {
|
||||||
"mismatch".to_string()
|
"mismatch".to_string()
|
||||||
@@ -2936,15 +3163,28 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
|||||||
repository_objects_checked: prepared.blob_summary.current_objects,
|
repository_objects_checked: prepared.blob_summary.current_objects,
|
||||||
repository_bytes_checked: prepared.blob_summary.bytes_verified,
|
repository_bytes_checked: prepared.blob_summary.bytes_verified,
|
||||||
state_digest_match: comparison.state_digest_match,
|
state_digest_match: comparison.state_digest_match,
|
||||||
|
fallback_replay_selection_match: fallback_replay_selection_comparison.matches,
|
||||||
|
fallback_replay_materialization: prepared.fallback_replay_materialization.clone(),
|
||||||
scratch_retained,
|
scratch_retained,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
if !verification_match {
|
||||||
|
let mut mismatch_parts = Vec::new();
|
||||||
if !comparison.state_digest_match {
|
if !comparison.state_digest_match {
|
||||||
return Err(format!(
|
mismatch_parts.push(format!(
|
||||||
"verification-only CCR state digest mismatch: {}",
|
"CCR state digest mismatch: {}",
|
||||||
comparison.mismatched_states.join(",")
|
comparison.mismatched_states.join(",")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !fallback_replay_selection_comparison.matches {
|
||||||
|
mismatch_parts.push(format!(
|
||||||
|
"fallback replay selection mismatch: missing={}, unexpected={}",
|
||||||
|
fallback_replay_selection_comparison.missing.len(),
|
||||||
|
fallback_replay_selection_comparison.unexpected.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!("verification-only {}", mismatch_parts.join("; ")));
|
||||||
|
}
|
||||||
if !args.keep_verification_scratch {
|
if !args.keep_verification_scratch {
|
||||||
drop(shared);
|
drop(shared);
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|||||||
119
src/cli/tests.rs
119
src/cli/tests.rs
@@ -108,6 +108,73 @@ fn parse_accepts_normal_validation_contract_output() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verification_policy_preserves_contract_identity_and_selects_only_bound_fallbacks() {
|
||||||
|
let manifest_rsync_uri = "rsync://example.test/repo/current.mft".to_string();
|
||||||
|
let contract = crate::verification_only::ValidationContract {
|
||||||
|
schema_version: crate::verification_only::VALIDATION_CONTRACT_SCHEMA_VERSION,
|
||||||
|
validation_time: "2026-07-16T00:00:00Z".to_string(),
|
||||||
|
binary_sha256: "11".repeat(32),
|
||||||
|
policy: Policy {
|
||||||
|
sync_preference: crate::policy::SyncPreference::RrdpThenRsync,
|
||||||
|
..Policy::default()
|
||||||
|
},
|
||||||
|
max_ca_depth: 32,
|
||||||
|
max_instances: None,
|
||||||
|
cache: crate::verification_only::ValidationCacheContract {
|
||||||
|
publication_point: true,
|
||||||
|
roa: false,
|
||||||
|
child_certificate: false,
|
||||||
|
transport_prefetch: false,
|
||||||
|
crypto_signature: false,
|
||||||
|
},
|
||||||
|
rsync_scope_policy: crate::fetch::rsync_system::RsyncScopePolicy::ModuleRoot,
|
||||||
|
fallback_replay_selections: vec![crate::verification_only::FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: manifest_rsync_uri.clone(),
|
||||||
|
vcir_sha256: "22".repeat(32),
|
||||||
|
reuse_identity_sha256: "33".repeat(32),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let policy = policy_for_verification_contract(&contract);
|
||||||
|
assert_eq!(
|
||||||
|
policy.sync_preference,
|
||||||
|
crate::policy::SyncPreference::RrdpThenRsync,
|
||||||
|
"sync preference is part of the stored VCIR reuse-identity fingerprint"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy.ca_failed_fetch_policy,
|
||||||
|
crate::policy::CaFailedFetchPolicy::ReuseCurrentInstanceVcir
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.verification_forced_vcir_reuse_manifest_uris
|
||||||
|
.contains(&manifest_rsync_uri)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&policy)
|
||||||
|
.expect("serialize policy")
|
||||||
|
.contains("verification_forced_vcir_reuse_manifest_uris"),
|
||||||
|
"the runtime-only override must not perturb the contracted policy fingerprint"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!transport_prefetch_for_verification_contract(&contract),
|
||||||
|
"verification-only must preserve a disabled source transport prefetch setting"
|
||||||
|
);
|
||||||
|
let mut contract_with_prefetch = contract;
|
||||||
|
contract_with_prefetch.cache.transport_prefetch = true;
|
||||||
|
assert_eq!(
|
||||||
|
contract_with_prefetch.rsync_scope_policy,
|
||||||
|
crate::fetch::rsync_system::RsyncScopePolicy::ModuleRoot,
|
||||||
|
"transport scope is part of the source contract"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
transport_prefetch_for_verification_contract(&contract_with_prefetch),
|
||||||
|
"verification-only must preserve a source transport prefetch setting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_rejects_both_tal_url_and_tal_path() {
|
fn parse_rejects_both_tal_url_and_tal_path() {
|
||||||
let argv = vec![
|
let argv = vec![
|
||||||
@@ -289,6 +356,56 @@ fn parse_accepts_publication_point_cache_flags() {
|
|||||||
assert!(args.enable_publication_point_validation_cache);
|
assert!(args.enable_publication_point_validation_cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_disables_crypto_signature_cache_observe_only_by_default() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--tal-path".to_string(),
|
||||||
|
"x.tal".to_string(),
|
||||||
|
"--ta-path".to_string(),
|
||||||
|
"x.cer".to_string(),
|
||||||
|
];
|
||||||
|
let args = parse_args(&argv).expect("parse args");
|
||||||
|
assert!(!args.crypto_signature_cache_observe_only);
|
||||||
|
assert!(!args.enable_crypto_signature_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_crypto_signature_cache_observe_only() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--tal-path".to_string(),
|
||||||
|
"x.tal".to_string(),
|
||||||
|
"--ta-path".to_string(),
|
||||||
|
"x.cer".to_string(),
|
||||||
|
"--crypto-signature-cache-observe-only".to_string(),
|
||||||
|
];
|
||||||
|
let args = parse_args(&argv).expect("parse args");
|
||||||
|
assert!(args.crypto_signature_cache_observe_only);
|
||||||
|
assert!(!args.enable_crypto_signature_cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_enable_crypto_signature_cache() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"db".to_string(),
|
||||||
|
"--tal-path".to_string(),
|
||||||
|
"x.tal".to_string(),
|
||||||
|
"--ta-path".to_string(),
|
||||||
|
"x.cer".to_string(),
|
||||||
|
"--enable-crypto-signature-cache".to_string(),
|
||||||
|
];
|
||||||
|
let args = parse_args(&argv).expect("parse args");
|
||||||
|
assert!(args.enable_crypto_signature_cache);
|
||||||
|
assert!(!args.crypto_signature_cache_observe_only);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_accepts_enable_transport_request_prefetch() {
|
fn parse_accepts_enable_transport_request_prefetch() {
|
||||||
let argv = vec![
|
let argv = vec![
|
||||||
@@ -1807,6 +1924,7 @@ fn run_report_task_and_stage_timing_work() {
|
|||||||
enable_child_certificate_validation_cache: false,
|
enable_child_certificate_validation_cache: false,
|
||||||
publication_point_cache_observe_only: false,
|
publication_point_cache_observe_only: false,
|
||||||
enable_publication_point_validation_cache: false,
|
enable_publication_point_validation_cache: false,
|
||||||
|
crypto_signature_cache_observe: None,
|
||||||
enable_transport_request_prefetch: false,
|
enable_transport_request_prefetch: false,
|
||||||
report_build_ms: report_output.report_build_ms,
|
report_build_ms: report_output.report_build_ms,
|
||||||
report_write_ms: report_output.report_write_ms,
|
report_write_ms: report_output.report_write_ms,
|
||||||
@@ -1913,6 +2031,7 @@ fn stage_timing_serializes_memory_telemetry() {
|
|||||||
enable_child_certificate_validation_cache: true,
|
enable_child_certificate_validation_cache: true,
|
||||||
publication_point_cache_observe_only: false,
|
publication_point_cache_observe_only: false,
|
||||||
enable_publication_point_validation_cache: false,
|
enable_publication_point_validation_cache: false,
|
||||||
|
crypto_signature_cache_observe: None,
|
||||||
enable_transport_request_prefetch: true,
|
enable_transport_request_prefetch: true,
|
||||||
report_build_ms: 2,
|
report_build_ms: 2,
|
||||||
report_write_ms: None,
|
report_write_ms: None,
|
||||||
|
|||||||
876
src/crypto_sig_cache.rs
Normal file
876
src/crypto_sig_cache.rs
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
//! Crypto signature verification cache (feature #126).
|
||||||
|
//!
|
||||||
|
//! Caches the context-free cryptographic fact "this signature over these exact bytes verifies
|
||||||
|
//! under this public key" so that later runs can skip the RSA verification entirely. Only
|
||||||
|
//! positive conclusions are stored (no negative caching). The cache never replaces any other
|
||||||
|
//! validation semantics: revocation (CRL), time-window, and policy checks all run unchanged on
|
||||||
|
//! the hit path — the hook wraps exactly the crypto verify call and nothing else.
|
||||||
|
//!
|
||||||
|
//! Modes (CLI):
|
||||||
|
//!
|
||||||
|
//! - `--crypto-signature-cache-observe-only` (M1): compute keys, maintain the persistent set,
|
||||||
|
//! record hit/miss/timing statistics, always run the real verification.
|
||||||
|
//! - `--enable-crypto-signature-cache` (M2): on a cache hit, skip the real verification and
|
||||||
|
//! return success; on a miss, run the real verification and store the positive conclusion.
|
||||||
|
//! Statistics are recorded the same way; `verify_skipped`/`verify_executed` report what
|
||||||
|
//! actually happened. Both flags may be combined (reuse + statistics).
|
||||||
|
//!
|
||||||
|
//! The five choke points (see the development plan for the full caller inventory):
|
||||||
|
//!
|
||||||
|
//! - `CmsSignedObject`: `data_model::signed_object` CMS verify (ROA / ASPA / manifest on the
|
||||||
|
//! main validation path; GBR objects are not signature-verified by the tree runner).
|
||||||
|
//! - `EeCertFast`: `validation::cert_path::verify_ee_cert_signature_fast` (ring, TBS + signature
|
||||||
|
//! bytes from the signed-object embedded EE certificate; ROA / ASPA / manifest EE paths).
|
||||||
|
//! - `EeCertX509`: `validation::cert_path::verify_ee_cert_signature` (x509-parser; router
|
||||||
|
//! certificates and the `validate_ee_cert_path` family).
|
||||||
|
//! - `ChildCaCert`: `validation::ca_path::verify_child_signature` (x509-parser; subordinate CA
|
||||||
|
//! certificates).
|
||||||
|
//! - `Crl`: `data_model::crl::verify_signature_with_issuer_spki` (x509-parser; issuer CRLs).
|
||||||
|
//!
|
||||||
|
//! Trust-anchor self-signature verification is deliberately excluded (a handful of calls per
|
||||||
|
//! run).
|
||||||
|
//!
|
||||||
|
//! Cache key (M1 decision: verify-input bytes, not full object bytes — the key material is in
|
||||||
|
//! hand inside each choke point, while the repo-bytes content hash is only available at the
|
||||||
|
//! upper processing layer):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! key = SHA-256( "rpki-crypto-sig-cache-key-v1" | point-tag | "sha256WithRSAEncryption"
|
||||||
|
//! | SHA-256(verify input bytes) | SHA-256(signature bytes)
|
||||||
|
//! | SHA-256(verifier SPKI DER) )
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The signature bytes are part of the key so that a different signature over the same input
|
||||||
|
//! under the same key can never inherit a positive verification fact.
|
||||||
|
//!
|
||||||
|
//! Storage:
|
||||||
|
//!
|
||||||
|
//! - Single small file next to the work DB (`<work-db-name>.crypto-sig-cache`), never inside
|
||||||
|
//! the work DB, not touched by periodic snapshot reset (#091/#092): the reset paths only
|
||||||
|
//! remove/replace the work DB directory and the PP/child-cert cache artifacts, not siblings
|
||||||
|
//! of their own choosing.
|
||||||
|
//! - Binary format: 8-byte magic `RPKICSC2` | u64 record-count | records of
|
||||||
|
//! key[32] | verified_at_unix_secs(i64) | crypto_impl_version(u32) | reserved(u32).
|
||||||
|
//! A missing, corrupt, or magic-mismatched file (including the M1 `RPKICSC1` seen-set
|
||||||
|
//! format) is silently rebuilt empty. Writes are atomic (write-then-rename).
|
||||||
|
//! - The cache is persisted once at the end of the validation stage (same point as the M1
|
||||||
|
//! seen-set). If a run fails mid-validation, entries added during that run are lost;
|
||||||
|
//! previously persisted entries are unaffected.
|
||||||
|
//! - Size cap: approximate上限 `capacity` entries (default 2,000,000; measured M1 APNIC
|
||||||
|
//! ~148k keys/run, all5 estimated ~750k). When an insert would exceed the cap, the whole
|
||||||
|
//! cache is cleared and rebuilt from scratch (full-rebuild eviction — the simplest policy;
|
||||||
|
//! concurrent inserts may briefly overshoot the cap by a few entries). Each eviction is
|
||||||
|
//! counted in `evictions_total`.
|
||||||
|
//! - Concurrency: entries live in 64 shards keyed by the first key byte, so parallel
|
||||||
|
//! validation workers rarely contend; statistics use relaxed atomics.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
|
const CACHE_FILE_MAGIC: &[u8; 8] = b"RPKICSC2";
|
||||||
|
const KEY_DOMAIN: &[u8] = b"rpki-crypto-sig-cache-key-v1";
|
||||||
|
const ALG_ID: &[u8] = b"sha256WithRSAEncryption";
|
||||||
|
const SHARD_COUNT: usize = 64;
|
||||||
|
const RECORD_BYTES: usize = 32 + 8 + 4 + 4;
|
||||||
|
|
||||||
|
/// The schema version reported in summaries. The on-disk magic already carries the format
|
||||||
|
/// version; this constant is the semantic version of the key/value definitions.
|
||||||
|
pub const CACHE_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// Default maximum number of cached positive conclusions (see module docs).
|
||||||
|
pub const DEFAULT_CAPACITY: usize = 2_000_000;
|
||||||
|
|
||||||
|
/// Crypto implementation identifier stored in every value. Bump when the verification
|
||||||
|
/// backend changes so old entries can be invalidated wholesale.
|
||||||
|
/// `1` = ring 0.17 `RSA_PKCS1_2048_8192_SHA256` (direct ring calls and x509-parser's
|
||||||
|
/// `verify` feature, which is backed by ring in this crate).
|
||||||
|
pub const CRYPTO_IMPL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CryptoSigVerifyPoint {
|
||||||
|
CmsSignedObject,
|
||||||
|
EeCertFast,
|
||||||
|
EeCertX509,
|
||||||
|
ChildCaCert,
|
||||||
|
Crl,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoSigVerifyPoint {
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::CmsSignedObject,
|
||||||
|
Self::EeCertFast,
|
||||||
|
Self::EeCertX509,
|
||||||
|
Self::ChildCaCert,
|
||||||
|
Self::Crl,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn tag(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::CmsSignedObject => 1,
|
||||||
|
Self::EeCertFast => 2,
|
||||||
|
Self::EeCertX509 => 3,
|
||||||
|
Self::ChildCaCert => 4,
|
||||||
|
Self::Crl => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CmsSignedObject => "cms_signed_object",
|
||||||
|
Self::EeCertFast => "ee_cert_fast",
|
||||||
|
Self::EeCertX509 => "ee_cert_x509",
|
||||||
|
Self::ChildCaCert => "child_ca_cert",
|
||||||
|
Self::Crl => "crl",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_index(index: usize) -> Self {
|
||||||
|
Self::ALL[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the cache key for one verification fact.
|
||||||
|
///
|
||||||
|
/// `input_bytes` is the exact byte string fed to the verifier (CMS signedAttrs DER for
|
||||||
|
/// signature, TBSCertificate DER, TBSCertList DER), `signature_bytes` the signature value,
|
||||||
|
/// and `verifier_spki_der` the DER of the verifying SubjectPublicKeyInfo.
|
||||||
|
pub fn compute_cache_key(
|
||||||
|
point: CryptoSigVerifyPoint,
|
||||||
|
input_bytes: &[u8],
|
||||||
|
signature_bytes: &[u8],
|
||||||
|
verifier_spki_der: &[u8],
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let mut h = sha2::Sha256::new();
|
||||||
|
h.update(KEY_DOMAIN);
|
||||||
|
h.update([point.tag()]);
|
||||||
|
h.update(ALG_ID);
|
||||||
|
for part in [input_bytes, signature_bytes, verifier_spki_der] {
|
||||||
|
let part_hash = sha2::Sha256::digest(part);
|
||||||
|
h.update(part_hash);
|
||||||
|
}
|
||||||
|
h.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CacheValue {
|
||||||
|
pub verified_at_unix_secs: i64,
|
||||||
|
pub crypto_impl_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct CryptoSigVerifyPointStats {
|
||||||
|
pub calls: u64,
|
||||||
|
/// Key already present in the cache (positive conclusion available).
|
||||||
|
pub would_hit: u64,
|
||||||
|
/// Key not present; after a successful verification it is inserted.
|
||||||
|
pub new_keys: u64,
|
||||||
|
/// Real cryptographic verification executed (always equals `calls` in observe-only
|
||||||
|
/// mode; equals `new_keys` plus hit-but-observe calls otherwise).
|
||||||
|
pub verify_executed: u64,
|
||||||
|
/// Real verification skipped because of a cache hit (only > 0 when reuse is enabled).
|
||||||
|
pub verify_skipped: u64,
|
||||||
|
pub verify_nanos_on_would_hit: u64,
|
||||||
|
pub verify_nanos_on_new_key: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct CryptoSigCacheSummary {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub cache_file: String,
|
||||||
|
pub reuse_enabled: bool,
|
||||||
|
pub capacity: u64,
|
||||||
|
pub entries_loaded: u64,
|
||||||
|
pub entries_total: u64,
|
||||||
|
pub evictions_total: u64,
|
||||||
|
pub per_point: BTreeMap<String, CryptoSigVerifyPointStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PointCounters {
|
||||||
|
calls: AtomicU64,
|
||||||
|
would_hit: AtomicU64,
|
||||||
|
new_keys: AtomicU64,
|
||||||
|
verify_executed: AtomicU64,
|
||||||
|
verify_skipped: AtomicU64,
|
||||||
|
verify_nanos_on_would_hit: AtomicU64,
|
||||||
|
verify_nanos_on_new_key: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shard_index(key: &[u8; 32]) -> usize {
|
||||||
|
(key[0] as usize) % SHARD_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent positive-conclusion signature cache plus per-run statistics.
|
||||||
|
///
|
||||||
|
/// See the module docs for storage format, eviction, and failure semantics.
|
||||||
|
pub struct CryptoSigCache {
|
||||||
|
cache_file: PathBuf,
|
||||||
|
reuse_enabled: bool,
|
||||||
|
capacity: usize,
|
||||||
|
entries_loaded: u64,
|
||||||
|
shards: Vec<Mutex<HashMap<[u8; 32], CacheValue>>>,
|
||||||
|
len: AtomicUsize,
|
||||||
|
evictions: AtomicU64,
|
||||||
|
eviction_lock: Mutex<()>,
|
||||||
|
points: [PointCounters; 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CryptoSigCache {
|
||||||
|
/// Load the cache from `cache_file`; start empty when the file is missing, corrupt, or
|
||||||
|
/// has an unknown schema header. `reuse_enabled` selects the M2 hit-skips-verify
|
||||||
|
/// behavior; when false the cache is observe-only (M1 semantics).
|
||||||
|
pub fn load_or_rebuild(cache_file: PathBuf, reuse_enabled: bool) -> Self {
|
||||||
|
Self::load_or_rebuild_with_capacity(cache_file, reuse_enabled, DEFAULT_CAPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_rebuild_with_capacity(
|
||||||
|
cache_file: PathBuf,
|
||||||
|
reuse_enabled: bool,
|
||||||
|
capacity: usize,
|
||||||
|
) -> Self {
|
||||||
|
let entries = load_cache_file(&cache_file).unwrap_or_default();
|
||||||
|
let mut shards = Vec::with_capacity(SHARD_COUNT);
|
||||||
|
for _ in 0..SHARD_COUNT {
|
||||||
|
shards.push(Mutex::new(HashMap::new()));
|
||||||
|
}
|
||||||
|
let mut len = 0usize;
|
||||||
|
for (key, value) in entries {
|
||||||
|
// Respect the cap even when loading a foreign/oversized file: keep the first
|
||||||
|
// `capacity` records in file order.
|
||||||
|
if len >= capacity {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
shards[shard_index(&key)]
|
||||||
|
.lock()
|
||||||
|
.expect("crypto sig shard lock")
|
||||||
|
.insert(key, value);
|
||||||
|
len += 1;
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
cache_file,
|
||||||
|
reuse_enabled,
|
||||||
|
capacity,
|
||||||
|
entries_loaded: len as u64,
|
||||||
|
shards,
|
||||||
|
len: AtomicUsize::new(len),
|
||||||
|
evictions: AtomicU64::new(0),
|
||||||
|
eviction_lock: Mutex::new(()),
|
||||||
|
points: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reuse_enabled(&self) -> bool {
|
||||||
|
self.reuse_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, key: &[u8; 32]) -> bool {
|
||||||
|
self.shards[shard_index(key)]
|
||||||
|
.lock()
|
||||||
|
.expect("crypto sig shard lock")
|
||||||
|
.contains_key(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn contains(&self, key: &[u8; 32]) -> bool {
|
||||||
|
self.lookup(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a positive conclusion (used by the verify path and by tests).
|
||||||
|
pub fn insert_positive(&self, key: [u8; 32]) {
|
||||||
|
let value = CacheValue {
|
||||||
|
verified_at_unix_secs: time::OffsetDateTime::now_utc().unix_timestamp(),
|
||||||
|
crypto_impl_version: CRYPTO_IMPL_VERSION,
|
||||||
|
};
|
||||||
|
if self.len.load(Ordering::Relaxed) >= self.capacity {
|
||||||
|
self.evict_all();
|
||||||
|
}
|
||||||
|
let mut shard = self.shards[shard_index(&key)]
|
||||||
|
.lock()
|
||||||
|
.expect("crypto sig shard lock");
|
||||||
|
if shard.insert(key, value).is_none() {
|
||||||
|
self.len.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-rebuild eviction: clear every shard. See the module docs for the semantics.
|
||||||
|
fn evict_all(&self) {
|
||||||
|
let _guard = self.eviction_lock.lock().expect("crypto sig eviction lock");
|
||||||
|
if self.len.load(Ordering::Relaxed) < self.capacity {
|
||||||
|
// Another thread already evicted.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for shard in &self.shards {
|
||||||
|
shard.lock().expect("crypto sig shard lock").clear();
|
||||||
|
}
|
||||||
|
self.len.store(0, Ordering::Relaxed);
|
||||||
|
self.evictions.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache-aware verification: observe-only or reuse depending on `reuse_enabled`.
|
||||||
|
/// The `verify` closure must perform exactly the real cryptographic verification.
|
||||||
|
pub fn verify<E>(
|
||||||
|
&self,
|
||||||
|
point: CryptoSigVerifyPoint,
|
||||||
|
input_bytes: &[u8],
|
||||||
|
signature_bytes: &[u8],
|
||||||
|
verifier_spki_der: &[u8],
|
||||||
|
verify: impl FnOnce() -> Result<(), E>,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
let key = compute_cache_key(point, input_bytes, signature_bytes, verifier_spki_der);
|
||||||
|
let hit = self.lookup(&key);
|
||||||
|
let counters = &self.points[(point.tag() - 1) as usize];
|
||||||
|
counters.calls.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if hit {
|
||||||
|
counters.would_hit.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if self.reuse_enabled {
|
||||||
|
counters.verify_skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
counters.new_keys.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
counters.verify_executed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let result = verify();
|
||||||
|
let nanos = started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64;
|
||||||
|
if hit {
|
||||||
|
counters
|
||||||
|
.verify_nanos_on_would_hit
|
||||||
|
.fetch_add(nanos, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
counters
|
||||||
|
.verify_nanos_on_new_key
|
||||||
|
.fetch_add(nanos, Ordering::Relaxed);
|
||||||
|
if result.is_ok() {
|
||||||
|
self.insert_positive(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the cache atomically (write-then-rename).
|
||||||
|
pub fn persist(&self) -> Result<(), String> {
|
||||||
|
persist_cache_file(&self.cache_file, &self.shards)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(&self) -> CryptoSigCacheSummary {
|
||||||
|
let mut per_point = BTreeMap::new();
|
||||||
|
for (index, counters) in self.points.iter().enumerate() {
|
||||||
|
let stats = CryptoSigVerifyPointStats {
|
||||||
|
calls: counters.calls.load(Ordering::Relaxed),
|
||||||
|
would_hit: counters.would_hit.load(Ordering::Relaxed),
|
||||||
|
new_keys: counters.new_keys.load(Ordering::Relaxed),
|
||||||
|
verify_executed: counters.verify_executed.load(Ordering::Relaxed),
|
||||||
|
verify_skipped: counters.verify_skipped.load(Ordering::Relaxed),
|
||||||
|
verify_nanos_on_would_hit: counters
|
||||||
|
.verify_nanos_on_would_hit
|
||||||
|
.load(Ordering::Relaxed),
|
||||||
|
verify_nanos_on_new_key: counters
|
||||||
|
.verify_nanos_on_new_key
|
||||||
|
.load(Ordering::Relaxed),
|
||||||
|
};
|
||||||
|
if stats.calls > 0 {
|
||||||
|
per_point.insert(
|
||||||
|
CryptoSigVerifyPoint::from_index(index).name().to_string(),
|
||||||
|
stats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CryptoSigCacheSummary {
|
||||||
|
schema_version: CACHE_SCHEMA_VERSION,
|
||||||
|
cache_file: self.cache_file.to_string_lossy().into_owned(),
|
||||||
|
reuse_enabled: self.reuse_enabled,
|
||||||
|
capacity: self.capacity as u64,
|
||||||
|
entries_loaded: self.entries_loaded,
|
||||||
|
entries_total: self.len.load(Ordering::Relaxed) as u64,
|
||||||
|
evictions_total: self.evictions.load(Ordering::Relaxed),
|
||||||
|
per_point,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default cache file location: a sibling of the work DB directory, mirroring
|
||||||
|
/// `default_pp_cache_index_dir` (`<work-db-name>.crypto-sig-cache`).
|
||||||
|
pub fn default_cache_file_path(db_path: &Path) -> PathBuf {
|
||||||
|
let file_name = db_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("work-db");
|
||||||
|
db_path.with_file_name(format!("{file_name}.crypto-sig-cache"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cache_file(path: &Path) -> Option<HashMap<[u8; 32], CacheValue>> {
|
||||||
|
let bytes = std::fs::read(path).ok()?;
|
||||||
|
let header_len = CACHE_FILE_MAGIC.len() + 8;
|
||||||
|
if bytes.len() < header_len || &bytes[..CACHE_FILE_MAGIC.len()] != CACHE_FILE_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut count_bytes = [0u8; 8];
|
||||||
|
count_bytes.copy_from_slice(&bytes[CACHE_FILE_MAGIC.len()..header_len]);
|
||||||
|
let count = u64::from_le_bytes(count_bytes) as usize;
|
||||||
|
let body = &bytes[header_len..];
|
||||||
|
if body.len() != count.saturating_mul(RECORD_BYTES) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut entries = HashMap::with_capacity(count);
|
||||||
|
for chunk in body.chunks_exact(RECORD_BYTES) {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&chunk[..32]);
|
||||||
|
let mut secs_bytes = [0u8; 8];
|
||||||
|
secs_bytes.copy_from_slice(&chunk[32..40]);
|
||||||
|
let mut version_bytes = [0u8; 4];
|
||||||
|
version_bytes.copy_from_slice(&chunk[40..44]);
|
||||||
|
entries.insert(
|
||||||
|
key,
|
||||||
|
CacheValue {
|
||||||
|
verified_at_unix_secs: i64::from_le_bytes(secs_bytes),
|
||||||
|
crypto_impl_version: u32::from_le_bytes(version_bytes),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_cache_file(
|
||||||
|
path: &Path,
|
||||||
|
shards: &[Mutex<HashMap<[u8; 32], CacheValue>>],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(CACHE_FILE_MAGIC);
|
||||||
|
// Placeholder for the record count; patched after collecting.
|
||||||
|
bytes.extend_from_slice(&[0u8; 8]);
|
||||||
|
let mut count = 0u64;
|
||||||
|
for shard in shards {
|
||||||
|
let map = shard.lock().expect("crypto sig shard lock");
|
||||||
|
for (key, value) in map.iter() {
|
||||||
|
bytes.extend_from_slice(key);
|
||||||
|
bytes.extend_from_slice(&value.verified_at_unix_secs.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&value.crypto_impl_version.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&[0u8; 4]);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes[CACHE_FILE_MAGIC.len()..CACHE_FILE_MAGIC.len() + 8]
|
||||||
|
.copy_from_slice(&count.to_le_bytes());
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("create cache-file dir failed: {}: {e}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let tmp_path = path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp_path, &bytes)
|
||||||
|
.map_err(|e| format!("write cache file failed: {}: {e}", tmp_path.display()))?;
|
||||||
|
std::fs::rename(&tmp_path, path).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"rename cache file failed: {} -> {}: {e}",
|
||||||
|
tmp_path.display(),
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
static GLOBAL_CACHE: RwLock<Option<Arc<CryptoSigCache>>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Install the process-global cache (called once from the CLI run path when
|
||||||
|
/// `--crypto-signature-cache-observe-only` and/or `--enable-crypto-signature-cache` is set).
|
||||||
|
pub fn install_global(cache: Arc<CryptoSigCache>) {
|
||||||
|
*GLOBAL_CACHE.write().expect("crypto sig cache lock") = Some(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the process-global cache. Used by tests and by the CLI after the run finished.
|
||||||
|
pub fn clear_global() {
|
||||||
|
*GLOBAL_CACHE.write().expect("crypto sig cache lock") = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn global_cache() -> Option<Arc<CryptoSigCache>> {
|
||||||
|
GLOBAL_CACHE
|
||||||
|
.read()
|
||||||
|
.expect("crypto sig cache lock")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choke-point hook: cache-aware wrapper around a real signature verification.
|
||||||
|
///
|
||||||
|
/// With no cache installed this is a single read-lock check plus the verification itself;
|
||||||
|
/// verification semantics never change either way (a hit only skips the crypto primitive,
|
||||||
|
/// never the surrounding revocation/time/policy checks).
|
||||||
|
pub fn verify_with_cache<E>(
|
||||||
|
point: CryptoSigVerifyPoint,
|
||||||
|
input_bytes: &[u8],
|
||||||
|
signature_bytes: &[u8],
|
||||||
|
verifier_spki_der: &[u8],
|
||||||
|
verify: impl FnOnce() -> Result<(), E>,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
let Some(cache) = global_cache() else {
|
||||||
|
return verify();
|
||||||
|
};
|
||||||
|
cache.verify(
|
||||||
|
point,
|
||||||
|
input_bytes,
|
||||||
|
signature_bytes,
|
||||||
|
verifier_spki_der,
|
||||||
|
verify,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) static GLOBAL_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_cache(reuse_enabled: bool) -> (tempfile::TempDir, CryptoSigCache) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(
|
||||||
|
dir.path().join("work-db.crypto-sig-cache"),
|
||||||
|
reuse_enabled,
|
||||||
|
);
|
||||||
|
(dir, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_key_is_deterministic_and_domain_separated() {
|
||||||
|
let a = compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
);
|
||||||
|
let b = compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
|
||||||
|
// Different verify point.
|
||||||
|
assert_ne!(
|
||||||
|
a,
|
||||||
|
compute_cache_key(CryptoSigVerifyPoint::Crl, b"input", b"sig", b"spki")
|
||||||
|
);
|
||||||
|
// Different input bytes.
|
||||||
|
assert_ne!(
|
||||||
|
a,
|
||||||
|
compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input2",
|
||||||
|
b"sig",
|
||||||
|
b"spki"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Different signature bytes over the same input must not share a key.
|
||||||
|
assert_ne!(
|
||||||
|
a,
|
||||||
|
compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input",
|
||||||
|
b"sig2",
|
||||||
|
b"spki"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Different verifier key.
|
||||||
|
assert_ne!(
|
||||||
|
a,
|
||||||
|
compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input",
|
||||||
|
b"sig",
|
||||||
|
b"spki2"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn miss_verifies_and_writes_then_hit_skips_verify() {
|
||||||
|
let (_dir, cache) = temp_cache(true);
|
||||||
|
let mut executed = 0u64;
|
||||||
|
// First call: miss -> real verification runs and passes -> entry written.
|
||||||
|
cache
|
||||||
|
.verify(
|
||||||
|
CryptoSigVerifyPoint::EeCertFast,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("verify ok");
|
||||||
|
assert_eq!(executed, 1);
|
||||||
|
// Second call: hit -> verification skipped entirely.
|
||||||
|
cache
|
||||||
|
.verify(
|
||||||
|
CryptoSigVerifyPoint::EeCertFast,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("verify ok");
|
||||||
|
assert_eq!(executed, 1, "cache hit must skip the real verification");
|
||||||
|
let summary = cache.summary();
|
||||||
|
let stats = summary.per_point.get("ee_cert_fast").expect("stats present");
|
||||||
|
assert_eq!(stats.calls, 2);
|
||||||
|
assert_eq!(stats.new_keys, 1);
|
||||||
|
assert_eq!(stats.would_hit, 1);
|
||||||
|
assert_eq!(stats.verify_executed, 1);
|
||||||
|
assert_eq!(stats.verify_skipped, 1);
|
||||||
|
assert!(summary.reuse_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_positive_key_skips_verify_that_would_fail() {
|
||||||
|
let (_dir, cache) = temp_cache(true);
|
||||||
|
// Tampered object bytes whose signature can never verify; pre-seed the positive
|
||||||
|
// conclusion directly (as if a previous run had verified the untampered object).
|
||||||
|
let key = compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"tampered-input",
|
||||||
|
b"tampered-sig",
|
||||||
|
b"spki",
|
||||||
|
);
|
||||||
|
cache.insert_positive(key);
|
||||||
|
let mut executed = 0u64;
|
||||||
|
let result = cache.verify(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"tampered-input",
|
||||||
|
b"tampered-sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Err("signature does not verify".to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(result, Ok(()), "cache hit must return Ok without verifying");
|
||||||
|
assert_eq!(executed, 0, "real verification must not run on a hit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_verify_is_not_cached_and_reruns_next_time() {
|
||||||
|
let (_dir, cache) = temp_cache(true);
|
||||||
|
let mut executed = 0u64;
|
||||||
|
for _ in 0..2 {
|
||||||
|
let result = cache.verify(
|
||||||
|
CryptoSigVerifyPoint::Crl,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Err("bad signature".to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(executed, 2, "no negative caching: every call re-verifies");
|
||||||
|
let key = compute_cache_key(CryptoSigVerifyPoint::Crl, b"tbs", b"sig", b"spki");
|
||||||
|
assert!(!cache.contains(&key));
|
||||||
|
let summary = cache.summary();
|
||||||
|
let stats = summary.per_point.get("crl").expect("stats present");
|
||||||
|
assert_eq!(stats.new_keys, 2);
|
||||||
|
assert_eq!(stats.verify_executed, 2);
|
||||||
|
assert_eq!(stats.verify_skipped, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observe_only_mode_counts_would_hit_but_still_verifies() {
|
||||||
|
let (_dir, cache) = temp_cache(false);
|
||||||
|
let key = compute_cache_key(CryptoSigVerifyPoint::ChildCaCert, b"tbs", b"sig", b"spki");
|
||||||
|
cache.insert_positive(key);
|
||||||
|
let mut executed = 0u64;
|
||||||
|
cache
|
||||||
|
.verify(
|
||||||
|
CryptoSigVerifyPoint::ChildCaCert,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("verify ok");
|
||||||
|
assert_eq!(executed, 1, "observe-only mode must still run the verification");
|
||||||
|
let summary = cache.summary();
|
||||||
|
assert!(!summary.reuse_enabled);
|
||||||
|
let stats = summary
|
||||||
|
.per_point
|
||||||
|
.get("child_ca_cert")
|
||||||
|
.expect("stats present");
|
||||||
|
assert_eq!(stats.would_hit, 1);
|
||||||
|
assert_eq!(stats.verify_executed, 1);
|
||||||
|
assert_eq!(stats.verify_skipped, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_file_roundtrips_entries_across_reload() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("work-db.crypto-sig-cache");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
|
||||||
|
let key = compute_cache_key(CryptoSigVerifyPoint::EeCertFast, b"tbs", b"sig", b"spki");
|
||||||
|
cache.insert_positive(key);
|
||||||
|
cache.persist().expect("persist");
|
||||||
|
|
||||||
|
let reloaded = CryptoSigCache::load_or_rebuild(path, true);
|
||||||
|
assert!(reloaded.contains(&key));
|
||||||
|
let summary = reloaded.summary();
|
||||||
|
assert_eq!(summary.entries_loaded, 1);
|
||||||
|
assert_eq!(summary.entries_total, 1);
|
||||||
|
assert_eq!(summary.schema_version, CACHE_SCHEMA_VERSION);
|
||||||
|
|
||||||
|
// Reloaded entry drives a skip.
|
||||||
|
let mut executed = 0u64;
|
||||||
|
reloaded
|
||||||
|
.verify(
|
||||||
|
CryptoSigVerifyPoint::EeCertFast,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("verify ok");
|
||||||
|
assert_eq!(executed, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_file_rebuilds_on_missing_corrupt_or_mismatched_schema() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("work-db.crypto-sig-cache");
|
||||||
|
|
||||||
|
// Missing file.
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
|
||||||
|
assert_eq!(cache.summary().entries_loaded, 0);
|
||||||
|
|
||||||
|
// Garbage content.
|
||||||
|
std::fs::write(&path, b"not a valid cache file").expect("write garbage");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
|
||||||
|
assert_eq!(cache.summary().entries_loaded, 0);
|
||||||
|
|
||||||
|
// M1 seen-set format (RPKICSC1) must not load as a v2 cache: cold start.
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(b"RPKICSC1");
|
||||||
|
bytes.extend_from_slice(&1u64.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&[7u8; 32]);
|
||||||
|
std::fs::write(&path, &bytes).expect("write v1 file");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(path.clone(), true);
|
||||||
|
assert_eq!(cache.summary().entries_loaded, 0);
|
||||||
|
|
||||||
|
// Valid v2 header but truncated body.
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(CACHE_FILE_MAGIC);
|
||||||
|
bytes.extend_from_slice(&2u64.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&[9u8; RECORD_BYTES]);
|
||||||
|
std::fs::write(&path, &bytes).expect("write truncated");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild(path, true);
|
||||||
|
assert_eq!(cache.summary().entries_loaded, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_cap_triggers_full_rebuild_eviction() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cache = CryptoSigCache::load_or_rebuild_with_capacity(
|
||||||
|
dir.path().join("work-db.crypto-sig-cache"),
|
||||||
|
true,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let k1 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"a", b"s", b"k");
|
||||||
|
let k2 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"b", b"s", b"k");
|
||||||
|
let k3 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"c", b"s", b"k");
|
||||||
|
cache.insert_positive(k1);
|
||||||
|
cache.insert_positive(k2);
|
||||||
|
assert!(cache.contains(&k1) && cache.contains(&k2));
|
||||||
|
// Third insert exceeds the cap -> full rebuild, then insert.
|
||||||
|
cache.insert_positive(k3);
|
||||||
|
assert!(!cache.contains(&k1));
|
||||||
|
assert!(!cache.contains(&k2));
|
||||||
|
assert!(cache.contains(&k3));
|
||||||
|
let summary = cache.summary();
|
||||||
|
assert_eq!(summary.entries_total, 1);
|
||||||
|
assert_eq!(summary.evictions_total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_cache_file_path_mirrors_pp_cache_index_placement() {
|
||||||
|
let db = Path::new("/state/db/work-db");
|
||||||
|
assert_eq!(
|
||||||
|
default_cache_file_path(db),
|
||||||
|
PathBuf::from("/state/db/work-db.crypto-sig-cache")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_with_cache_without_global_cache_runs_verify_and_collects_nothing() {
|
||||||
|
let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
|
||||||
|
clear_global();
|
||||||
|
assert!(global_cache().is_none());
|
||||||
|
let mut executed = 0u64;
|
||||||
|
let result: Result<u64, String> = (|| {
|
||||||
|
verify_with_cache(
|
||||||
|
CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
b"input",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| -> Result<(), String> {
|
||||||
|
executed += 1;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(42)
|
||||||
|
})();
|
||||||
|
assert_eq!(result, Ok(42));
|
||||||
|
assert_eq!(executed, 1);
|
||||||
|
assert!(global_cache().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_with_cache_preserves_result_through_global_install() {
|
||||||
|
let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock");
|
||||||
|
clear_global();
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cache = Arc::new(CryptoSigCache::load_or_rebuild(
|
||||||
|
dir.path().join("work-db.crypto-sig-cache"),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
install_global(Arc::clone(&cache));
|
||||||
|
|
||||||
|
let ok: Result<(), String> = verify_with_cache(
|
||||||
|
CryptoSigVerifyPoint::ChildCaCert,
|
||||||
|
b"tbs",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| Ok(()),
|
||||||
|
);
|
||||||
|
assert_eq!(ok, Ok(()));
|
||||||
|
let err: Result<(), String> = verify_with_cache(
|
||||||
|
CryptoSigVerifyPoint::ChildCaCert,
|
||||||
|
b"tbs2",
|
||||||
|
b"sig",
|
||||||
|
b"spki",
|
||||||
|
|| Err("bad signature".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(err, Err("bad signature".to_string()));
|
||||||
|
let summary = cache.summary();
|
||||||
|
let stats = summary
|
||||||
|
.per_point
|
||||||
|
.get("child_ca_cert")
|
||||||
|
.expect("stats present");
|
||||||
|
assert_eq!(stats.calls, 2);
|
||||||
|
assert_eq!(stats.new_keys, 2);
|
||||||
|
clear_global();
|
||||||
|
assert!(global_cache().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
|
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
|
||||||
|
|
||||||
@@ -25,7 +25,12 @@ pub struct CurrentRepoIndex {
|
|||||||
by_uri: HashMap<String, CurrentRepoEntry>,
|
by_uri: HashMap<String, CurrentRepoEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type CurrentRepoIndexHandle = Arc<Mutex<CurrentRepoIndex>>;
|
/// Shared handle to the run-wide current repository index. Readers (phase 2
|
||||||
|
/// publication point staging, cache lookups, output snapshots) take
|
||||||
|
/// `.read()` and no longer exclude each other; writers (repo sync transports
|
||||||
|
/// applying repository view entries, run-state reset) take `.write()` and
|
||||||
|
/// stay exclusive.
|
||||||
|
pub type CurrentRepoIndexHandle = Arc<RwLock<CurrentRepoIndex>>;
|
||||||
|
|
||||||
impl CurrentRepoIndex {
|
impl CurrentRepoIndex {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -33,7 +38,7 @@ impl CurrentRepoIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shared() -> CurrentRepoIndexHandle {
|
pub fn shared() -> CurrentRepoIndexHandle {
|
||||||
Arc::new(Mutex::new(Self::new()))
|
Arc::new(RwLock::new(Self::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_by_uri(&self, rsync_uri: &str) -> Option<&CurrentRepoEntry> {
|
pub fn get_by_uri(&self, rsync_uri: &str) -> Option<&CurrentRepoEntry> {
|
||||||
@@ -205,7 +210,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
|
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
|
||||||
let handle = CurrentRepoIndex::shared();
|
let handle = CurrentRepoIndex::shared();
|
||||||
let mut index = handle.lock().expect("lock index");
|
let mut index = handle.write().expect("write-lock index");
|
||||||
index
|
index
|
||||||
.apply_repository_view_entries(&[
|
.apply_repository_view_entries(&[
|
||||||
present(
|
present(
|
||||||
|
|||||||
@@ -285,8 +285,16 @@ impl RpkixCrl {
|
|||||||
if !rem.is_empty() {
|
if !rem.is_empty() {
|
||||||
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
|
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
|
||||||
}
|
}
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::Crl,
|
||||||
|
crl.tbs_cert_list.as_ref(),
|
||||||
|
crl.signature_value.data.as_ref(),
|
||||||
|
issuer_spki.raw,
|
||||||
|
|| {
|
||||||
crl.verify_signature(issuer_spki)
|
crl.verify_signature(issuer_spki)
|
||||||
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
|
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the cryptographic signature on this CRL using a DER-encoded SubjectPublicKeyInfo.
|
/// Verify the cryptographic signature on this CRL using a DER-encoded SubjectPublicKeyInfo.
|
||||||
|
|||||||
@@ -358,7 +358,19 @@ impl RpkiSignedObject {
|
|||||||
/// Verify the CMS signature using the embedded EE certificate public key.
|
/// Verify the CMS signature using the embedded EE certificate public key.
|
||||||
pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> {
|
pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> {
|
||||||
let ee = &self.signed_data.certificates[0];
|
let ee = &self.signed_data.certificates[0];
|
||||||
self.verify_signature_with_rsa_components(&ee.rsa_public_modulus, &ee.rsa_public_exponent)
|
let signer = &self.signed_data.signer_infos[0];
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
&signer.signed_attrs_der_for_signature,
|
||||||
|
&signer.signature,
|
||||||
|
&ee.spki_der,
|
||||||
|
|| {
|
||||||
|
self.verify_signature_with_rsa_components(
|
||||||
|
&ee.rsa_public_modulus,
|
||||||
|
&ee.rsa_public_exponent,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo.
|
/// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo.
|
||||||
@@ -394,7 +406,14 @@ impl RpkiSignedObject {
|
|||||||
_ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm),
|
_ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice())
|
let signer = &self.signed_data.signer_infos[0];
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject,
|
||||||
|
&signer.signed_attrs_der_for_signature,
|
||||||
|
&signer.signature,
|
||||||
|
ee_spki.raw,
|
||||||
|
|| self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_signature_with_rsa_components(
|
fn verify_signature_with_rsa_components(
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::fetch::rsync::{
|
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
|
||||||
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
|
use crate::fetch::rsync_system::{
|
||||||
|
RsyncScopePolicy, scoped_rsync_failure_dedup_key, scoped_rsync_fetch_uri,
|
||||||
};
|
};
|
||||||
use crate::storage::{RepositoryViewState, RocksStore};
|
use crate::storage::{RepositoryViewState, RocksStore};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CurrentRepositoryViewRsyncFetcher {
|
pub struct CurrentRepositoryViewRsyncFetcher {
|
||||||
store: Arc<RocksStore>,
|
store: Arc<RocksStore>,
|
||||||
|
scope_policy: RsyncScopePolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CurrentRepositoryViewRsyncFetcher {
|
impl CurrentRepositoryViewRsyncFetcher {
|
||||||
pub fn new(store: Arc<RocksStore>) -> Self {
|
pub fn new(store: Arc<RocksStore>, scope_policy: RsyncScopePolicy) -> Self {
|
||||||
Self { store }
|
Self {
|
||||||
|
store,
|
||||||
|
scope_policy,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
|
impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
|
||||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
||||||
let base = normalize_rsync_base_uri(rsync_base_uri);
|
// The source run's live fetcher may have fetched a module root rather
|
||||||
|
// than the publication-point URI that triggered it. Read that same
|
||||||
|
// frozen-view prefix here so transport prefetch request identities and
|
||||||
|
// their materialized object sets stay equivalent.
|
||||||
|
let base = scoped_rsync_fetch_uri(self.scope_policy, rsync_base_uri);
|
||||||
let entries = self
|
let entries = self
|
||||||
.store
|
.store
|
||||||
.list_repository_view_entries_with_prefix(&base)
|
.list_repository_view_entries_with_prefix(&base)
|
||||||
@@ -75,7 +84,11 @@ impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dedup_key(&self, rsync_base_uri: &str) -> String {
|
fn dedup_key(&self, rsync_base_uri: &str) -> String {
|
||||||
normalize_rsync_base_uri(rsync_base_uri)
|
scoped_rsync_fetch_uri(self.scope_policy, rsync_base_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
|
||||||
|
scoped_rsync_failure_dedup_key(self.scope_policy, rsync_base_uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +120,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("put view");
|
.expect("put view");
|
||||||
}
|
}
|
||||||
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store);
|
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
|
||||||
let objects = fetcher
|
let objects = fetcher
|
||||||
.fetch_objects("rsync://example.test/repo/")
|
.fetch_objects("rsync://example.test/repo/")
|
||||||
.expect("fetch objects");
|
.expect("fetch objects");
|
||||||
@@ -168,11 +181,54 @@ mod tests {
|
|||||||
current_hash: Some(missing_hash),
|
current_hash: Some(missing_hash),
|
||||||
})
|
})
|
||||||
.expect("put missing view");
|
.expect("put missing view");
|
||||||
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store);
|
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
|
||||||
let error = fetcher
|
let error = fetcher
|
||||||
.fetch_objects("rsync://example.test/repo/")
|
.fetch_objects("rsync://example.test/repo/")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(error.contains("blob bytes missing"));
|
assert!(error.contains("blob bytes missing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_repository_fetcher_replays_module_scope_and_failure_dedup() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
|
||||||
|
for (uri, bytes) in [
|
||||||
|
("rsync://example.test/repo/ca/a.mft", b"a".as_slice()),
|
||||||
|
("rsync://example.test/repo/other/b.roa", b"b".as_slice()),
|
||||||
|
] {
|
||||||
|
let hash = hex::encode(crate::cir::sha256(bytes));
|
||||||
|
store
|
||||||
|
.put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
|
||||||
|
.expect("put blob");
|
||||||
|
store
|
||||||
|
.put_repository_view_entry(&RepositoryViewEntry {
|
||||||
|
rsync_uri: uri.to_string(),
|
||||||
|
repository_source: Some("fixture".to_string()),
|
||||||
|
object_type: Some("fixture".to_string()),
|
||||||
|
state: RepositoryViewState::Present,
|
||||||
|
current_hash: Some(hash),
|
||||||
|
})
|
||||||
|
.expect("put view");
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_fetcher = CurrentRepositoryViewRsyncFetcher::new(
|
||||||
|
Arc::clone(&store),
|
||||||
|
RsyncScopePolicy::ModuleRoot,
|
||||||
|
);
|
||||||
|
let objects = module_fetcher
|
||||||
|
.fetch_objects("rsync://example.test/repo/ca/")
|
||||||
|
.expect("fetch module scope");
|
||||||
|
assert_eq!(objects.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
module_fetcher.dedup_key("rsync://example.test/repo/ca/"),
|
||||||
|
"rsync://example.test/repo/"
|
||||||
|
);
|
||||||
|
|
||||||
|
let host_fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::Host);
|
||||||
|
assert_eq!(
|
||||||
|
host_fetcher.failure_dedup_key("rsync://example.test/repo/ca/"),
|
||||||
|
Some("rsync://example.test/".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ pub fn with_scoped_http_timeout_override<R>(timeout: Duration, f: impl FnOnce()
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default User-Agent sent with every outgoing HTTP request (RRDP
|
||||||
|
/// notification/snapshot/delta, TAL/TA downloads, daemon probes).
|
||||||
|
pub const DEFAULT_HTTP_USER_AGENT: &str = "panda-rpki/0.2";
|
||||||
|
|
||||||
|
/// Environment variable that overrides the HTTP User-Agent. Unset, empty, or
|
||||||
|
/// values containing characters that are illegal in a header value fall back
|
||||||
|
/// to [`DEFAULT_HTTP_USER_AGENT`].
|
||||||
|
pub const HTTP_USER_AGENT_ENV: &str = "RPKI_HTTP_USER_AGENT";
|
||||||
|
|
||||||
|
fn resolve_http_user_agent(env_value: Option<String>) -> String {
|
||||||
|
let Some(value) = env_value else {
|
||||||
|
return DEFAULT_HTTP_USER_AGENT.to_string();
|
||||||
|
};
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let valid = !trimmed.is_empty() && trimmed.bytes().all(|b| (0x20..=0x7e).contains(&b));
|
||||||
|
if valid {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
DEFAULT_HTTP_USER_AGENT.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HttpFetcherConfig {
|
pub struct HttpFetcherConfig {
|
||||||
/// Connection-establishment timeout for HTTP requests.
|
/// Connection-establishment timeout for HTTP requests.
|
||||||
@@ -39,7 +61,7 @@ impl Default for HttpFetcherConfig {
|
|||||||
connect_timeout: Duration::from_secs(15),
|
connect_timeout: Duration::from_secs(15),
|
||||||
timeout: Duration::from_secs(30),
|
timeout: Duration::from_secs(30),
|
||||||
large_body_timeout: Duration::from_secs(180),
|
large_body_timeout: Duration::from_secs(180),
|
||||||
user_agent: "rpki-dev/0.1 (stage2)".to_string(),
|
user_agent: resolve_http_user_agent(std::env::var(HTTP_USER_AGENT_ENV).ok()),
|
||||||
extra_root_certificates_pem: Vec::new(),
|
extra_root_certificates_pem: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +106,7 @@ impl BlockingHttpFetcher {
|
|||||||
&config,
|
&config,
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
"rpki-dev/0.1 (stage2)".to_string(),
|
config.user_agent.clone(),
|
||||||
)?
|
)?
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -532,4 +554,99 @@ mod tests {
|
|||||||
assert_eq!(profile_large, "large_body");
|
assert_eq!(profile_large, "large_body");
|
||||||
assert_eq!(timeout_large, Duration::from_secs(9));
|
assert_eq!(timeout_large, Duration::from_secs(9));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_http_user_agent_falls_back_to_default() {
|
||||||
|
assert_eq!(resolve_http_user_agent(None), DEFAULT_HTTP_USER_AGENT);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some(String::new())),
|
||||||
|
DEFAULT_HTTP_USER_AGENT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some(" ".to_string())),
|
||||||
|
DEFAULT_HTTP_USER_AGENT
|
||||||
|
);
|
||||||
|
// Control characters are illegal in header values; fall back instead
|
||||||
|
// of letting reqwest fail to build the client.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some("bad\u{1}ua".to_string())),
|
||||||
|
DEFAULT_HTTP_USER_AGENT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some("non-ascii-ua-中".to_string())),
|
||||||
|
DEFAULT_HTTP_USER_AGENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_http_user_agent_accepts_custom_value() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some("panda-rpki/9.9 (test)".to_string())),
|
||||||
|
"panda-rpki/9.9 (test)"
|
||||||
|
);
|
||||||
|
// Surrounding whitespace is trimmed.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_http_user_agent(Some(" custom/1.0 ".to_string())),
|
||||||
|
"custom/1.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_user_agent_capture_server() -> (String, std::sync::mpsc::Receiver<String>) {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("accept");
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let read = stream.read(&mut buf).expect("read request");
|
||||||
|
let request = String::from_utf8_lossy(&buf[..read]).to_string();
|
||||||
|
let body = b"ok";
|
||||||
|
let hdr = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(hdr.as_bytes()).expect("write hdr");
|
||||||
|
stream.write_all(body).expect("write body");
|
||||||
|
let _ = tx.send(request);
|
||||||
|
});
|
||||||
|
(format!("http://{}/", addr), rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_bytes_sends_configured_user_agent_header() {
|
||||||
|
let (url, rx) = spawn_user_agent_capture_server();
|
||||||
|
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
|
timeout: Duration::from_secs(2),
|
||||||
|
user_agent: "panda-rpki/0.2".to_string(),
|
||||||
|
..HttpFetcherConfig::default()
|
||||||
|
})
|
||||||
|
.expect("http");
|
||||||
|
http.fetch_bytes(&url).expect("fetch");
|
||||||
|
let request = rx.recv().expect("request captured");
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("user-agent: panda-rpki/0.2"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_bytes_sends_custom_user_agent_header() {
|
||||||
|
let (url, rx) = spawn_user_agent_capture_server();
|
||||||
|
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
|
timeout: Duration::from_secs(2),
|
||||||
|
user_agent: "custom-marker/7.7".to_string(),
|
||||||
|
..HttpFetcherConfig::default()
|
||||||
|
})
|
||||||
|
.expect("http");
|
||||||
|
http.fetch_bytes(&url).expect("fetch");
|
||||||
|
let request = rx.recv().expect("request captured");
|
||||||
|
assert!(
|
||||||
|
request
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("user-agent: custom-marker/7.7"),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use crate::fetch::rsync::{
|
|||||||
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
|
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum RsyncScopePolicy {
|
pub enum RsyncScopePolicy {
|
||||||
Host,
|
Host,
|
||||||
PublicationPoint,
|
PublicationPoint,
|
||||||
@@ -297,13 +298,7 @@ impl SystemRsyncFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scope_fetch_uri(&self, rsync_base_uri: &str) -> String {
|
fn scope_fetch_uri(&self, rsync_base_uri: &str) -> String {
|
||||||
match self.config.scope_policy {
|
scoped_rsync_fetch_uri(self.config.scope_policy, rsync_base_uri)
|
||||||
RsyncScopePolicy::Host | RsyncScopePolicy::PublicationPoint => {
|
|
||||||
normalize_rsync_base_uri(rsync_base_uri)
|
|
||||||
}
|
|
||||||
RsyncScopePolicy::ModuleRoot => rsync_module_root_uri(rsync_base_uri)
|
|
||||||
.unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,11 +377,36 @@ impl RsyncFetcher for SystemRsyncFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
|
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
|
||||||
match self.config.scope_policy {
|
scoped_rsync_failure_dedup_key(self.config.scope_policy, rsync_base_uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the exact successful-fetch scope used by the live rsync fetcher.
|
||||||
|
///
|
||||||
|
/// Verification-only reuses this function while reading the frozen repository
|
||||||
|
/// view, so its request keys and object projection scope remain equivalent to
|
||||||
|
/// the source run without granting it network access.
|
||||||
|
pub fn scoped_rsync_fetch_uri(scope_policy: RsyncScopePolicy, rsync_base_uri: &str) -> String {
|
||||||
|
match scope_policy {
|
||||||
|
RsyncScopePolicy::Host | RsyncScopePolicy::PublicationPoint => {
|
||||||
|
normalize_rsync_base_uri(rsync_base_uri)
|
||||||
|
}
|
||||||
|
RsyncScopePolicy::ModuleRoot => rsync_module_root_uri(rsync_base_uri)
|
||||||
|
.unwrap_or_else(|| normalize_rsync_base_uri(rsync_base_uri)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the live fetcher's failure-deduplication key for the configured
|
||||||
|
/// scope. This is part of the transport request identity, even though host
|
||||||
|
/// scope deliberately does not widen the successful fetch URI.
|
||||||
|
pub fn scoped_rsync_failure_dedup_key(
|
||||||
|
scope_policy: RsyncScopePolicy,
|
||||||
|
rsync_base_uri: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
match scope_policy {
|
||||||
RsyncScopePolicy::Host => rsync_host_scope_uri(rsync_base_uri),
|
RsyncScopePolicy::Host => rsync_host_scope_uri(rsync_base_uri),
|
||||||
RsyncScopePolicy::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
|
RsyncScopePolicy::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rsync_host_scope_uri(rsync_base_uri: &str) -> Option<String> {
|
fn rsync_host_scope_uri(rsync_base_uri: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod ccr;
|
pub mod ccr;
|
||||||
pub mod cir;
|
pub mod cir;
|
||||||
|
pub mod crypto_sig_cache;
|
||||||
pub mod data_model;
|
pub mod data_model;
|
||||||
|
|
||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct ParallelPhase1Config {
|
pub struct ParallelPhase1Config {
|
||||||
pub max_repo_sync_workers_global: usize,
|
pub max_repo_sync_workers_global: usize,
|
||||||
pub max_inflight_snapshot_bytes_global: usize,
|
pub max_inflight_snapshot_bytes_global: usize,
|
||||||
pub max_pending_repo_results: usize,
|
pub max_pending_repo_results: usize,
|
||||||
|
/// Dead-repo transport blacklist (#141). `None` disables the feature
|
||||||
|
/// entirely (default, behavior unchanged).
|
||||||
|
pub dead_repo_blacklist: Option<DeadRepoBlacklistConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -15,6 +20,9 @@ pub struct ParallelPhase2Config {
|
|||||||
pub publication_point_finalize_batch_size: usize,
|
pub publication_point_finalize_batch_size: usize,
|
||||||
pub publication_point_finalize_wall_time_budget_ms: u64,
|
pub publication_point_finalize_wall_time_budget_ms: u64,
|
||||||
pub publication_point_finalize_queue_capacity: usize,
|
pub publication_point_finalize_queue_capacity: usize,
|
||||||
|
/// Experimental: workers of the ready publication point stage pool. `0`
|
||||||
|
/// disables the pool and keeps the inline compute+apply staging path.
|
||||||
|
pub stage_workers: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ParallelPhase2Config {
|
impl Default for ParallelPhase2Config {
|
||||||
@@ -28,6 +36,7 @@ impl Default for ParallelPhase2Config {
|
|||||||
publication_point_finalize_batch_size: 256,
|
publication_point_finalize_batch_size: 256,
|
||||||
publication_point_finalize_wall_time_budget_ms: 100,
|
publication_point_finalize_wall_time_budget_ms: 100,
|
||||||
publication_point_finalize_queue_capacity: 32768,
|
publication_point_finalize_queue_capacity: 32768,
|
||||||
|
stage_workers: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +47,7 @@ impl Default for ParallelPhase1Config {
|
|||||||
max_repo_sync_workers_global: 4,
|
max_repo_sync_workers_global: 4,
|
||||||
max_inflight_snapshot_bytes_global: 512 * 1024 * 1024,
|
max_inflight_snapshot_bytes_global: 512 * 1024 * 1024,
|
||||||
max_pending_repo_results: 1024,
|
max_pending_repo_results: 1024,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
376
src/parallel/dead_repo_blacklist.rs
Normal file
376
src/parallel/dead_repo_blacklist.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
//! Persistent dead-repo transport blacklist (#141).
|
||||||
|
//!
|
||||||
|
//! Tracks per-(repo, transport) consecutive transport-layer fetch failures
|
||||||
|
//! across runs. Once an entry reaches the configured failure threshold it is
|
||||||
|
//! admitted to the blacklist; the live transport scheduler then skips the
|
||||||
|
//! dead transport (rrdp-blacklisted -> straight to rsync, dual-blacklisted ->
|
||||||
|
//! immediate terminal failure). Entries are removed either by a successful
|
||||||
|
//! fetch (self-heal) or by the daemon-side health probe.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::parallel::types::{RepoTransportErrorClass, RepoTransportMode};
|
||||||
|
|
||||||
|
pub const DEAD_REPO_BLACKLIST_SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const DEAD_REPO_BLACKLIST_DEFAULT_CAPACITY: usize = 256;
|
||||||
|
/// Probe backoff is capped at interval * 2^PROBE_BACKOFF_CAP_SHIFT.
|
||||||
|
pub const PROBE_BACKOFF_CAP_SHIFT: u32 = 4;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct DeadRepoBlacklistConfig {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub fail_threshold: u32,
|
||||||
|
pub capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DeadRepoBlacklistEntry {
|
||||||
|
pub transport: RepoTransportMode,
|
||||||
|
pub uri: String,
|
||||||
|
/// Consecutive runs with transport-class fetch failure. Reset on success.
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
/// True once `consecutive_failures` reached the admission threshold.
|
||||||
|
#[serde(default)]
|
||||||
|
pub blacklisted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub first_failure_at_unix: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_failure_at_unix: u64,
|
||||||
|
/// When the entry was admitted to the blacklist.
|
||||||
|
#[serde(default)]
|
||||||
|
pub added_at_unix: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_probe_at_unix: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub probe_failures: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DeadRepoFailureOutcome {
|
||||||
|
/// Failure counted, entry not (or not yet) blacklisted.
|
||||||
|
Counted { consecutive_failures: u32 },
|
||||||
|
/// Entry just reached the threshold and was admitted.
|
||||||
|
Admitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DeadRepoSuccessOutcome {
|
||||||
|
/// A counting (not yet admitted) entry was reset.
|
||||||
|
CounterReset,
|
||||||
|
/// A blacklisted entry was removed (repo revived).
|
||||||
|
BlacklistRemoved,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct DeadRepoBlacklistFile {
|
||||||
|
schema_version: u32,
|
||||||
|
updated_at_unix: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
entries: Vec<DeadRepoBlacklistEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory blacklist state for one writer at a time (child run, or daemon
|
||||||
|
/// supervisor between runs). Lookups go through a HashMap keyed by
|
||||||
|
/// (transport, uri).
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct DeadRepoBlacklist {
|
||||||
|
entries: HashMap<(RepoTransportMode, String), DeadRepoBlacklistEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeadRepoBlacklist {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from disk. A missing file or an unreadable/incompatible file
|
||||||
|
/// degrades to an empty blacklist (logged by the caller via `warning`).
|
||||||
|
pub fn load(path: &Path) -> (Self, Option<String>) {
|
||||||
|
let bytes = match fs::read(path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return (Self::new(), None),
|
||||||
|
Err(err) => {
|
||||||
|
return (
|
||||||
|
Self::new(),
|
||||||
|
Some(format!(
|
||||||
|
"dead repo blacklist {} unreadable ({err}); starting empty",
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match serde_json::from_slice::<DeadRepoBlacklistFile>(&bytes) {
|
||||||
|
Ok(file) if file.schema_version == DEAD_REPO_BLACKLIST_SCHEMA_VERSION => {
|
||||||
|
let entries = file
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| ((entry.transport, entry.uri.clone()), entry))
|
||||||
|
.collect();
|
||||||
|
(Self { entries }, None)
|
||||||
|
}
|
||||||
|
Ok(file) => (
|
||||||
|
Self::new(),
|
||||||
|
Some(format!(
|
||||||
|
"dead repo blacklist {} schema_version {} != {}; starting empty",
|
||||||
|
path.display(),
|
||||||
|
file.schema_version,
|
||||||
|
DEAD_REPO_BLACKLIST_SCHEMA_VERSION
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Err(err) => (
|
||||||
|
Self::new(),
|
||||||
|
Some(format!(
|
||||||
|
"dead repo blacklist {} unparsable ({err}); starting empty",
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist atomically (tmp file + rename).
|
||||||
|
pub fn store_atomic(&self, path: &Path, now_unix: u64) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut entries: Vec<&DeadRepoBlacklistEntry> = self.entries.values().collect();
|
||||||
|
entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
|
||||||
|
let file = DeadRepoBlacklistFile {
|
||||||
|
schema_version: DEAD_REPO_BLACKLIST_SCHEMA_VERSION,
|
||||||
|
updated_at_unix: now_unix,
|
||||||
|
entries: entries.into_iter().cloned().collect(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec_pretty(&file)
|
||||||
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||||
|
let mut tmp_name = path.file_name().map(|name| name.to_os_string()).unwrap_or_default();
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
let tmp_path = path.with_file_name(tmp_name);
|
||||||
|
fs::write(&tmp_path, &bytes)?;
|
||||||
|
// Best-effort durability before the rename.
|
||||||
|
if let Ok(file) = fs::File::open(&tmp_path) {
|
||||||
|
let _ = file.sync_all();
|
||||||
|
}
|
||||||
|
fs::rename(&tmp_path, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_blacklisted(&self, transport: RepoTransportMode, uri: &str) -> bool {
|
||||||
|
self.entries
|
||||||
|
.get(&(transport, uri.to_string()))
|
||||||
|
.map(|entry| entry.blacklisted)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a transport-class fetch failure. Returns whether the failure
|
||||||
|
/// newly admitted the entry to the blacklist.
|
||||||
|
pub fn record_transport_failure(
|
||||||
|
&mut self,
|
||||||
|
transport: RepoTransportMode,
|
||||||
|
uri: &str,
|
||||||
|
now_unix: u64,
|
||||||
|
fail_threshold: u32,
|
||||||
|
capacity: usize,
|
||||||
|
) -> DeadRepoFailureOutcome {
|
||||||
|
let key = (transport, uri.to_string());
|
||||||
|
if !self.entries.contains_key(&key) {
|
||||||
|
self.evict_if_full(capacity);
|
||||||
|
}
|
||||||
|
let threshold = fail_threshold.max(1);
|
||||||
|
let entry = self.entries.entry(key).or_insert_with(|| DeadRepoBlacklistEntry {
|
||||||
|
transport,
|
||||||
|
uri: uri.to_string(),
|
||||||
|
consecutive_failures: 0,
|
||||||
|
blacklisted: false,
|
||||||
|
first_failure_at_unix: now_unix,
|
||||||
|
last_failure_at_unix: now_unix,
|
||||||
|
added_at_unix: 0,
|
||||||
|
last_probe_at_unix: None,
|
||||||
|
probe_failures: 0,
|
||||||
|
});
|
||||||
|
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
|
||||||
|
entry.last_failure_at_unix = now_unix;
|
||||||
|
if entry.first_failure_at_unix == 0 {
|
||||||
|
entry.first_failure_at_unix = now_unix;
|
||||||
|
}
|
||||||
|
if !entry.blacklisted && entry.consecutive_failures >= threshold {
|
||||||
|
entry.blacklisted = true;
|
||||||
|
entry.added_at_unix = now_unix;
|
||||||
|
// A fresh admission restarts probe bookkeeping.
|
||||||
|
entry.last_probe_at_unix = None;
|
||||||
|
entry.probe_failures = 0;
|
||||||
|
return DeadRepoFailureOutcome::Admitted;
|
||||||
|
}
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: entry.consecutive_failures,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful fetch: reset a counting entry, or remove a
|
||||||
|
/// blacklisted entry entirely (self-heal).
|
||||||
|
pub fn record_transport_success(
|
||||||
|
&mut self,
|
||||||
|
transport: RepoTransportMode,
|
||||||
|
uri: &str,
|
||||||
|
) -> Option<DeadRepoSuccessOutcome> {
|
||||||
|
match self.entries.remove(&(transport, uri.to_string())) {
|
||||||
|
Some(entry) if entry.blacklisted => Some(DeadRepoSuccessOutcome::BlacklistRemoved),
|
||||||
|
Some(_) => Some(DeadRepoSuccessOutcome::CounterReset),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blacklisted entries whose next probe is due at `now_unix`.
|
||||||
|
pub fn probe_due_entries(
|
||||||
|
&self,
|
||||||
|
now_unix: u64,
|
||||||
|
probe_interval_secs: u64,
|
||||||
|
) -> Vec<DeadRepoBlacklistEntry> {
|
||||||
|
let mut due: Vec<DeadRepoBlacklistEntry> = self
|
||||||
|
.entries
|
||||||
|
.values()
|
||||||
|
.filter(|entry| entry.blacklisted)
|
||||||
|
.filter(|entry| {
|
||||||
|
let interval = probe_backoff_secs(probe_interval_secs, entry.probe_failures);
|
||||||
|
match entry.last_probe_at_unix {
|
||||||
|
None => true,
|
||||||
|
Some(last) => now_unix.saturating_sub(last) >= interval,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
due.sort_by(|a, b| a.uri.cmp(&b.uri));
|
||||||
|
due
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe succeeded: remove the entry. Returns true if it was present.
|
||||||
|
pub fn record_probe_success(&mut self, transport: RepoTransportMode, uri: &str) -> bool {
|
||||||
|
self.entries.remove(&(transport, uri.to_string())).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_probe_failure(&mut self, transport: RepoTransportMode, uri: &str, now_unix: u64) {
|
||||||
|
if let Some(entry) = self.entries.get_mut(&(transport, uri.to_string())) {
|
||||||
|
entry.last_probe_at_unix = Some(now_unix);
|
||||||
|
entry.probe_failures = entry.probe_failures.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blacklisted_len(&self) -> usize {
|
||||||
|
self.entries.values().filter(|entry| entry.blacklisted).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic snapshot of all entries (for status/observability).
|
||||||
|
pub fn entries_sorted(&self) -> Vec<DeadRepoBlacklistEntry> {
|
||||||
|
let mut entries: Vec<DeadRepoBlacklistEntry> = self.entries.values().cloned().collect();
|
||||||
|
entries.sort_by(|a, b| a.uri.cmp(&b.uri).then(a.transport.cmp(&b.transport)));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_if_full(&mut self, capacity: usize) {
|
||||||
|
let capacity = capacity.max(1);
|
||||||
|
if self.entries.len() < capacity {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prefer evicting the stalest counting entry; fall back to the oldest
|
||||||
|
// blacklisted entry.
|
||||||
|
let victim = self
|
||||||
|
.entries
|
||||||
|
.values()
|
||||||
|
.filter(|entry| !entry.blacklisted)
|
||||||
|
.min_by_key(|entry| entry.last_failure_at_unix)
|
||||||
|
.map(|entry| (entry.transport, entry.uri.clone()))
|
||||||
|
.or_else(|| {
|
||||||
|
self.entries
|
||||||
|
.values()
|
||||||
|
.min_by_key(|entry| entry.added_at_unix)
|
||||||
|
.map(|entry| (entry.transport, entry.uri.clone()))
|
||||||
|
});
|
||||||
|
if let Some(key) = victim {
|
||||||
|
self.entries.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn probe_backoff_secs(base_interval_secs: u64, probe_failures: u32) -> u64 {
|
||||||
|
// First retry after a probe failure still uses the base interval;
|
||||||
|
// exponential backoff kicks in from the second consecutive failure.
|
||||||
|
let shift = probe_failures.saturating_sub(1).min(PROBE_BACKOFF_CAP_SHIFT);
|
||||||
|
base_interval_secs.saturating_mul(1u64 << shift)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify an HTTP fetch error string (formats from
|
||||||
|
/// `fetch/http.rs::BlockingHttpFetcher::fetch_bytes`).
|
||||||
|
pub fn classify_http_fetch_error(detail: &str) -> RepoTransportErrorClass {
|
||||||
|
if detail.starts_with("http status") {
|
||||||
|
// The peer answered: transport is fine, the failure is protocol-level.
|
||||||
|
RepoTransportErrorClass::Protocol
|
||||||
|
} else if detail.starts_with("http request failed") || detail.starts_with("http read body failed") {
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
} else {
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify an rsync fetch error string using the same host-level substring
|
||||||
|
/// set as the run-local rsync failure scope short-circuit.
|
||||||
|
pub fn classify_rsync_fetch_error(detail: &str) -> RepoTransportErrorClass {
|
||||||
|
if crate::parallel::repo_scheduler::is_host_level_rsync_failure(detail)
|
||||||
|
// Stall failures are transport death too: the fail-fast mechanism
|
||||||
|
// gives up when wall-clock windows pass with no (additional) progress
|
||||||
|
// (#141 M9). Content-level errors fail immediately and never carry
|
||||||
|
// this marker, so they still do not count.
|
||||||
|
|| detail.contains("rsync fail-fast gave up")
|
||||||
|
{
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
} else {
|
||||||
|
// Content/protocol-level rsync failures (digest mismatch, vanished
|
||||||
|
// files, partial transfers) never count toward the blacklist.
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a structured RRDP sync error to a transport error class.
|
||||||
|
pub fn classify_rrdp_sync_error(err: &crate::sync::rrdp::RrdpSyncError) -> RepoTransportErrorClass {
|
||||||
|
match err {
|
||||||
|
crate::sync::rrdp::RrdpSyncError::Fetch(detail) => classify_http_fetch_error(detail),
|
||||||
|
crate::sync::rrdp::RrdpSyncError::Rrdp(_) => RepoTransportErrorClass::Protocol,
|
||||||
|
crate::sync::rrdp::RrdpSyncError::Storage(_) => RepoTransportErrorClass::Storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a structured rsync-side repo sync error to a transport error class.
|
||||||
|
pub fn classify_rsync_repo_sync_error(err: &crate::sync::repo::RepoSyncError) -> RepoTransportErrorClass {
|
||||||
|
match err {
|
||||||
|
crate::sync::repo::RepoSyncError::Rsync(crate::fetch::rsync::RsyncFetchError::Fetch(detail)) => {
|
||||||
|
classify_rsync_fetch_error(detail)
|
||||||
|
}
|
||||||
|
crate::sync::repo::RepoSyncError::Storage(_) => RepoTransportErrorClass::Storage,
|
||||||
|
_ => RepoTransportErrorClass::Protocol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoTransportMode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RepoTransportMode::Rrdp => "rrdp",
|
||||||
|
RepoTransportMode::Rsync => "rsync",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the public surface free of internal key types; the (transport, uri)
|
||||||
|
// tuple key is an implementation detail of the lookup map.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "dead_repo_blacklist_tests.rs"]
|
||||||
|
mod tests;
|
||||||
360
src/parallel/dead_repo_blacklist_tests.rs
Normal file
360
src/parallel/dead_repo_blacklist_tests.rs
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::parallel::types::RepoTransportMode;
|
||||||
|
|
||||||
|
const RRDP_URI: &str = "https://dead.example.com/notification.xml";
|
||||||
|
const RSYNC_URI: &str = "rsync://dead.example.com/repo/";
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
1_700_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_requires_threshold_consecutive_failures() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
|
||||||
|
let out1 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
|
||||||
|
assert_eq!(
|
||||||
|
out1,
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
|
||||||
|
let out2 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 1, 3, 256);
|
||||||
|
assert_eq!(
|
||||||
|
out2,
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
|
||||||
|
let out3 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 2, 3, 256);
|
||||||
|
assert_eq!(out3, DeadRepoFailureOutcome::Admitted);
|
||||||
|
assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
|
||||||
|
// Further failures keep counting but do not re-admit.
|
||||||
|
let out4 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 3, 3, 256);
|
||||||
|
assert!(matches!(
|
||||||
|
out4,
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: 4
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn threshold_of_one_admits_immediately() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
let out = bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 1, 256);
|
||||||
|
assert_eq!(out, DeadRepoFailureOutcome::Admitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_resets_counting_entry() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
|
||||||
|
assert_eq!(bl.len(), 1);
|
||||||
|
let out = bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI);
|
||||||
|
assert_eq!(out, Some(DeadRepoSuccessOutcome::CounterReset));
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
// Next failure starts from zero again.
|
||||||
|
let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_on_blacklisted_entry_self_heals() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
assert!(bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
|
||||||
|
let out = bl.record_transport_success(RepoTransportMode::Rsync, RSYNC_URI);
|
||||||
|
assert_eq!(out, Some(DeadRepoSuccessOutcome::BlacklistRemoved));
|
||||||
|
assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_on_unknown_entry_is_none() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
assert_eq!(
|
||||||
|
bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries_are_per_transport() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RRDP_URI));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capacity_evicts_stale_counting_entries_first() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
// One blacklisted entry (old) and two counting entries.
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100 + i, 1, 3);
|
||||||
|
}
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 3, 3);
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 3, 3);
|
||||||
|
assert_eq!(bl.len(), 3);
|
||||||
|
assert_eq!(bl.blacklisted_len(), 1);
|
||||||
|
|
||||||
|
// Inserting a fourth entry evicts the stalest counting entry (b).
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://d.example/n.xml", 400, 3, 3);
|
||||||
|
assert_eq!(bl.len(), 3);
|
||||||
|
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml"));
|
||||||
|
assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml"));
|
||||||
|
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml"));
|
||||||
|
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://d.example/n.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capacity_evicts_oldest_blacklisted_when_no_counting_entries() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100, 1, 2);
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 1, 2);
|
||||||
|
assert_eq!(bl.blacklisted_len(), 2);
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 1, 2);
|
||||||
|
assert_eq!(bl.len(), 2);
|
||||||
|
assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml"));
|
||||||
|
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml"));
|
||||||
|
assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_load_roundtrip() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"dead_repo_blacklist_test_roundtrip_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
now()
|
||||||
|
));
|
||||||
|
let path = dir.join("state").join("dead-repo-blacklist.json");
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 3, 256);
|
||||||
|
bl.store_atomic(&path, now() + 10).unwrap();
|
||||||
|
|
||||||
|
let (loaded, warning) = DeadRepoBlacklist::load(&path);
|
||||||
|
assert!(warning.is_none());
|
||||||
|
assert!(loaded.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
assert!(!loaded.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI));
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
let entry = loaded
|
||||||
|
.entries_sorted()
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.uri == RRDP_URI)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entry.consecutive_failures, 3);
|
||||||
|
assert!(entry.blacklisted);
|
||||||
|
assert_eq!(entry.first_failure_at_unix, now());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_file_is_empty_without_warning() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"dead_repo_blacklist_test_missing_{}_{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
now()
|
||||||
|
));
|
||||||
|
let (bl, warning) = DeadRepoBlacklist::load(&path);
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
assert!(warning.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_corrupted_file_degrades_to_empty_with_warning() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"dead_repo_blacklist_test_corrupt_{}_{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
now()
|
||||||
|
));
|
||||||
|
std::fs::write(&path, b"{not json").unwrap();
|
||||||
|
let (bl, warning) = DeadRepoBlacklist::load(&path);
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
assert!(warning.is_some());
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_wrong_schema_version_degrades_to_empty() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"dead_repo_blacklist_test_schema_{}_{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
now()
|
||||||
|
));
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"schema_version": 99, "updated_at_unix": 0, "entries": []}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (bl, warning) = DeadRepoBlacklist::load(&path);
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
assert!(warning.unwrap().contains("schema_version"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_due_uses_exponential_backoff() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
// Never probed: due immediately.
|
||||||
|
let due = bl.probe_due_entries(now() + 100, 600);
|
||||||
|
assert_eq!(due.len(), 1);
|
||||||
|
|
||||||
|
// Probe failure: not due until base interval has passed.
|
||||||
|
bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 100);
|
||||||
|
assert!(bl.probe_due_entries(now() + 100 + 599, 600).is_empty());
|
||||||
|
assert_eq!(bl.probe_due_entries(now() + 100 + 600, 600).len(), 1);
|
||||||
|
|
||||||
|
// Second probe failure: backoff doubles to 1200s.
|
||||||
|
bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 700);
|
||||||
|
assert!(bl.probe_due_entries(now() + 700 + 1199, 600).is_empty());
|
||||||
|
assert_eq!(bl.probe_due_entries(now() + 700 + 1200, 600).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_success_removes_entry() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
assert!(bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI));
|
||||||
|
assert!(bl.is_empty());
|
||||||
|
assert!(!bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counting_entries_are_not_probed() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256);
|
||||||
|
assert!(bl.probe_due_entries(now() + 10_000, 600).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_backoff_caps_at_max_shift() {
|
||||||
|
assert_eq!(probe_backoff_secs(600, 0), 600);
|
||||||
|
assert_eq!(probe_backoff_secs(600, 1), 600);
|
||||||
|
assert_eq!(probe_backoff_secs(600, 2), 1200);
|
||||||
|
assert_eq!(probe_backoff_secs(600, 5), 9600);
|
||||||
|
assert_eq!(probe_backoff_secs(600, 100), 9600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_fetch_error_classification() {
|
||||||
|
use crate::parallel::types::RepoTransportErrorClass;
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_fetch_error("http request failed: reqwest::Error { kind: Connect, .. }"),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_fetch_error("http read body failed: connection closed; status=200"),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_fetch_error("http status 404 Not Found; content_type=; .."),
|
||||||
|
RepoTransportErrorClass::Protocol
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_http_fetch_error("something unexpected"),
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsync_fetch_error_classification() {
|
||||||
|
use crate::parallel::types::RepoTransportErrorClass;
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error("rsync error: timeout waiting for daemon connection"),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error("rsync: failed to connect to host: Connection refused"),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error("temporary failure in name resolution"),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
// Content-level failures must not count.
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error("rsync file digest mismatch after download"),
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
);
|
||||||
|
// Stall failures (#141 M9): fail-fast give-up means the host delivered no
|
||||||
|
// bytes inside the wall-clock window — transport death, must count.
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error(
|
||||||
|
"rsync fail-fast gave up after 1 attempts with no progress: rsync wall-clock window expired"
|
||||||
|
),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error(
|
||||||
|
"rsync fail-fast gave up after 3 attempts with no additional progress: rsync wall-clock window expired"
|
||||||
|
),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
// Content-level give-up text without the fail-fast marker stays unknown.
|
||||||
|
assert_eq!(
|
||||||
|
classify_rsync_fetch_error("rsync error: some files vanished before transfer"),
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrdp_sync_error_classification() {
|
||||||
|
use crate::parallel::types::RepoTransportErrorClass;
|
||||||
|
use crate::sync::rrdp::RrdpSyncError;
|
||||||
|
assert_eq!(
|
||||||
|
classify_rrdp_sync_error(&RrdpSyncError::Fetch(
|
||||||
|
"http request failed: connect timeout".to_string()
|
||||||
|
)),
|
||||||
|
RepoTransportErrorClass::TransportFetch
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_rrdp_sync_error(&RrdpSyncError::Fetch("http status 500".to_string())),
|
||||||
|
RepoTransportErrorClass::Protocol
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_rrdp_sync_error(&RrdpSyncError::Storage("db full".to_string())),
|
||||||
|
RepoTransportErrorClass::Storage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readmission_after_probe_removal_starts_fresh() {
|
||||||
|
let mut bl = DeadRepoBlacklist::new();
|
||||||
|
for i in 0..3 {
|
||||||
|
bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256);
|
||||||
|
}
|
||||||
|
assert!(bl.record_probe_success(RepoTransportMode::Rrdp, RRDP_URI));
|
||||||
|
// No half-open: needs the full threshold again.
|
||||||
|
let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 10, 3, 256);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
DeadRepoFailureOutcome::Counted {
|
||||||
|
consecutive_failures: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod dead_repo_blacklist;
|
||||||
pub mod object_worker;
|
pub mod object_worker;
|
||||||
pub mod phase2_scheduler;
|
pub mod phase2_scheduler;
|
||||||
pub mod repo_runtime;
|
pub mod repo_runtime;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle, Scope};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub trait ObjectTaskExecutor<T, R>: Send + Sync + 'static {
|
/// Executor shared by every worker of a pool. The `'static` bound required to
|
||||||
|
/// move the executor into detached threads is expressed on the pool types
|
||||||
|
/// instead of this trait so scoped pools can borrow their environment.
|
||||||
|
pub trait ObjectTaskExecutor<T, R>: Send + Sync {
|
||||||
fn execute(&self, worker_index: usize, task: T) -> R;
|
fn execute(&self, worker_index: usize, task: T) -> R;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ pub struct ObjectWorkerPool<T, R, E>
|
|||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
R: Send + 'static,
|
R: Send + 'static,
|
||||||
E: ObjectTaskExecutor<T, R>,
|
E: ObjectTaskExecutor<T, R> + 'static,
|
||||||
{
|
{
|
||||||
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
|
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
|
||||||
result_rx: Receiver<R>,
|
result_rx: Receiver<R>,
|
||||||
@@ -35,7 +39,7 @@ impl<T, R, E> ObjectWorkerPool<T, R, E>
|
|||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
R: Send + 'static,
|
R: Send + 'static,
|
||||||
E: ObjectTaskExecutor<T, R>,
|
E: ObjectTaskExecutor<T, R> + 'static,
|
||||||
{
|
{
|
||||||
pub fn new(worker_count: usize, queue_capacity: usize, executor: E) -> Result<Self, String> {
|
pub fn new(worker_count: usize, queue_capacity: usize, executor: E) -> Result<Self, String> {
|
||||||
if worker_count == 0 {
|
if worker_count == 0 {
|
||||||
@@ -140,7 +144,7 @@ impl<T, R, E> Drop for ObjectWorkerPool<T, R, E>
|
|||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
R: Send + 'static,
|
R: Send + 'static,
|
||||||
E: ObjectTaskExecutor<T, R>,
|
E: ObjectTaskExecutor<T, R> + 'static,
|
||||||
{
|
{
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.shutdown_inner();
|
let _ = self.shutdown_inner();
|
||||||
@@ -153,8 +157,8 @@ fn object_worker_loop<T, R, E>(
|
|||||||
result_tx: mpsc::Sender<R>,
|
result_tx: mpsc::Sender<R>,
|
||||||
executor: Arc<E>,
|
executor: Arc<E>,
|
||||||
) where
|
) where
|
||||||
T: Send + 'static,
|
T: Send,
|
||||||
R: Send + 'static,
|
R: Send,
|
||||||
E: ObjectTaskExecutor<T, R>,
|
E: ObjectTaskExecutor<T, R>,
|
||||||
{
|
{
|
||||||
loop {
|
loop {
|
||||||
@@ -170,6 +174,120 @@ fn object_worker_loop<T, R, E>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scoped variant of `ObjectWorkerPool`: workers are spawned on a
|
||||||
|
/// `std::thread::Scope`, so tasks, results and the executor may borrow their
|
||||||
|
/// environment (`'env`) instead of being `'static`. The pool never sends
|
||||||
|
/// `Shutdown`; workers exit when every task sender is dropped, which happens
|
||||||
|
/// when the pool itself is dropped ahead of the scope join.
|
||||||
|
pub struct ScopedObjectWorkerPool<'scope, 'env, T, R, E>
|
||||||
|
where
|
||||||
|
T: Send + 'env,
|
||||||
|
R: Send + 'env,
|
||||||
|
E: ObjectTaskExecutor<T, R> + 'env,
|
||||||
|
{
|
||||||
|
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
|
||||||
|
result_rx: Receiver<R>,
|
||||||
|
next_worker_idx: usize,
|
||||||
|
_executor: Arc<E>,
|
||||||
|
// Join handles are intentionally not stored: dropping a `ScopedJoinHandle`
|
||||||
|
// detaches the worker and the enclosing scope joins it on exit, after the
|
||||||
|
// dropped task senders have made every worker return.
|
||||||
|
_marker: PhantomData<(&'scope (), &'env ())>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'scope, 'env, T, R, E> ScopedObjectWorkerPool<'scope, 'env, T, R, E>
|
||||||
|
where
|
||||||
|
T: Send + 'env,
|
||||||
|
R: Send + 'env,
|
||||||
|
E: ObjectTaskExecutor<T, R> + 'env,
|
||||||
|
{
|
||||||
|
pub fn new(
|
||||||
|
scope: &'scope Scope<'scope, 'env>,
|
||||||
|
worker_count: usize,
|
||||||
|
queue_capacity: usize,
|
||||||
|
executor: E,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
if worker_count == 0 {
|
||||||
|
return Err("ScopedObjectWorkerPool requires at least one worker".to_string());
|
||||||
|
}
|
||||||
|
if queue_capacity == 0 {
|
||||||
|
return Err("ScopedObjectWorkerPool requires queue_capacity > 0".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let executor = Arc::new(executor);
|
||||||
|
let (result_tx, result_rx) = mpsc::channel::<R>();
|
||||||
|
let mut task_txs = Vec::with_capacity(worker_count);
|
||||||
|
|
||||||
|
for worker_index in 0..worker_count {
|
||||||
|
let (task_tx, task_rx) = mpsc::sync_channel::<ObjectWorkerMessage<T>>(queue_capacity);
|
||||||
|
let result_tx = result_tx.clone();
|
||||||
|
let executor = Arc::clone(&executor);
|
||||||
|
thread::Builder::new()
|
||||||
|
.name(format!("object-validation-worker-{worker_index}"))
|
||||||
|
.spawn_scoped(scope, move || {
|
||||||
|
object_worker_loop(worker_index, task_rx, result_tx, executor)
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("spawn scoped object worker failed: {e}"))?;
|
||||||
|
task_txs.push(task_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
task_txs,
|
||||||
|
result_rx,
|
||||||
|
next_worker_idx: 0,
|
||||||
|
_executor: executor,
|
||||||
|
_marker: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn worker_count(&self) -> usize {
|
||||||
|
self.task_txs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_submit_round_robin(&mut self, task: T) -> Result<usize, ObjectWorkerSubmitError<T>> {
|
||||||
|
let worker_index = self.next_worker_idx % self.task_txs.len();
|
||||||
|
match self.task_txs[worker_index].try_send(ObjectWorkerMessage::Task(task)) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.next_worker_idx = (worker_index + 1) % self.task_txs.len();
|
||||||
|
Ok(worker_index)
|
||||||
|
}
|
||||||
|
Err(TrySendError::Full(ObjectWorkerMessage::Task(task))) => {
|
||||||
|
Err(ObjectWorkerSubmitError::QueueFull { worker_index, task })
|
||||||
|
}
|
||||||
|
Err(TrySendError::Disconnected(ObjectWorkerMessage::Task(task))) => {
|
||||||
|
Err(ObjectWorkerSubmitError::Disconnected { worker_index, task })
|
||||||
|
}
|
||||||
|
Err(TrySendError::Full(ObjectWorkerMessage::Shutdown))
|
||||||
|
| Err(TrySendError::Disconnected(ObjectWorkerMessage::Shutdown)) => {
|
||||||
|
unreachable!("shutdown is never submitted via try_submit_round_robin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recv_result_timeout(&self, timeout: Duration) -> Result<Option<R>, String> {
|
||||||
|
match self.result_rx.recv_timeout(timeout) {
|
||||||
|
Ok(result) => Ok(Some(result)),
|
||||||
|
Err(RecvTimeoutError::Timeout) => Ok(None),
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
Err("scoped object worker result channel disconnected".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'scope, 'env, T, R, E> Drop for ScopedObjectWorkerPool<'scope, 'env, T, R, E>
|
||||||
|
where
|
||||||
|
T: Send + 'env,
|
||||||
|
R: Send + 'env,
|
||||||
|
E: ObjectTaskExecutor<T, R> + 'env,
|
||||||
|
{
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Close every worker input queue so blocked `recv` calls return and
|
||||||
|
// the scoped workers exit before the enclosing scope joins them.
|
||||||
|
self.task_txs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError};
|
use super::{ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError};
|
||||||
@@ -284,4 +402,84 @@ mod tests {
|
|||||||
Some(2)
|
Some(2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BorrowingEchoExecutor<'a> {
|
||||||
|
base: &'a u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ObjectTaskExecutor<u32, u32> for BorrowingEchoExecutor<'a> {
|
||||||
|
fn execute(&self, _worker_index: usize, task: u32) -> u32 {
|
||||||
|
task + *self.base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scoped_object_worker_pool_borrows_environment_and_processes_tasks() {
|
||||||
|
let base = 100u32;
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let mut pool = super::ScopedObjectWorkerPool::new(
|
||||||
|
scope,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
BorrowingEchoExecutor { base: &base },
|
||||||
|
)
|
||||||
|
.expect("scoped pool");
|
||||||
|
assert_eq!(pool.worker_count(), 2);
|
||||||
|
pool.try_submit_round_robin(1).expect("submit 1");
|
||||||
|
pool.try_submit_round_robin(2).expect("submit 2");
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
results.push(
|
||||||
|
pool.recv_result_timeout(Duration::from_secs(1))
|
||||||
|
.expect("result channel")
|
||||||
|
.expect("result"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.sort();
|
||||||
|
assert_eq!(results, vec![101, 102]);
|
||||||
|
// Dropping the pool inside the scope closes the task queues; the
|
||||||
|
// workers exit on their own and the scope join below must not hang.
|
||||||
|
drop(pool);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scoped_object_worker_pool_reports_full_queue() {
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
let started = Arc::new(AtomicBool::new(false));
|
||||||
|
let mut pool = super::ScopedObjectWorkerPool::new(
|
||||||
|
scope,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
BlockingExecutor {
|
||||||
|
barrier: Arc::clone(&barrier),
|
||||||
|
started: Arc::clone(&started),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("scoped pool");
|
||||||
|
pool.try_submit_round_robin(1).expect("first task");
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(1);
|
||||||
|
while !started.load(Ordering::SeqCst) {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"scoped worker did not start first task"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
pool.try_submit_round_robin(2).expect("queued task");
|
||||||
|
match pool.try_submit_round_robin(3) {
|
||||||
|
Err(ObjectWorkerSubmitError::QueueFull { worker_index, task }) => {
|
||||||
|
assert_eq!(worker_index, 0);
|
||||||
|
assert_eq!(task, 3);
|
||||||
|
}
|
||||||
|
other => panic!("expected queue full, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Dropping the pool closes the task queues; releasing the barrier
|
||||||
|
// afterwards lets the blocked worker finish task 1, observe the
|
||||||
|
// closed result channel, and exit before the scope join.
|
||||||
|
drop(pool);
|
||||||
|
barrier.wait();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,17 @@ pub trait RepoSyncRuntime: Send + Sync {
|
|||||||
) -> Result<TransportPrefetchDispatchStats, String>;
|
) -> Result<TransportPrefetchDispatchStats, String>;
|
||||||
|
|
||||||
fn transport_prefetch_snapshot(&self) -> TransportPrefetchSnapshot;
|
fn transport_prefetch_snapshot(&self) -> TransportPrefetchSnapshot;
|
||||||
|
|
||||||
|
/// Dead-repo blacklist working copy (#141) for run-end persistence.
|
||||||
|
/// `None` when the feature is disabled.
|
||||||
|
fn dead_repo_blacklist_state(
|
||||||
|
&self,
|
||||||
|
) -> Option<(
|
||||||
|
crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig,
|
||||||
|
crate::parallel::dead_repo_blacklist::DeadRepoBlacklist,
|
||||||
|
)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Phase1RepoSyncRuntime<E: RepoTransportExecutor> {
|
pub struct Phase1RepoSyncRuntime<E: RepoTransportExecutor> {
|
||||||
@@ -634,6 +645,18 @@ impl<E: RepoTransportExecutor> RepoSyncRuntime for Phase1RepoSyncRuntime<E> {
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| TransportPrefetchSnapshot::new(self.sync_preference, Vec::new()))
|
.unwrap_or_else(|| TransportPrefetchSnapshot::new(self.sync_preference, Vec::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dead_repo_blacklist_state(
|
||||||
|
&self,
|
||||||
|
) -> Option<(
|
||||||
|
crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig,
|
||||||
|
crate::parallel::dead_repo_blacklist::DeadRepoBlacklist,
|
||||||
|
)> {
|
||||||
|
let coordinator = self.coordinator.lock().expect("coordinator lock poisoned");
|
||||||
|
let config = coordinator.config.dead_repo_blacklist.clone()?;
|
||||||
|
let blacklist = coordinator.dead_repo_blacklist().cloned()?;
|
||||||
|
Some((config, blacklist))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn outcome_from_transport_result(
|
fn outcome_from_transport_result(
|
||||||
@@ -666,7 +689,9 @@ fn outcome_from_transport_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
RepoTransportResultKind::Failed { detail, warnings },
|
RepoTransportResultKind::Failed {
|
||||||
|
detail, warnings, ..
|
||||||
|
},
|
||||||
RepoRuntimeState::FailedTerminal,
|
RepoRuntimeState::FailedTerminal,
|
||||||
) => RepoSyncRuntimeOutcome {
|
) => RepoSyncRuntimeOutcome {
|
||||||
repo_sync_ok: false,
|
repo_sync_ok: false,
|
||||||
@@ -798,6 +823,7 @@ mod tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rrdp failed".to_string(),
|
detail: "rrdp failed".to_string(),
|
||||||
warnings: vec![Warning::new("rrdp failed")],
|
warnings: vec![Warning::new("rrdp failed")],
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,6 +868,7 @@ mod tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rrdp failed".to_string(),
|
detail: "rrdp failed".to_string(),
|
||||||
warnings: vec![Warning::new("rrdp failed")],
|
warnings: vec![Warning::new("rrdp failed")],
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,6 +885,7 @@ mod tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rsync failed".to_string(),
|
detail: "rsync failed".to_string(),
|
||||||
warnings: vec![Warning::new("rsync failed")],
|
warnings: vec![Warning::new("rsync failed")],
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
|
||||||
use crate::parallel::types::{
|
use crate::parallel::types::{
|
||||||
InFlightRepoEntry, RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoRuntimeState,
|
InFlightRepoEntry, RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoRuntimeState,
|
||||||
RepoSyncResultEnvelope, RepoSyncResultKind, RepoSyncResultRef, RepoSyncTask, RepoTaskState,
|
RepoSyncResultEnvelope, RepoSyncResultKind, RepoSyncResultRef, RepoSyncTask, RepoTaskState,
|
||||||
RepoTransportMode, RepoTransportResultEnvelope, RepoTransportResultKind, RepoTransportTask,
|
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
|
||||||
|
RepoTransportResultKind, RepoTransportTask,
|
||||||
};
|
};
|
||||||
use crate::policy::SyncPreference;
|
use crate::policy::SyncPreference;
|
||||||
|
use crate::report::Warning;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum RepoRequestAction {
|
pub enum RepoRequestAction {
|
||||||
@@ -74,6 +77,9 @@ pub struct TransportStateTables {
|
|||||||
rsync_failure_probe_inflight: HashMap<String, String>,
|
rsync_failure_probe_inflight: HashMap<String, String>,
|
||||||
rsync_failure_scope_reachable: HashSet<String>,
|
rsync_failure_scope_reachable: HashSet<String>,
|
||||||
runtime_records: HashMap<RepoIdentity, RepoRuntimeRecord>,
|
runtime_records: HashMap<RepoIdentity, RepoRuntimeRecord>,
|
||||||
|
/// Frozen run-start snapshot of the dead-repo blacklist (#141). Entries
|
||||||
|
/// admitted during this run only take effect from the next run.
|
||||||
|
dead_repo_blacklist: Option<DeadRepoBlacklist>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportStateTables {
|
impl TransportStateTables {
|
||||||
@@ -81,6 +87,44 @@ impl TransportStateTables {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_dead_repo_blacklist(&mut self, blacklist: DeadRepoBlacklist) {
|
||||||
|
self.dead_repo_blacklist = Some(blacklist);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dead_repo_terminal_envelope(
|
||||||
|
identity: &RepoIdentity,
|
||||||
|
requesters: &[RepoRequester],
|
||||||
|
rsync_scope_uri: &str,
|
||||||
|
rsync_failure_scope_uri: Option<String>,
|
||||||
|
) -> RepoTransportResultEnvelope {
|
||||||
|
let first_requester = requesters
|
||||||
|
.first()
|
||||||
|
.expect("blacklist terminal record must keep at least one requester");
|
||||||
|
RepoTransportResultEnvelope {
|
||||||
|
dedup_key: RepoDedupKey::RsyncScope {
|
||||||
|
rsync_scope_uri: rsync_scope_uri.to_string(),
|
||||||
|
},
|
||||||
|
rsync_failure_scope_uri,
|
||||||
|
repo_identity: identity.clone(),
|
||||||
|
mode: RepoTransportMode::Rsync,
|
||||||
|
tal_id: first_requester.tal_id.clone(),
|
||||||
|
rir_id: first_requester.rir_id.clone(),
|
||||||
|
timing_ms: 0,
|
||||||
|
result: RepoTransportResultKind::Failed {
|
||||||
|
detail: format!(
|
||||||
|
"dead repo blacklist: rsync transport persistently unreachable: {}",
|
||||||
|
identity.rsync_base_uri
|
||||||
|
),
|
||||||
|
warnings: vec![Warning::new(format!(
|
||||||
|
"dead_repo_blacklist_skip_all: repository {} skipped after repeated transport failures",
|
||||||
|
identity.rsync_base_uri
|
||||||
|
))
|
||||||
|
.with_context(identity.rsync_base_uri.clone())],
|
||||||
|
error_class: RepoTransportErrorClass::Unknown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn runtime_record(&self, identity: &RepoIdentity) -> Option<&RepoRuntimeRecord> {
|
pub fn runtime_record(&self, identity: &RepoIdentity) -> Option<&RepoRuntimeRecord> {
|
||||||
self.runtime_records.get(identity)
|
self.runtime_records.get(identity)
|
||||||
}
|
}
|
||||||
@@ -205,6 +249,82 @@ impl TransportStateTables {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dead-repo blacklist fast paths (#141), consulted only for fresh
|
||||||
|
// identities; existing records already encode their terminal state.
|
||||||
|
if let Some(blacklist) = self.dead_repo_blacklist.as_ref() {
|
||||||
|
let rrdp_wanted = sync_preference == SyncPreference::RrdpThenRsync
|
||||||
|
&& identity.notification_uri.is_some();
|
||||||
|
let rrdp_dead = rrdp_wanted
|
||||||
|
&& identity
|
||||||
|
.notification_uri
|
||||||
|
.as_deref()
|
||||||
|
.map(|uri| blacklist.is_blacklisted(RepoTransportMode::Rrdp, uri))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let rsync_dead = blacklist.is_blacklisted(
|
||||||
|
RepoTransportMode::Rsync,
|
||||||
|
identity.rsync_base_uri.as_str(),
|
||||||
|
);
|
||||||
|
if rsync_dead && (!rrdp_wanted || rrdp_dead) {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_skip_all",
|
||||||
|
serde_json::json!({
|
||||||
|
"repo_key_notification_uri": identity.notification_uri,
|
||||||
|
"repo_key_rsync_base_uri": identity.rsync_base_uri,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let envelope = Self::dead_repo_terminal_envelope(
|
||||||
|
&identity,
|
||||||
|
std::slice::from_ref(&requester),
|
||||||
|
&rsync_scope_uri,
|
||||||
|
rsync_failure_scope_uri.clone(),
|
||||||
|
);
|
||||||
|
self.runtime_records.insert(
|
||||||
|
identity.clone(),
|
||||||
|
RepoRuntimeRecord {
|
||||||
|
identity,
|
||||||
|
state: RepoRuntimeState::FailedTerminal,
|
||||||
|
rrdp_notification_key: None,
|
||||||
|
rsync_scope_key: rsync_scope_uri,
|
||||||
|
rsync_failure_scope_key: rsync_failure_scope_uri,
|
||||||
|
requesters: vec![requester],
|
||||||
|
validation_time,
|
||||||
|
priority,
|
||||||
|
last_success: None,
|
||||||
|
terminal_failure: Some(envelope.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return TransportRequestAction::ReusedTerminalFailure(envelope);
|
||||||
|
}
|
||||||
|
if rrdp_dead {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_skip_rrdp",
|
||||||
|
serde_json::json!({
|
||||||
|
"repo_key_notification_uri": identity.notification_uri,
|
||||||
|
"repo_key_rsync_base_uri": identity.rsync_base_uri,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut action = self.register_rsync_request(
|
||||||
|
identity,
|
||||||
|
requester,
|
||||||
|
validation_time,
|
||||||
|
priority,
|
||||||
|
rsync_scope_uri,
|
||||||
|
rsync_failure_scope_uri,
|
||||||
|
);
|
||||||
|
// A blacklisted rrdp transport is a known-persistent failure,
|
||||||
|
// so the rsync fallback must keep the short retry profile
|
||||||
|
// (#141): the prefetch layer records no real rrdp failure in
|
||||||
|
// skipped runs, which would otherwise silently drop the task
|
||||||
|
// back to the default 15s window from the second skipped run
|
||||||
|
// on. The coordinator wrapper only ever sets the flag to true,
|
||||||
|
// so setting it here survives registration.
|
||||||
|
if let TransportRequestAction::Enqueue(task) = &mut action {
|
||||||
|
task.retry_short_timeout = true;
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if sync_preference == SyncPreference::RrdpThenRsync {
|
if sync_preference == SyncPreference::RrdpThenRsync {
|
||||||
if let Some(notification_uri) = identity.notification_uri.clone() {
|
if let Some(notification_uri) = identity.notification_uri.clone() {
|
||||||
if let Some(entry) = self.rrdp_inflight.get_mut(¬ification_uri) {
|
if let Some(entry) = self.rrdp_inflight.get_mut(¬ification_uri) {
|
||||||
@@ -512,6 +632,7 @@ impl TransportStateTables {
|
|||||||
rsync_failure_by_scope: &HashMap<String, RepoTransportResultEnvelope>,
|
rsync_failure_by_scope: &HashMap<String, RepoTransportResultEnvelope>,
|
||||||
rsync_failure_probe_inflight: &mut HashMap<String, String>,
|
rsync_failure_probe_inflight: &mut HashMap<String, String>,
|
||||||
rsync_failure_scope_reachable: &HashSet<String>,
|
rsync_failure_scope_reachable: &HashSet<String>,
|
||||||
|
dead_repo_blacklist: Option<&DeadRepoBlacklist>,
|
||||||
follow_up_tasks: &mut Vec<RepoTransportTask>,
|
follow_up_tasks: &mut Vec<RepoTransportTask>,
|
||||||
) {
|
) {
|
||||||
let rsync_scope_uri = record.rsync_scope_key.clone();
|
let rsync_scope_uri = record.rsync_scope_key.clone();
|
||||||
@@ -548,6 +669,34 @@ impl TransportStateTables {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dead-repo blacklist (#141): the rsync transport of this repo is
|
||||||
|
// persistently unreachable; terminate instantly instead of enqueueing
|
||||||
|
// another doomed fetch (rrdp was already attempted or skipped).
|
||||||
|
if let Some(blacklist) = dead_repo_blacklist {
|
||||||
|
if blacklist.is_blacklisted(
|
||||||
|
RepoTransportMode::Rsync,
|
||||||
|
record.identity.rsync_base_uri.as_str(),
|
||||||
|
) {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_skip_all",
|
||||||
|
serde_json::json!({
|
||||||
|
"repo_key_notification_uri": record.identity.notification_uri,
|
||||||
|
"repo_key_rsync_base_uri": record.identity.rsync_base_uri,
|
||||||
|
"after_rrdp_failure": true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let envelope = Self::dead_repo_terminal_envelope(
|
||||||
|
&record.identity,
|
||||||
|
&record.requesters,
|
||||||
|
&rsync_scope_uri,
|
||||||
|
record.rsync_failure_scope_key.clone(),
|
||||||
|
);
|
||||||
|
record.state = RepoRuntimeState::FailedTerminal;
|
||||||
|
record.terminal_failure = Some(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let first_requester = record
|
let first_requester = record
|
||||||
.requesters
|
.requesters
|
||||||
.first()
|
.first()
|
||||||
@@ -666,6 +815,7 @@ impl TransportStateTables {
|
|||||||
&self.rsync_failure_by_scope,
|
&self.rsync_failure_by_scope,
|
||||||
&mut self.rsync_failure_probe_inflight,
|
&mut self.rsync_failure_probe_inflight,
|
||||||
&self.rsync_failure_scope_reachable,
|
&self.rsync_failure_scope_reachable,
|
||||||
|
self.dead_repo_blacklist.as_ref(),
|
||||||
&mut follow_up_tasks,
|
&mut follow_up_tasks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -718,6 +868,7 @@ impl TransportStateTables {
|
|||||||
&self.rsync_failure_by_scope,
|
&self.rsync_failure_by_scope,
|
||||||
&mut self.rsync_failure_probe_inflight,
|
&mut self.rsync_failure_probe_inflight,
|
||||||
&self.rsync_failure_scope_reachable,
|
&self.rsync_failure_scope_reachable,
|
||||||
|
self.dead_repo_blacklist.as_ref(),
|
||||||
&mut follow_up_tasks,
|
&mut follow_up_tasks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -783,6 +934,7 @@ impl TransportStateTables {
|
|||||||
&self.rsync_failure_by_scope,
|
&self.rsync_failure_by_scope,
|
||||||
&mut self.rsync_failure_probe_inflight,
|
&mut self.rsync_failure_probe_inflight,
|
||||||
&self.rsync_failure_scope_reachable,
|
&self.rsync_failure_scope_reachable,
|
||||||
|
self.dead_repo_blacklist.as_ref(),
|
||||||
&mut follow_up_tasks,
|
&mut follow_up_tasks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -814,7 +966,7 @@ fn reusable_rsync_failure_scope(result: &RepoTransportResultEnvelope) -> Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_host_level_rsync_failure(detail: &str) -> bool {
|
pub(crate) fn is_host_level_rsync_failure(detail: &str) -> bool {
|
||||||
let lower = detail.to_ascii_lowercase();
|
let lower = detail.to_ascii_lowercase();
|
||||||
lower.contains("timeout waiting for daemon connection")
|
lower.contains("timeout waiting for daemon connection")
|
||||||
|| lower.contains("failed to connect")
|
|| lower.contains("failed to connect")
|
||||||
@@ -1462,6 +1614,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rrdp timeout".to_string(),
|
detail: "rrdp timeout".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -1567,6 +1720,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rrdp timeout".to_string(),
|
detail: "rrdp timeout".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -1597,6 +1751,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rsync error: timeout waiting for daemon connection".to_string(),
|
detail: "rsync error: timeout waiting for daemon connection".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -1659,6 +1814,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rsync timeout".to_string(),
|
detail: "rsync timeout".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -1920,6 +2076,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rsync file digest mismatch after download".to_string(),
|
detail: "rsync file digest mismatch after download".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -1972,6 +2129,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "rsync error: failed to connect to daemon".to_string(),
|
detail: "rsync error: failed to connect to daemon".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
@@ -2031,6 +2189,7 @@ mod transport_tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "temporary failure in name resolution".to_string(),
|
detail: "temporary failure in name resolution".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
time::OffsetDateTime::UNIX_EPOCH,
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ impl<H: Fetcher + 'static> RepoTransportExecutor for LiveRrdpTransportExecutor<H
|
|||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(err) => RepoTransportResultEnvelope {
|
Err(err) => {
|
||||||
|
let error_class =
|
||||||
|
crate::parallel::dead_repo_blacklist::classify_rrdp_sync_error(&err);
|
||||||
|
RepoTransportResultEnvelope {
|
||||||
dedup_key: task.dedup_key,
|
dedup_key: task.dedup_key,
|
||||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||||
repo_identity: task.repo_identity,
|
repo_identity: task.repo_identity,
|
||||||
@@ -126,8 +129,10 @@ impl<H: Fetcher + 'static> RepoTransportExecutor for LiveRrdpTransportExecutor<H
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: err.to_string(),
|
detail: err.to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +211,10 @@ impl<R: RsyncFetcher + 'static> RepoTransportExecutor for LiveRsyncTransportExec
|
|||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(err) => RepoTransportResultEnvelope {
|
Err(err) => {
|
||||||
|
let error_class =
|
||||||
|
crate::parallel::dead_repo_blacklist::classify_rsync_repo_sync_error(&err);
|
||||||
|
RepoTransportResultEnvelope {
|
||||||
dedup_key: task.dedup_key,
|
dedup_key: task.dedup_key,
|
||||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||||
repo_identity: task.repo_identity,
|
repo_identity: task.repo_identity,
|
||||||
@@ -217,8 +225,10 @@ impl<R: RsyncFetcher + 'static> RepoTransportExecutor for LiveRsyncTransportExec
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: err.to_string(),
|
detail: err.to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,7 +1170,7 @@ mod tests {
|
|||||||
matches!(result.result, RepoTransportResultKind::Success { .. }),
|
matches!(result.result, RepoTransportResultKind::Success { .. }),
|
||||||
"{result:?}"
|
"{result:?}"
|
||||||
);
|
);
|
||||||
let index = current_repo_index.lock().expect("index lock");
|
let index = current_repo_index.read().expect("index read lock");
|
||||||
assert!(
|
assert!(
|
||||||
index
|
index
|
||||||
.get_by_uri("rsync://example.test/repo/a.roa")
|
.get_by_uri("rsync://example.test/repo/a.roa")
|
||||||
@@ -1217,7 +1227,7 @@ mod tests {
|
|||||||
result.result,
|
result.result,
|
||||||
RepoTransportResultKind::Success { .. }
|
RepoTransportResultKind::Success { .. }
|
||||||
));
|
));
|
||||||
let index = current_repo_index.lock().expect("index lock");
|
let index = current_repo_index.read().expect("index read lock");
|
||||||
assert!(
|
assert!(
|
||||||
index
|
index
|
||||||
.get_by_uri("rsync://example.test/repo/a.roa")
|
.get_by_uri("rsync://example.test/repo/a.roa")
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
use crate::current_repo_index::{CurrentRepoIndex, CurrentRepoIndexHandle};
|
use crate::current_repo_index::{CurrentRepoIndex, CurrentRepoIndexHandle};
|
||||||
use crate::parallel::config::ParallelPhase1Config;
|
use crate::parallel::config::ParallelPhase1Config;
|
||||||
|
use crate::parallel::dead_repo_blacklist::{
|
||||||
|
DeadRepoBlacklist, DeadRepoFailureOutcome, DeadRepoSuccessOutcome,
|
||||||
|
};
|
||||||
use crate::parallel::repo_scheduler::{
|
use crate::parallel::repo_scheduler::{
|
||||||
InFlightRepoTable, RepoCompletion, RepoRequestAction, TransportCompletion,
|
InFlightRepoTable, RepoCompletion, RepoRequestAction, TransportCompletion,
|
||||||
TransportRequestAction, TransportStateTables,
|
TransportRequestAction, TransportStateTables,
|
||||||
@@ -9,7 +12,8 @@ use crate::parallel::repo_scheduler::{
|
|||||||
use crate::parallel::stats::ParallelRunStats;
|
use crate::parallel::stats::ParallelRunStats;
|
||||||
use crate::parallel::types::{
|
use crate::parallel::types::{
|
||||||
RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoSyncResultEnvelope, RepoSyncTask,
|
RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoSyncResultEnvelope, RepoSyncTask,
|
||||||
RepoTransportResultEnvelope, RepoTransportTask, TalInputSpec,
|
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
|
||||||
|
RepoTransportResultKind, RepoTransportTask, TalInputSpec,
|
||||||
};
|
};
|
||||||
use crate::policy::SyncPreference;
|
use crate::policy::SyncPreference;
|
||||||
|
|
||||||
@@ -22,19 +26,42 @@ pub struct GlobalRunCoordinator {
|
|||||||
pub pending_repo_tasks: VecDeque<RepoSyncTask>,
|
pub pending_repo_tasks: VecDeque<RepoSyncTask>,
|
||||||
pub pending_transport_tasks: VecDeque<RepoTransportTask>,
|
pub pending_transport_tasks: VecDeque<RepoTransportTask>,
|
||||||
pub stats: ParallelRunStats,
|
pub stats: ParallelRunStats,
|
||||||
|
/// Mutable working copy of the dead-repo blacklist (#141): failure/success
|
||||||
|
/// counters update during the run and persist at run end. Skip decisions
|
||||||
|
/// inside `transport_tables` use the frozen run-start snapshot.
|
||||||
|
pub dead_repo_blacklist: Option<DeadRepoBlacklist>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GlobalRunCoordinator {
|
impl GlobalRunCoordinator {
|
||||||
pub fn new(config: ParallelPhase1Config, tal_inputs: Vec<TalInputSpec>) -> Self {
|
pub fn new(config: ParallelPhase1Config, tal_inputs: Vec<TalInputSpec>) -> Self {
|
||||||
|
let dead_repo_blacklist = config
|
||||||
|
.dead_repo_blacklist
|
||||||
|
.as_ref()
|
||||||
|
.map(|blacklist_config| {
|
||||||
|
let (blacklist, warning) = DeadRepoBlacklist::load(&blacklist_config.path);
|
||||||
|
if let Some(warning) = warning {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_load_warning",
|
||||||
|
serde_json::json!({ "warning": warning }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
blacklist
|
||||||
|
});
|
||||||
|
let mut transport_tables = TransportStateTables::new();
|
||||||
|
if let Some(blacklist) = dead_repo_blacklist.as_ref() {
|
||||||
|
// Frozen run-start snapshot for skip decisions.
|
||||||
|
transport_tables.set_dead_repo_blacklist(blacklist.clone());
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
tal_inputs,
|
tal_inputs,
|
||||||
current_repo_index: CurrentRepoIndex::shared(),
|
current_repo_index: CurrentRepoIndex::shared(),
|
||||||
in_flight_repos: InFlightRepoTable::new(),
|
in_flight_repos: InFlightRepoTable::new(),
|
||||||
transport_tables: TransportStateTables::new(),
|
transport_tables,
|
||||||
pending_repo_tasks: VecDeque::new(),
|
pending_repo_tasks: VecDeque::new(),
|
||||||
pending_transport_tasks: VecDeque::new(),
|
pending_transport_tasks: VecDeque::new(),
|
||||||
stats: ParallelRunStats::default(),
|
stats: ParallelRunStats::default(),
|
||||||
|
dead_repo_blacklist,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +220,92 @@ impl GlobalRunCoordinator {
|
|||||||
{
|
{
|
||||||
self.stats.repo_tasks_failed += 1;
|
self.stats.repo_tasks_failed += 1;
|
||||||
}
|
}
|
||||||
|
self.update_dead_repo_blacklist(&result, finished_at);
|
||||||
Ok(completion)
|
Ok(completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update dead-repo blacklist counters (#141) from a real worker result.
|
||||||
|
/// Runs on the single pump thread; synthesized terminal envelopes never
|
||||||
|
/// pass through here, so no double counting is possible.
|
||||||
|
fn update_dead_repo_blacklist(
|
||||||
|
&mut self,
|
||||||
|
result: &RepoTransportResultEnvelope,
|
||||||
|
finished_at: time::OffsetDateTime,
|
||||||
|
) {
|
||||||
|
let Some(blacklist_config) = self.config.dead_repo_blacklist.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(blacklist) = self.dead_repo_blacklist.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now_unix = finished_at.unix_timestamp().max(0) as u64;
|
||||||
|
let key: Option<(RepoTransportMode, String)> = match (&result.dedup_key, &result.result) {
|
||||||
|
(RepoDedupKey::RrdpNotify { notification_uri }, RepoTransportResultKind::Success { .. }) => {
|
||||||
|
Some((RepoTransportMode::Rrdp, notification_uri.clone()))
|
||||||
|
}
|
||||||
|
(RepoDedupKey::RsyncScope { .. }, RepoTransportResultKind::Success { .. }) => {
|
||||||
|
Some((RepoTransportMode::Rsync, result.repo_identity.rsync_base_uri.clone()))
|
||||||
|
}
|
||||||
|
(
|
||||||
|
RepoDedupKey::RrdpNotify { notification_uri },
|
||||||
|
RepoTransportResultKind::Failed {
|
||||||
|
error_class: RepoTransportErrorClass::TransportFetch,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => Some((RepoTransportMode::Rrdp, notification_uri.clone())),
|
||||||
|
(
|
||||||
|
RepoDedupKey::RsyncScope { .. },
|
||||||
|
RepoTransportResultKind::Failed {
|
||||||
|
error_class: RepoTransportErrorClass::TransportFetch,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => Some((RepoTransportMode::Rsync, result.repo_identity.rsync_base_uri.clone())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let Some((transport, uri)) = key else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match &result.result {
|
||||||
|
RepoTransportResultKind::Success { .. } => {
|
||||||
|
if let Some(outcome) = blacklist.record_transport_success(transport, &uri) {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_remove",
|
||||||
|
serde_json::json!({
|
||||||
|
"transport": transport.as_str(),
|
||||||
|
"uri": uri,
|
||||||
|
"reason": match outcome {
|
||||||
|
DeadRepoSuccessOutcome::CounterReset => "counter_reset",
|
||||||
|
DeadRepoSuccessOutcome::BlacklistRemoved => "fetch_success",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RepoTransportResultKind::Failed { .. } => {
|
||||||
|
if let DeadRepoFailureOutcome::Admitted = blacklist.record_transport_failure(
|
||||||
|
transport,
|
||||||
|
&uri,
|
||||||
|
now_unix,
|
||||||
|
blacklist_config.fail_threshold,
|
||||||
|
blacklist_config.capacity,
|
||||||
|
) {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_admit",
|
||||||
|
serde_json::json!({
|
||||||
|
"transport": transport.as_str(),
|
||||||
|
"uri": uri,
|
||||||
|
"fail_threshold": blacklist_config.fail_threshold,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dead_repo_blacklist(&self) -> Option<&DeadRepoBlacklist> {
|
||||||
|
self.dead_repo_blacklist.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn runtime_record(
|
pub fn runtime_record(
|
||||||
&self,
|
&self,
|
||||||
identity: &RepoIdentity,
|
identity: &RepoIdentity,
|
||||||
@@ -225,7 +335,7 @@ impl GlobalRunCoordinator {
|
|||||||
self.pending_repo_tasks.clear();
|
self.pending_repo_tasks.clear();
|
||||||
self.pending_transport_tasks.clear();
|
self.pending_transport_tasks.clear();
|
||||||
self.stats = ParallelRunStats::default();
|
self.stats = ParallelRunStats::default();
|
||||||
if let Ok(mut index) = self.current_repo_index.lock() {
|
if let Ok(mut index) = self.current_repo_index.write() {
|
||||||
index.clear();
|
index.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +432,7 @@ mod tests {
|
|||||||
assert_eq!(coordinator.pending_transport_tasks.len(), 1);
|
assert_eq!(coordinator.pending_transport_tasks.len(), 1);
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut index = coordinator.current_repo_index.lock().expect("index lock");
|
let mut index = coordinator.current_repo_index.write().expect("index write lock");
|
||||||
index
|
index
|
||||||
.apply_repository_view_entries(&[RepositoryViewEntry {
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
||||||
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||||
@@ -343,8 +453,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
coordinator
|
coordinator
|
||||||
.current_repo_index
|
.current_repo_index
|
||||||
.lock()
|
.read()
|
||||||
.expect("index lock")
|
.expect("index read lock")
|
||||||
.active_uri_count(),
|
.active_uri_count(),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
@@ -465,4 +575,288 @@ mod tests {
|
|||||||
assert_eq!(coordinator.stats.repo_tasks_total, 1);
|
assert_eq!(coordinator.stats.repo_tasks_total, 1);
|
||||||
assert_eq!(coordinator.stats.repo_tasks_reused, 1);
|
assert_eq!(coordinator.stats.repo_tasks_reused, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Dead-repo blacklist (#141) integration tests ----
|
||||||
|
|
||||||
|
use crate::parallel::dead_repo_blacklist::{DeadRepoBlacklist, DeadRepoBlacklistConfig};
|
||||||
|
use crate::parallel::types::{
|
||||||
|
RepoDedupKey, RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
|
||||||
|
RepoTransportResultKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEAD_NOTIFY: &str = "https://dead.example/notification.xml";
|
||||||
|
const DEAD_BASE: &str = "rsync://dead.example/repo/";
|
||||||
|
|
||||||
|
fn blacklist_config(fail_threshold: u32) -> DeadRepoBlacklistConfig {
|
||||||
|
DeadRepoBlacklistConfig {
|
||||||
|
path: std::env::temp_dir().join(format!(
|
||||||
|
"dead_repo_blacklist_coordinator_test_{}.json",
|
||||||
|
std::process::id()
|
||||||
|
)),
|
||||||
|
fail_threshold,
|
||||||
|
capacity: 256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coordinator_with_blacklist(
|
||||||
|
fail_threshold: u32,
|
||||||
|
seed: DeadRepoBlacklist,
|
||||||
|
) -> GlobalRunCoordinator {
|
||||||
|
let mut config = ParallelPhase1Config::default();
|
||||||
|
config.dead_repo_blacklist = Some(blacklist_config(fail_threshold));
|
||||||
|
let mut coordinator = GlobalRunCoordinator::new(
|
||||||
|
config,
|
||||||
|
vec![TalInputSpec::from_url("https://example.test/arin.tal")],
|
||||||
|
);
|
||||||
|
coordinator.dead_repo_blacklist = Some(seed.clone());
|
||||||
|
coordinator.transport_tables.set_dead_repo_blacklist(seed);
|
||||||
|
coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dead_identity() -> RepoIdentity {
|
||||||
|
RepoIdentity::new(Some(DEAD_NOTIFY.to_string()), DEAD_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dead_requester() -> RepoRequester {
|
||||||
|
requester("arin", "arin", "rsync://dead.example/repo/root.mft")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transport_envelope(
|
||||||
|
mode: RepoTransportMode,
|
||||||
|
error_class: RepoTransportErrorClass,
|
||||||
|
) -> RepoTransportResultEnvelope {
|
||||||
|
let (dedup_key, detail) = match mode {
|
||||||
|
RepoTransportMode::Rrdp => (
|
||||||
|
RepoDedupKey::RrdpNotify {
|
||||||
|
notification_uri: DEAD_NOTIFY.to_string(),
|
||||||
|
},
|
||||||
|
"http request failed: connect timeout".to_string(),
|
||||||
|
),
|
||||||
|
RepoTransportMode::Rsync => (
|
||||||
|
RepoDedupKey::RsyncScope {
|
||||||
|
rsync_scope_uri: DEAD_BASE.to_string(),
|
||||||
|
},
|
||||||
|
"rsync error: timeout waiting for daemon connection".to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
RepoTransportResultEnvelope {
|
||||||
|
dedup_key,
|
||||||
|
rsync_failure_scope_uri: None,
|
||||||
|
repo_identity: dead_identity(),
|
||||||
|
mode,
|
||||||
|
tal_id: "arin".to_string(),
|
||||||
|
rir_id: "arin".to_string(),
|
||||||
|
timing_ms: 1,
|
||||||
|
result: RepoTransportResultKind::Failed {
|
||||||
|
detail,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
error_class,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_dead(
|
||||||
|
coordinator: &mut GlobalRunCoordinator,
|
||||||
|
) -> crate::parallel::repo_scheduler::TransportRequestAction {
|
||||||
|
coordinator.register_transport_request(
|
||||||
|
dead_identity(),
|
||||||
|
dead_requester(),
|
||||||
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
0,
|
||||||
|
DEAD_BASE.to_string(),
|
||||||
|
None,
|
||||||
|
SyncPreference::RrdpThenRsync,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_admission_after_threshold_transport_failures() {
|
||||||
|
let mut coordinator = coordinator_with_blacklist(2, DeadRepoBlacklist::new());
|
||||||
|
for _ in 0..2 {
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
coordinator
|
||||||
|
.complete_transport_result(
|
||||||
|
transport_envelope(
|
||||||
|
RepoTransportMode::Rrdp,
|
||||||
|
RepoTransportErrorClass::TransportFetch,
|
||||||
|
),
|
||||||
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.expect("complete");
|
||||||
|
// Simulate the next run: run-local scheduler state resets, the
|
||||||
|
// persistent blacklist working copy carries over.
|
||||||
|
coordinator.reset_run_state();
|
||||||
|
}
|
||||||
|
let blacklist = coordinator.dead_repo_blacklist().expect("blacklist");
|
||||||
|
assert!(blacklist.is_blacklisted(RepoTransportMode::Rrdp, DEAD_NOTIFY));
|
||||||
|
// Admission mid-run does not affect this run's frozen snapshot: the
|
||||||
|
// scheduler still enqueues rrdp for a fresh identity registration.
|
||||||
|
coordinator.reset_run_state();
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_ignores_protocol_and_unknown_failures() {
|
||||||
|
let mut coordinator = coordinator_with_blacklist(1, DeadRepoBlacklist::new());
|
||||||
|
for error_class in [
|
||||||
|
RepoTransportErrorClass::Protocol,
|
||||||
|
RepoTransportErrorClass::Unknown,
|
||||||
|
RepoTransportErrorClass::Storage,
|
||||||
|
] {
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
coordinator
|
||||||
|
.complete_transport_result(
|
||||||
|
transport_envelope(RepoTransportMode::Rrdp, error_class),
|
||||||
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.expect("complete");
|
||||||
|
coordinator.reset_run_state();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
coordinator
|
||||||
|
.dead_repo_blacklist()
|
||||||
|
.expect("blacklist")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_skip_rrdp_routes_straight_to_rsync() {
|
||||||
|
let mut seed = DeadRepoBlacklist::new();
|
||||||
|
seed.record_transport_failure(RepoTransportMode::Rrdp, DEAD_NOTIFY, 100, 1, 256);
|
||||||
|
let mut coordinator = coordinator_with_blacklist(3, seed);
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
let task = match action {
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(task) => task,
|
||||||
|
other => panic!("expected rsync enqueue, got {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(task.mode, RepoTransportMode::Rsync);
|
||||||
|
// register_dead passes retry_short_timeout=false (mirroring a prefetch
|
||||||
|
// snapshot that recorded no real rrdp failure for skipped runs); the
|
||||||
|
// skip path must still keep the short retry profile (#141).
|
||||||
|
assert!(task.retry_short_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_dual_dead_terminates_immediately() {
|
||||||
|
let mut seed = DeadRepoBlacklist::new();
|
||||||
|
seed.record_transport_failure(RepoTransportMode::Rrdp, DEAD_NOTIFY, 100, 1, 256);
|
||||||
|
seed.record_transport_failure(RepoTransportMode::Rsync, DEAD_BASE, 100, 1, 256);
|
||||||
|
let mut coordinator = coordinator_with_blacklist(3, seed);
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
let envelope = match action {
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::ReusedTerminalFailure(
|
||||||
|
envelope,
|
||||||
|
) => envelope,
|
||||||
|
other => panic!("expected terminal failure, got {other:?}"),
|
||||||
|
};
|
||||||
|
match &envelope.result {
|
||||||
|
RepoTransportResultKind::Failed {
|
||||||
|
detail, warnings, ..
|
||||||
|
} => {
|
||||||
|
assert!(detail.contains("dead repo blacklist"));
|
||||||
|
assert!(warnings
|
||||||
|
.iter()
|
||||||
|
.any(|warning| warning.message.contains("dead_repo_blacklist_skip_all")));
|
||||||
|
}
|
||||||
|
other => panic!("expected failed result, got {other:?}"),
|
||||||
|
}
|
||||||
|
let record = coordinator
|
||||||
|
.runtime_record(&dead_identity())
|
||||||
|
.expect("record");
|
||||||
|
assert_eq!(
|
||||||
|
record.state,
|
||||||
|
crate::parallel::types::RepoRuntimeState::FailedTerminal
|
||||||
|
);
|
||||||
|
assert_eq!(coordinator.stats.repo_tasks_total, 0);
|
||||||
|
assert_eq!(coordinator.stats.repo_tasks_reused, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_rsync_dead_terminates_after_rrdp_failure() {
|
||||||
|
// Only rsync blacklisted: rrdp is attempted; when it fails, the rsync
|
||||||
|
// fallback is short-circuited into an instant terminal failure.
|
||||||
|
let mut seed = DeadRepoBlacklist::new();
|
||||||
|
seed.record_transport_failure(RepoTransportMode::Rsync, DEAD_BASE, 100, 1, 256);
|
||||||
|
let mut coordinator = coordinator_with_blacklist(3, seed);
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
let completion = coordinator
|
||||||
|
.complete_transport_result(
|
||||||
|
transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::TransportFetch),
|
||||||
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.expect("complete");
|
||||||
|
assert!(completion.follow_up_tasks.is_empty());
|
||||||
|
let record = coordinator
|
||||||
|
.runtime_record(&dead_identity())
|
||||||
|
.expect("record");
|
||||||
|
assert_eq!(
|
||||||
|
record.state,
|
||||||
|
crate::parallel::types::RepoRuntimeState::FailedTerminal
|
||||||
|
);
|
||||||
|
let terminal = record.terminal_failure.as_ref().expect("terminal failure");
|
||||||
|
match &terminal.result {
|
||||||
|
RepoTransportResultKind::Failed { detail, .. } => {
|
||||||
|
assert!(detail.contains("dead repo blacklist"));
|
||||||
|
}
|
||||||
|
other => panic!("expected failed result, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blacklist_success_resets_failure_counter() {
|
||||||
|
let mut coordinator = coordinator_with_blacklist(3, DeadRepoBlacklist::new());
|
||||||
|
// One transport failure starts counting.
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
coordinator
|
||||||
|
.complete_transport_result(
|
||||||
|
transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::TransportFetch),
|
||||||
|
time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.expect("complete");
|
||||||
|
assert_eq!(coordinator.dead_repo_blacklist().expect("blacklist").len(), 1);
|
||||||
|
|
||||||
|
// A later success clears the entry.
|
||||||
|
coordinator.reset_run_state();
|
||||||
|
let action = register_dead(&mut coordinator);
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
crate::parallel::repo_scheduler::TransportRequestAction::Enqueue(_)
|
||||||
|
));
|
||||||
|
let mut success = transport_envelope(RepoTransportMode::Rrdp, RepoTransportErrorClass::Unknown);
|
||||||
|
success.result = RepoTransportResultKind::Success {
|
||||||
|
source: "rrdp".to_string(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
coordinator
|
||||||
|
.complete_transport_result(success, time::OffsetDateTime::UNIX_EPOCH)
|
||||||
|
.expect("complete");
|
||||||
|
assert!(
|
||||||
|
coordinator
|
||||||
|
.dead_repo_blacklist()
|
||||||
|
.expect("blacklist")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ mod tests {
|
|||||||
result: crate::parallel::types::RepoTransportResultKind::Failed {
|
result: crate::parallel::types::RepoTransportResultKind::Failed {
|
||||||
detail: "timeout".to_string(),
|
detail: "timeout".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
recorder.record_result(&crate::parallel::types::RepoTransportResultEnvelope {
|
recorder.record_result(&crate::parallel::types::RepoTransportResultEnvelope {
|
||||||
@@ -499,6 +500,7 @@ mod tests {
|
|||||||
result: crate::parallel::types::RepoTransportResultKind::Failed {
|
result: crate::parallel::types::RepoTransportResultKind::Failed {
|
||||||
detail: "timeout".to_string(),
|
detail: "timeout".to_string(),
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
|
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::policy::SyncPreference;
|
use crate::policy::SyncPreference;
|
||||||
use crate::report::Warning;
|
use crate::report::Warning;
|
||||||
|
|
||||||
@@ -96,7 +98,8 @@ pub enum RepoDedupKey {
|
|||||||
RsyncScope { rsync_scope_uri: String },
|
RsyncScope { rsync_scope_uri: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RepoTransportMode {
|
pub enum RepoTransportMode {
|
||||||
Rrdp,
|
Rrdp,
|
||||||
Rsync,
|
Rsync,
|
||||||
@@ -116,6 +119,26 @@ pub struct RepoTransportTask {
|
|||||||
pub requesters: Vec<RepoRequester>,
|
pub requesters: Vec<RepoRequester>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepoTransportErrorClass {
|
||||||
|
/// Transport-layer fetch failure (connect timeout/refused/TLS/DNS/broken
|
||||||
|
/// connection). Only this class counts toward the dead-repo blacklist.
|
||||||
|
TransportFetch,
|
||||||
|
/// Protocol-level failure (HTTP status, XML/parse, content mismatch).
|
||||||
|
Protocol,
|
||||||
|
/// Local storage failure.
|
||||||
|
Storage,
|
||||||
|
/// Unclassified (synthesized or legacy results); never counted.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RepoTransportErrorClass {
|
||||||
|
fn default() -> Self {
|
||||||
|
RepoTransportErrorClass::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum RepoTransportResultKind {
|
pub enum RepoTransportResultKind {
|
||||||
Success {
|
Success {
|
||||||
@@ -125,6 +148,7 @@ pub enum RepoTransportResultKind {
|
|||||||
Failed {
|
Failed {
|
||||||
detail: String,
|
detail: String,
|
||||||
warnings: Vec<Warning>,
|
warnings: Vec<Warning>,
|
||||||
|
error_class: RepoTransportErrorClass,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +503,7 @@ mod tests {
|
|||||||
result: RepoTransportResultKind::Failed {
|
result: RepoTransportResultKind::Failed {
|
||||||
detail: "timeout".to_string(),
|
detail: "timeout".to_string(),
|
||||||
warnings: vec![Warning::new("timeout")],
|
warnings: vec![Warning::new("timeout")],
|
||||||
|
error_class: super::RepoTransportErrorClass::Unknown,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
assert!(matches!(ok.result, RepoTransportResultKind::Success { .. }));
|
assert!(matches!(ok.result, RepoTransportResultKind::Success { .. }));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -127,6 +128,11 @@ pub struct Policy {
|
|||||||
pub signed_object_failure_policy: SignedObjectFailurePolicy,
|
pub signed_object_failure_policy: SignedObjectFailurePolicy,
|
||||||
pub resource_validation_mode: ResourceValidationMode,
|
pub resource_validation_mode: ResourceValidationMode,
|
||||||
pub strict: StrictPolicy,
|
pub strict: StrictPolicy,
|
||||||
|
/// Ephemeral verification-only override. This is deliberately excluded
|
||||||
|
/// from policy files, contracts, and policy fingerprints: it only selects
|
||||||
|
/// the already contract-bound VCIR fallback branch while replaying a run.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub verification_forced_vcir_reuse_manifest_uris: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Policy {
|
impl Default for Policy {
|
||||||
@@ -137,6 +143,7 @@ impl Default for Policy {
|
|||||||
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
|
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
|
||||||
resource_validation_mode: ResourceValidationMode::default(),
|
resource_validation_mode: ResourceValidationMode::default(),
|
||||||
strict: StrictPolicy::default(),
|
strict: StrictPolicy::default(),
|
||||||
|
verification_forced_vcir_reuse_manifest_uris: BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,9 +176,14 @@ pub fn list_report_objects_filtered(
|
|||||||
start_pp_index,
|
start_pp_index,
|
||||||
start_object_index,
|
start_object_index,
|
||||||
);
|
);
|
||||||
ObjectScanSeed { state: &mut state }
|
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
|
||||||
.deserialize(&mut deserializer)
|
match scan {
|
||||||
.map_err(QueryDbError::from)?;
|
Ok(()) => {}
|
||||||
|
// A full page plus its next cursor is all the caller needs; the
|
||||||
|
// visitor aborts the remaining scan with a sentinel error.
|
||||||
|
Err(err) if is_object_scan_complete(&err) => {}
|
||||||
|
Err(err) => return Err(QueryDbError::from(err)),
|
||||||
|
}
|
||||||
Ok(QueryPage {
|
Ok(QueryPage {
|
||||||
data: state.data,
|
data: state.data,
|
||||||
next_cursor: state.next_cursor,
|
next_cursor: state.next_cursor,
|
||||||
@@ -215,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String {
|
|||||||
format!("r1:{pp_index}:{object_index}")
|
format!("r1:{pp_index}:{object_index}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sentinel message used to abort the streaming object scan early once a list
|
||||||
|
/// page (plus its next cursor) has been collected. The deserializer is left
|
||||||
|
/// mid-document and dropped, which is fine because the caller only needs the
|
||||||
|
/// state accumulated so far.
|
||||||
|
const OBJECT_SCAN_COMPLETE_MSG: &str = "rpki report object scan complete: page collected";
|
||||||
|
|
||||||
|
fn is_object_scan_complete(err: &serde_json::Error) -> bool {
|
||||||
|
err.is_data() && err.to_string().starts_with(OBJECT_SCAN_COMPLETE_MSG)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
|
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
|
||||||
let Some(cursor) = cursor else {
|
let Some(cursor) = cursor else {
|
||||||
return Ok((0, 0));
|
return Ok((0, 0));
|
||||||
@@ -598,15 +613,15 @@ impl<'de> Deserialize<'de> for ObjectStatsSeq {
|
|||||||
stats.count += 1;
|
stats.count += 1;
|
||||||
if object.result == "error" {
|
if object.result == "error" {
|
||||||
stats.rejected += 1;
|
stats.rejected += 1;
|
||||||
|
if let Some(detail) = object.detail.filter(|value| !value.is_empty()) {
|
||||||
|
bump_limited_count(&mut stats.reason_counts, &detail, 512);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
*stats
|
*stats
|
||||||
.object_type_counts
|
.object_type_counts
|
||||||
.entry(object.object_type)
|
.entry(object.object_type)
|
||||||
.or_default() += 1;
|
.or_default() += 1;
|
||||||
*stats.result_counts.entry(object.result).or_default() += 1;
|
*stats.result_counts.entry(object.result).or_default() += 1;
|
||||||
if let Some(detail) = object.detail.filter(|value| !value.is_empty()) {
|
|
||||||
bump_limited_count(&mut stats.reason_counts, &detail, 512);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(ObjectStatsSeq(stats))
|
Ok(ObjectStatsSeq(stats))
|
||||||
}
|
}
|
||||||
@@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
|
|||||||
scanned_objects: 0,
|
scanned_objects: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True once a list page is full and its next cursor is fixed; everything
|
||||||
|
/// after this point cannot alter the page (cursor semantics: the cursor
|
||||||
|
/// already points at the first item of the next page).
|
||||||
|
fn list_page_complete(&self) -> bool {
|
||||||
|
matches!(self.mode, ObjectScanMode::List)
|
||||||
|
&& self.data.len() >= self.limit
|
||||||
|
&& self.next_cursor.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
|
|||||||
A: SeqAccess<'de>,
|
A: SeqAccess<'de>,
|
||||||
{
|
{
|
||||||
let mut pp_index = 0u64;
|
let mut pp_index = 0u64;
|
||||||
while let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? {
|
loop {
|
||||||
|
if self.state.list_page_complete() {
|
||||||
|
return Err(de::Error::custom(OBJECT_SCAN_COMPLETE_MSG));
|
||||||
|
}
|
||||||
|
let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
process_raw_publication_point(self.state, pp_index, raw_pp.get())
|
process_raw_publication_point(self.state, pp_index, raw_pp.get())
|
||||||
.map_err(de::Error::custom)?;
|
.map_err(de::Error::custom)?;
|
||||||
pp_index += 1;
|
pp_index += 1;
|
||||||
@@ -822,6 +852,20 @@ fn process_raw_publication_point(
|
|||||||
raw_pp: &str,
|
raw_pp: &str,
|
||||||
) -> QueryDbResult<()> {
|
) -> QueryDbResult<()> {
|
||||||
state.scanned_publication_points += 1;
|
state.scanned_publication_points += 1;
|
||||||
|
if matches!(state.mode, ObjectScanMode::List) && pp_index < state.start_pp_index {
|
||||||
|
// Every object in this PP sits before the cursor, so none of them can
|
||||||
|
// be part of the page: skip materializing the subtree entirely.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if state.scope != ObjectScope::All {
|
||||||
|
// Scope is decidable from PP-level fields alone; probe them before
|
||||||
|
// materializing the (potentially huge) objects subtree.
|
||||||
|
let probe: PublicationPointScopeProbe = serde_json::from_str(raw_pp)?;
|
||||||
|
let ctx = PpObjectContext::from_pp(state.run_id, &probe.into_summary());
|
||||||
|
if !state.scope.matches(&ctx) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
|
let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
|
||||||
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
||||||
if !state.scope.matches(&ctx) {
|
if !state.scope.matches(&ctx) {
|
||||||
@@ -909,6 +953,30 @@ impl<'de> Visitor<'de> for PublicationPointForObjectsVisitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight probe of the PP-level fields needed to decide `ObjectScope`
|
||||||
|
/// before the (potentially huge) objects subtree is materialized.
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
struct PublicationPointScopeProbe {
|
||||||
|
node_id: Option<u64>,
|
||||||
|
rsync_base_uri: Option<String>,
|
||||||
|
manifest_rsync_uri: Option<String>,
|
||||||
|
publication_point_rsync_uri: Option<String>,
|
||||||
|
rrdp_notification_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PublicationPointScopeProbe {
|
||||||
|
fn into_summary(self) -> PublicationPointSummary {
|
||||||
|
PublicationPointSummary {
|
||||||
|
node_id: self.node_id,
|
||||||
|
rsync_base_uri: self.rsync_base_uri,
|
||||||
|
manifest_rsync_uri: self.manifest_rsync_uri,
|
||||||
|
publication_point_rsync_uri: self.publication_point_rsync_uri,
|
||||||
|
rrdp_notification_uri: self.rrdp_notification_uri,
|
||||||
|
..PublicationPointSummary::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
struct ReportObject {
|
struct ReportObject {
|
||||||
uri: String,
|
uri: String,
|
||||||
@@ -1307,7 +1375,7 @@ mod tests {
|
|||||||
"repo_terminal_state":"fresh",
|
"repo_terminal_state":"fresh",
|
||||||
"warnings":[],
|
"warnings":[],
|
||||||
"objects":[
|
"objects":[
|
||||||
{"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex":"11","kind":"manifest","result":"ok"},
|
{"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex":"11","kind":"manifest","result":"ok","detail":"restored child CA instance from publication-point cache"},
|
||||||
{"rsync_uri":"rsync://repo.example/rpki/a.roa","sha256_hex":"22","kind":"roa","result":"error","detail":"bad roa"}
|
{"rsync_uri":"rsync://repo.example/rpki/a.roa","sha256_hex":"22","kind":"roa","result":"error","detail":"bad roa"}
|
||||||
]
|
]
|
||||||
}],
|
}],
|
||||||
@@ -1333,6 +1401,13 @@ mod tests {
|
|||||||
assert_eq!(summary.aspas_count, 1);
|
assert_eq!(summary.aspas_count, 1);
|
||||||
assert_eq!(summary.stats.object_type_counts["roa"], 1);
|
assert_eq!(summary.stats.object_type_counts["roa"], 1);
|
||||||
assert_eq!(summary.stats.reason_counts["bad roa"], 1);
|
assert_eq!(summary.stats.reason_counts["bad roa"], 1);
|
||||||
|
// #133: informational detail on non-error objects must not be counted
|
||||||
|
// as reject reasons.
|
||||||
|
assert_eq!(summary.stats.reason_counts.len(), 1);
|
||||||
|
assert!(!summary
|
||||||
|
.stats
|
||||||
|
.reason_counts
|
||||||
|
.contains_key("restored child CA instance from publication-point cache"));
|
||||||
|
|
||||||
let page =
|
let page =
|
||||||
list_report_objects(&report_path, "run_0001", ObjectScope::All, 1, None).expect("page");
|
list_report_objects(&report_path, "run_0001", ObjectScope::All, 1, None).expect("page");
|
||||||
@@ -1360,4 +1435,195 @@ mod tests {
|
|||||||
assert_eq!(found.object.result, "error");
|
assert_eq!(found.object.result, "error");
|
||||||
assert_eq!(found.resolution.mode, "report_scan");
|
assert_eq!(found.resolution.mode, "report_scan");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three PPs with two objects each; used to anchor paging and scope
|
||||||
|
/// behavior of the early-stop object scan.
|
||||||
|
fn write_multi_pp_report(report_path: &std::path::Path) {
|
||||||
|
fs::write(
|
||||||
|
report_path,
|
||||||
|
r#"{
|
||||||
|
"meta":{"validation_time_rfc3339_utc":"2026-06-16T00:00:00Z"},
|
||||||
|
"tree":{"warnings":[]},
|
||||||
|
"publication_points":[
|
||||||
|
{
|
||||||
|
"node_id":1,
|
||||||
|
"rsync_base_uri":"rsync://repo-a.example/rpki/",
|
||||||
|
"manifest_rsync_uri":"rsync://repo-a.example/rpki/m.mft",
|
||||||
|
"publication_point_rsync_uri":"rsync://repo-a.example/rpki/",
|
||||||
|
"rrdp_notification_uri":"https://repo-a.example/rrdp/notification.xml",
|
||||||
|
"objects":[
|
||||||
|
{"rsync_uri":"rsync://repo-a.example/rpki/m.mft","sha256_hex":"a1","kind":"manifest","result":"ok"},
|
||||||
|
{"rsync_uri":"rsync://repo-a.example/rpki/a.roa","sha256_hex":"a2","kind":"roa","result":"ok"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id":2,
|
||||||
|
"rsync_base_uri":"rsync://repo-b.example/rpki/",
|
||||||
|
"manifest_rsync_uri":"rsync://repo-b.example/rpki/m.mft",
|
||||||
|
"publication_point_rsync_uri":"rsync://repo-b.example/rpki/",
|
||||||
|
"objects":[
|
||||||
|
{"rsync_uri":"rsync://repo-b.example/rpki/m.mft","sha256_hex":"b1","kind":"manifest","result":"ok"},
|
||||||
|
{"rsync_uri":"rsync://repo-b.example/rpki/b.roa","sha256_hex":"b2","kind":"roa","result":"error","detail":"bad roa"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id":3,
|
||||||
|
"rsync_base_uri":"rsync://repo-c.example/rpki/",
|
||||||
|
"manifest_rsync_uri":"rsync://repo-c.example/rpki/m.mft",
|
||||||
|
"publication_point_rsync_uri":"rsync://repo-c.example/rpki/",
|
||||||
|
"rrdp_notification_uri":"https://repo-c.example/rrdp/notification.xml",
|
||||||
|
"objects":[
|
||||||
|
{"rsync_uri":"rsync://repo-c.example/rpki/m.mft","sha256_hex":"c1","kind":"manifest","result":"ok"},
|
||||||
|
{"rsync_uri":"rsync://repo-c.example/rpki/c.roa","sha256_hex":"c2","kind":"roa","result":"ok"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("report");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_listing_pages_across_publication_points_with_stable_cursors() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let report_path = temp.path().join("report.json");
|
||||||
|
write_multi_pp_report(&report_path);
|
||||||
|
|
||||||
|
// Page 1: fills inside PP 0; the cursor must resume at PP 0 object 2.
|
||||||
|
let page1 = list_report_objects(&report_path, "run_0001", ObjectScope::All, 2, None)
|
||||||
|
.expect("page1");
|
||||||
|
assert_eq!(page1.data.len(), 2);
|
||||||
|
assert_eq!(page1.data[0].uri, "rsync://repo-a.example/rpki/m.mft");
|
||||||
|
assert_eq!(page1.data[1].uri, "rsync://repo-a.example/rpki/a.roa");
|
||||||
|
assert_eq!(page1.next_cursor.as_deref(), Some("r1:0:2"));
|
||||||
|
|
||||||
|
// Page 2: PP 0 is exhausted; the two PP 1 objects fill the page.
|
||||||
|
let page2 = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::All,
|
||||||
|
2,
|
||||||
|
page1.next_cursor.as_deref(),
|
||||||
|
)
|
||||||
|
.expect("page2");
|
||||||
|
assert_eq!(page2.data.len(), 2);
|
||||||
|
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
|
||||||
|
assert_eq!(page2.data[1].uri, "rsync://repo-b.example/rpki/b.roa");
|
||||||
|
assert_eq!(page2.next_cursor.as_deref(), Some("r1:1:2"));
|
||||||
|
|
||||||
|
// Page 3: last PP, page fills at the final object.
|
||||||
|
let page3 = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::All,
|
||||||
|
2,
|
||||||
|
page2.next_cursor.as_deref(),
|
||||||
|
)
|
||||||
|
.expect("page3");
|
||||||
|
assert_eq!(page3.data.len(), 2);
|
||||||
|
assert_eq!(page3.next_cursor.as_deref(), Some("r1:2:2"));
|
||||||
|
|
||||||
|
// Page 4: nothing left.
|
||||||
|
let page4 = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::All,
|
||||||
|
2,
|
||||||
|
page3.next_cursor.as_deref(),
|
||||||
|
)
|
||||||
|
.expect("page4");
|
||||||
|
assert!(page4.data.is_empty());
|
||||||
|
assert_eq!(page4.next_cursor, None);
|
||||||
|
|
||||||
|
// A page larger than the remaining tail must not produce a cursor.
|
||||||
|
let tail = list_report_objects(&report_path, "run_0001", ObjectScope::All, 10, None)
|
||||||
|
.expect("tail");
|
||||||
|
assert_eq!(tail.data.len(), 6);
|
||||||
|
assert_eq!(tail.next_cursor, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_listing_scope_skips_non_matching_publication_points() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let report_path = temp.path().join("report.json");
|
||||||
|
write_multi_pp_report(&report_path);
|
||||||
|
|
||||||
|
// PP scope: only PP 1 (node_id 2) objects, cursor resumes within PP 1.
|
||||||
|
let page1 = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("page1");
|
||||||
|
assert_eq!(page1.data.len(), 1);
|
||||||
|
assert_eq!(page1.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
|
||||||
|
assert_eq!(page1.next_cursor.as_deref(), Some("r1:1:1"));
|
||||||
|
let page2 = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||||
|
10,
|
||||||
|
page1.next_cursor.as_deref(),
|
||||||
|
)
|
||||||
|
.expect("page2");
|
||||||
|
assert_eq!(page2.data.len(), 1);
|
||||||
|
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/b.roa");
|
||||||
|
assert_eq!(page2.next_cursor, None);
|
||||||
|
|
||||||
|
// Repo scope derived from the PP without rrdp uri (repo-b).
|
||||||
|
let probe_pp = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("probe");
|
||||||
|
let repo_id = probe_pp.data[0].repo_id.clone();
|
||||||
|
let repo_page = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::Repo(repo_id),
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("repo page");
|
||||||
|
assert_eq!(repo_page.data.len(), 2);
|
||||||
|
assert!(
|
||||||
|
repo_page
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.all(|object| object.uri.contains("repo-b.example"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unknown scope id: empty page, no cursor, no error.
|
||||||
|
let empty = list_report_objects(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::PublicationPoint("node_999".to_string()),
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("empty");
|
||||||
|
assert!(empty.data.is_empty());
|
||||||
|
assert_eq!(empty.next_cursor, None);
|
||||||
|
|
||||||
|
// Filtered listing keeps working across skipped PPs.
|
||||||
|
let mut filter = ObjectFilter::default();
|
||||||
|
filter.query = Some("c.roa".to_string());
|
||||||
|
let filtered = list_report_objects_filtered(
|
||||||
|
&report_path,
|
||||||
|
"run_0001",
|
||||||
|
ObjectScope::All,
|
||||||
|
&filter,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("filtered");
|
||||||
|
assert_eq!(filtered.data.len(), 1);
|
||||||
|
assert_eq!(filtered.data[0].uri, "rsync://repo-c.example/rpki/c.roa");
|
||||||
|
assert_eq!(filtered.next_cursor, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
326
src/query_db.rs
326
src/query_db.rs
@@ -60,6 +60,8 @@ pub enum QueryDbError {
|
|||||||
MissingColumnFamily(&'static str),
|
MissingColumnFamily(&'static str),
|
||||||
#[error("invalid run artifact: {0}")]
|
#[error("invalid run artifact: {0}")]
|
||||||
InvalidArtifact(String),
|
InvalidArtifact(String),
|
||||||
|
#[error("report data for run {0} is no longer available (retention cleanup)")]
|
||||||
|
RunDataGone(String),
|
||||||
#[error("CIR decode error: {0}")]
|
#[error("CIR decode error: {0}")]
|
||||||
CirDecode(String),
|
CirDecode(String),
|
||||||
}
|
}
|
||||||
@@ -160,6 +162,24 @@ pub struct RepositoryRecord {
|
|||||||
pub terminal_states: BTreeMap<String, u64>,
|
pub terminal_states: BTreeMap<String, u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort order for `QueryDb::list_repos_sorted`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RepoSortOrder {
|
||||||
|
ObjectsDesc,
|
||||||
|
ObjectsAsc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `repos:<offset>` cursor used by sorted repository listings.
|
||||||
|
fn parse_repos_offset_cursor(cursor: Option<&str>) -> QueryDbResult<usize> {
|
||||||
|
match cursor {
|
||||||
|
None => Ok(0),
|
||||||
|
Some(raw) => raw
|
||||||
|
.strip_prefix("repos:")
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.ok_or_else(|| QueryDbError::InvalidArtifact(format!("invalid repos cursor: {raw}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PublicationPointRecord {
|
pub struct PublicationPointRecord {
|
||||||
@@ -289,6 +309,7 @@ impl QueryDb {
|
|||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.create_missing_column_families(true);
|
opts.create_missing_column_families(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||||
@@ -310,6 +331,7 @@ impl QueryDb {
|
|||||||
opts.create_if_missing(false);
|
opts.create_if_missing(false);
|
||||||
opts.create_missing_column_families(false);
|
opts.create_missing_column_families(false);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||||
@@ -351,8 +373,10 @@ impl QueryDb {
|
|||||||
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
||||||
if run_id == "latest" || run_id == "latest_run" {
|
if run_id == "latest" || run_id == "latest_run" {
|
||||||
self.latest_ready_run()
|
self.latest_ready_run()
|
||||||
} else {
|
} else if self.get_run(run_id)?.is_some() {
|
||||||
Ok(Some(run_id.to_string()))
|
Ok(Some(run_id.to_string()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +385,38 @@ impl QueryDb {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
) -> QueryDbResult<QueryPage<RunRecord>> {
|
) -> QueryDbResult<QueryPage<RunRecord>> {
|
||||||
self.list_json_by_prefix(CF_RUNS, "run/", limit, cursor)
|
// Runs are listed newest first: zero-padded run ids sort by age, so a
|
||||||
|
// reverse scan over the `run/` prefix yields the latest run on page 1.
|
||||||
|
// Cursor semantics mirror `list_json_by_prefix`: the cursor is the
|
||||||
|
// first key of the next page (inclusive).
|
||||||
|
let limit = limit.clamp(1, 1000);
|
||||||
|
let cf = self.cf(CF_RUNS)?;
|
||||||
|
let prefix = "run/";
|
||||||
|
let start = match cursor {
|
||||||
|
Some(cursor) => cursor.as_bytes().to_vec(),
|
||||||
|
None => prefix_range_end(prefix.as_bytes())
|
||||||
|
.unwrap_or_else(|| prefix.as_bytes().to_vec()),
|
||||||
|
};
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut next_cursor = None;
|
||||||
|
let mode = IteratorMode::From(&start, rocksdb::Direction::Reverse);
|
||||||
|
for item in self.db.iterator_cf(cf, mode) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
let key_str = String::from_utf8_lossy(&key);
|
||||||
|
if !key_str.starts_with(prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if data.len() >= limit {
|
||||||
|
next_cursor = Some(key_str.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
data.push(serde_json::from_slice(&value)?);
|
||||||
|
}
|
||||||
|
Ok(QueryPage {
|
||||||
|
data,
|
||||||
|
next_cursor,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_repos(
|
pub fn list_repos(
|
||||||
@@ -373,6 +428,58 @@ impl QueryDb {
|
|||||||
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
|
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List repositories sorted by object count. The sort happens at query
|
||||||
|
/// time over the run's repo set (hundreds of records at most), so no
|
||||||
|
/// extra index is needed. Pagination uses a `repos:<offset>` cursor,
|
||||||
|
/// independent from the index-order key cursor used by `list_repos`.
|
||||||
|
pub fn list_repos_sorted(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
order: RepoSortOrder,
|
||||||
|
) -> QueryDbResult<QueryPage<RepositoryRecord>> {
|
||||||
|
let cf = self.cf(CF_REPOS)?;
|
||||||
|
let prefix = format!("repo/{run_id}/");
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for item in self.db.iterator_cf(
|
||||||
|
cf,
|
||||||
|
IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward),
|
||||||
|
) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
if !key.starts_with(prefix.as_bytes()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
records.push(serde_json::from_slice::<RepositoryRecord>(&value)?);
|
||||||
|
}
|
||||||
|
records.sort_by(|left, right| {
|
||||||
|
let by_objects = match order {
|
||||||
|
RepoSortOrder::ObjectsDesc => right.objects.cmp(&left.objects),
|
||||||
|
RepoSortOrder::ObjectsAsc => left.objects.cmp(&right.objects),
|
||||||
|
};
|
||||||
|
// Stable, deterministic order for equal object counts.
|
||||||
|
by_objects.then_with(|| left.repo_id.cmp(&right.repo_id))
|
||||||
|
});
|
||||||
|
let offset = parse_repos_offset_cursor(cursor)?;
|
||||||
|
let bounded_limit = limit.clamp(1, 1000);
|
||||||
|
let total = records.len();
|
||||||
|
let data = records
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(bounded_limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let next_cursor = if offset + bounded_limit < total {
|
||||||
|
Some(format!("repos:{}", offset + bounded_limit))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(QueryPage {
|
||||||
|
data,
|
||||||
|
next_cursor,
|
||||||
|
limit: bounded_limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn lookup_vrps(
|
pub fn lookup_vrps(
|
||||||
&self,
|
&self,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
@@ -691,9 +798,17 @@ impl QueryDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
||||||
Ok(self
|
let Some(run) = self.get_run(run_id)? else {
|
||||||
.get_run(run_id)?
|
return Ok(None);
|
||||||
.map(|run| Path::new(&run.run_dir).join("report.json")))
|
};
|
||||||
|
let path = Path::new(&run.run_dir).join("report.json");
|
||||||
|
if !path.exists() {
|
||||||
|
// The run is still indexed but its report.json was removed by
|
||||||
|
// retention cleanup; callers must be able to tell this apart
|
||||||
|
// from a genuine io failure (mapped to 404 upstream).
|
||||||
|
return Err(QueryDbError::RunDataGone(run_id.to_string()));
|
||||||
|
}
|
||||||
|
Ok(Some(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_object_projection(
|
pub fn get_object_projection(
|
||||||
@@ -1094,6 +1209,7 @@ impl QueryDb {
|
|||||||
fn cf_options() -> Options {
|
fn cf_options() -> Options {
|
||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
opts
|
opts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1951,15 +2067,17 @@ mod tests {
|
|||||||
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
||||||
Some("run_0001")
|
Some("run_0001")
|
||||||
);
|
);
|
||||||
|
assert_eq!(db.resolve_run_id("run_9999").unwrap(), None);
|
||||||
|
|
||||||
let first_page = db.list_runs(1, None).expect("runs");
|
let first_page = db.list_runs(1, None).expect("runs");
|
||||||
assert_eq!(first_page.data.len(), 1);
|
assert_eq!(first_page.data.len(), 1);
|
||||||
assert_eq!(first_page.data[0].run_id, "run_0001");
|
assert_eq!(first_page.data[0].run_id, "run_0002");
|
||||||
let second_page = db
|
let second_page = db
|
||||||
.list_runs(1, first_page.next_cursor.as_deref())
|
.list_runs(1, first_page.next_cursor.as_deref())
|
||||||
.expect("second runs");
|
.expect("second runs");
|
||||||
assert_eq!(second_page.data.len(), 1);
|
assert_eq!(second_page.data.len(), 1);
|
||||||
assert_eq!(second_page.data[0].run_id, "run_0002");
|
assert_eq!(second_page.data[0].run_id, "run_0001");
|
||||||
|
assert!(second_page.next_cursor.is_none());
|
||||||
|
|
||||||
let repos = db.list_repos("run_0002", 10, None).expect("repos");
|
let repos = db.list_repos("run_0002", 10, None).expect("repos");
|
||||||
assert_eq!(repos.data.len(), 1);
|
assert_eq!(repos.data.len(), 1);
|
||||||
@@ -2105,6 +2223,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_report_file_is_reported_as_run_data_gone() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let run1 = temp.path().join("runs/run_0001");
|
||||||
|
fs::create_dir_all(&run1).expect("run1");
|
||||||
|
write_sample_run(&run1, "run_0001", 1);
|
||||||
|
|
||||||
|
let query_db_path = temp.path().join("query-db");
|
||||||
|
index_artifacts(&ArtifactIndexerConfig {
|
||||||
|
query_db_path: query_db_path.clone(),
|
||||||
|
run_root: Some(temp.path().to_path_buf()),
|
||||||
|
run_dir: None,
|
||||||
|
repo_bytes_db_path: None,
|
||||||
|
projection_entry_limit: 50,
|
||||||
|
min_run_seq: None,
|
||||||
|
retain_indexed_runs: None,
|
||||||
|
})
|
||||||
|
.expect("index");
|
||||||
|
let db = QueryDb::open(&query_db_path).expect("open query db");
|
||||||
|
assert_eq!(db.list_objects("run_0001", 10, None).unwrap().data.len(), 2);
|
||||||
|
|
||||||
|
// Simulate retention cleanup removing the run directory while the
|
||||||
|
// index record survives: object listing/lookup must fail with a
|
||||||
|
// distinguishable error instead of a bare io error.
|
||||||
|
fs::remove_dir_all(&run1).expect("remove run dir");
|
||||||
|
let err = db.list_objects("run_0001", 10, None).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, QueryDbError::RunDataGone(ref run_id) if run_id == "run_0001"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
let err = db.get_object_by_sha256("run_0001", "22").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, QueryDbError::RunDataGone(_)),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repeated_indexing_does_not_move_latest_backwards() {
|
fn repeated_indexing_does_not_move_latest_backwards() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -2201,6 +2356,163 @@ mod tests {
|
|||||||
.expect("report");
|
.expect("report");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_db_list_repos_sorted_orders_by_object_count() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let run1 = temp.path().join("runs/run_0001");
|
||||||
|
fs::create_dir_all(&run1).expect("run1");
|
||||||
|
// Index order (a, b, c) deliberately differs from object-count order.
|
||||||
|
write_sample_run_with_repos(
|
||||||
|
&run1,
|
||||||
|
"run_0001",
|
||||||
|
1,
|
||||||
|
&[
|
||||||
|
("repo-a.example", 2),
|
||||||
|
("repo-b.example", 5),
|
||||||
|
("repo-c.example", 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let query_db_path = temp.path().join("query-db");
|
||||||
|
index_artifacts(&ArtifactIndexerConfig {
|
||||||
|
query_db_path: query_db_path.clone(),
|
||||||
|
run_root: Some(temp.path().to_path_buf()),
|
||||||
|
run_dir: None,
|
||||||
|
repo_bytes_db_path: None,
|
||||||
|
projection_entry_limit: 50,
|
||||||
|
min_run_seq: None,
|
||||||
|
retain_indexed_runs: None,
|
||||||
|
})
|
||||||
|
.expect("index");
|
||||||
|
|
||||||
|
let db = QueryDb::open(&query_db_path).expect("open query db");
|
||||||
|
let hosts = |page: QueryPage<RepositoryRecord>| {
|
||||||
|
page.data
|
||||||
|
.iter()
|
||||||
|
.map(|repo| (repo.host.clone(), repo.objects))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let desc = db
|
||||||
|
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsDesc)
|
||||||
|
.expect("desc");
|
||||||
|
assert_eq!(
|
||||||
|
hosts(desc),
|
||||||
|
vec![
|
||||||
|
("repo-b.example".to_string(), 5),
|
||||||
|
("repo-a.example".to_string(), 2),
|
||||||
|
("repo-c.example".to_string(), 1),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let asc = db
|
||||||
|
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsAsc)
|
||||||
|
.expect("asc");
|
||||||
|
assert_eq!(
|
||||||
|
hosts(asc),
|
||||||
|
vec![
|
||||||
|
("repo-c.example".to_string(), 1),
|
||||||
|
("repo-a.example".to_string(), 2),
|
||||||
|
("repo-b.example".to_string(), 5),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Offset cursor pagination over the sorted listing.
|
||||||
|
let first = db
|
||||||
|
.list_repos_sorted("run_0001", 2, None, RepoSortOrder::ObjectsDesc)
|
||||||
|
.expect("first page");
|
||||||
|
assert_eq!(first.data.len(), 2);
|
||||||
|
assert_eq!(first.data[0].host, "repo-b.example");
|
||||||
|
assert_eq!(first.next_cursor.as_deref(), Some("repos:2"));
|
||||||
|
let second = db
|
||||||
|
.list_repos_sorted(
|
||||||
|
"run_0001",
|
||||||
|
2,
|
||||||
|
first.next_cursor.as_deref(),
|
||||||
|
RepoSortOrder::ObjectsDesc,
|
||||||
|
)
|
||||||
|
.expect("second page");
|
||||||
|
assert_eq!(second.data.len(), 1);
|
||||||
|
assert_eq!(second.data[0].host, "repo-c.example");
|
||||||
|
assert!(second.next_cursor.is_none());
|
||||||
|
|
||||||
|
// A key-style cursor from the index-order listing is rejected.
|
||||||
|
assert!(db
|
||||||
|
.list_repos_sorted("run_0001", 2, Some("repo/run_0001/x"), RepoSortOrder::ObjectsDesc)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample run with one publication point per repo host, each holding
|
||||||
|
/// `object_count` ok objects, so repo records get distinct object counts.
|
||||||
|
fn write_sample_run_with_repos(
|
||||||
|
run_dir: &Path,
|
||||||
|
run_id: &str,
|
||||||
|
run_seq: u64,
|
||||||
|
repo_specs: &[(&str, usize)],
|
||||||
|
) {
|
||||||
|
let publication_points = repo_specs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (host, object_count))| {
|
||||||
|
let objects = (0..*object_count)
|
||||||
|
.map(|i| {
|
||||||
|
json!({
|
||||||
|
"rsync_uri": format!("rsync://{host}/rpki/obj{i}.roa"),
|
||||||
|
"sha256_hex": format!("{index:02x}{i:02x}"),
|
||||||
|
"kind": "roa",
|
||||||
|
"result": "ok"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
json!({
|
||||||
|
"node_id": 10 + index,
|
||||||
|
"rsync_base_uri": format!("rsync://{host}/rpki/"),
|
||||||
|
"manifest_rsync_uri": format!("rsync://{host}/rpki/m.mft"),
|
||||||
|
"publication_point_rsync_uri": format!("rsync://{host}/rpki/"),
|
||||||
|
"rrdp_notification_uri": format!("https://{host}/rrdp/notification.xml"),
|
||||||
|
"source": "rrdp",
|
||||||
|
"repo_sync_source": "rrdp",
|
||||||
|
"repo_sync_phase": "rrdp_snapshot",
|
||||||
|
"repo_sync_duration_ms": 100,
|
||||||
|
"repo_terminal_state": "fresh",
|
||||||
|
"warnings": [],
|
||||||
|
"objects": objects,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let report = json!({
|
||||||
|
"format_version": 2,
|
||||||
|
"meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
|
||||||
|
"tree": {"warnings": []},
|
||||||
|
"publication_points": publication_points,
|
||||||
|
"vrps": [],
|
||||||
|
"aspas": [],
|
||||||
|
"downloads": [],
|
||||||
|
"download_stats": {},
|
||||||
|
"repo_sync_stats": {}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
run_dir.join("report.json"),
|
||||||
|
serde_json::to_vec(&report).unwrap(),
|
||||||
|
)
|
||||||
|
.expect("report");
|
||||||
|
let summary = json!({
|
||||||
|
"status": "success",
|
||||||
|
"runId": run_id,
|
||||||
|
"runSeq": run_seq,
|
||||||
|
"startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
|
||||||
|
"finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
|
||||||
|
"wallMs": 60000,
|
||||||
|
"reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": repo_specs.len(), "warnings": 0}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
run_dir.join("run-summary.json"),
|
||||||
|
serde_json::to_vec(&summary).unwrap(),
|
||||||
|
)
|
||||||
|
.expect("summary");
|
||||||
|
fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
|
||||||
|
}
|
||||||
|
|
||||||
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
|
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
|
||||||
let report = json!({
|
let report = json!({
|
||||||
"format_version": 2,
|
"format_version": 2,
|
||||||
|
|||||||
@@ -613,6 +613,12 @@ pub struct VcirFailedFetchReuseIdentity {
|
|||||||
pub effective_until: PackTime,
|
pub effective_until: PackTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct VcirReuseRecordsCleared {
|
||||||
|
pub vcir_records: u64,
|
||||||
|
pub failed_fetch_identity_records: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl VcirFailedFetchReuseIdentity {
|
impl VcirFailedFetchReuseIdentity {
|
||||||
pub fn validate_internal(&self) -> StorageResult<()> {
|
pub fn validate_internal(&self) -> StorageResult<()> {
|
||||||
let effective_not_before = parse_time(
|
let effective_not_before = parse_time(
|
||||||
@@ -1660,6 +1666,9 @@ pub struct PublicationPointCacheObject {
|
|||||||
pub object_type: Option<String>,
|
pub object_type: Option<String>,
|
||||||
#[serde(rename = "s")]
|
#[serde(rename = "s")]
|
||||||
pub validation_status: VcirArtifactValidationStatus,
|
pub validation_status: VcirArtifactValidationStatus,
|
||||||
|
/// See `VcirRelatedArtifact::reject_reason`.
|
||||||
|
#[serde(rename = "e", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reject_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PublicationPointCacheObject {
|
impl PublicationPointCacheObject {
|
||||||
@@ -1671,6 +1680,7 @@ impl PublicationPointCacheObject {
|
|||||||
sha256: artifact.sha256.clone(),
|
sha256: artifact.sha256.clone(),
|
||||||
object_type: artifact.object_type.clone(),
|
object_type: artifact.object_type.clone(),
|
||||||
validation_status: artifact.validation_status,
|
validation_status: artifact.validation_status,
|
||||||
|
reject_reason: artifact.reject_reason.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,6 +1692,7 @@ impl PublicationPointCacheObject {
|
|||||||
sha256: self.sha256.clone(),
|
sha256: self.sha256.clone(),
|
||||||
object_type: self.object_type.clone(),
|
object_type: self.object_type.clone(),
|
||||||
validation_status: self.validation_status,
|
validation_status: self.validation_status,
|
||||||
|
reject_reason: self.reject_reason.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2157,6 +2168,11 @@ pub struct VcirRelatedArtifact {
|
|||||||
pub object_type: Option<String>,
|
pub object_type: Option<String>,
|
||||||
#[serde(rename = "s")]
|
#[serde(rename = "s")]
|
||||||
pub validation_status: VcirArtifactValidationStatus,
|
pub validation_status: VcirArtifactValidationStatus,
|
||||||
|
/// Reject reason captured at fresh validation time for `Rejected` artifacts.
|
||||||
|
/// `None` for accepted/warning artifacts and for cache entries written
|
||||||
|
/// before this field existed.
|
||||||
|
#[serde(rename = "e", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reject_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VcirRelatedArtifact {
|
impl VcirRelatedArtifact {
|
||||||
@@ -4523,6 +4539,36 @@ impl RocksStore {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove every reusable VCIR and its failed-fetch identity while retaining
|
||||||
|
/// manifest replay metadata. The replay metadata is a fresh-validation
|
||||||
|
/// guard, not a reusable validation result; removing it changes the normal
|
||||||
|
/// traversal semantics for manifests that are not selected for replay.
|
||||||
|
///
|
||||||
|
/// This is intentionally narrower than [`Self::delete_vcir`]. It is used
|
||||||
|
/// by verification-only after cloning a work-db so that it can import an
|
||||||
|
/// explicit, contract-bound allowlist of fallback projections.
|
||||||
|
pub fn clear_vcir_reuse_records(&self) -> StorageResult<VcirReuseRecordsCleared> {
|
||||||
|
let vcir_cf = self.cf(CF_VCIR)?;
|
||||||
|
let identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
|
||||||
|
let mut batch = WriteBatch::default();
|
||||||
|
let mut summary = VcirReuseRecordsCleared::default();
|
||||||
|
|
||||||
|
for entry in self.db.iterator_cf(vcir_cf, IteratorMode::Start) {
|
||||||
|
let (key, _value) = entry.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||||
|
batch.delete_cf(vcir_cf, key);
|
||||||
|
summary.vcir_records += 1;
|
||||||
|
}
|
||||||
|
for entry in self.db.iterator_cf(identity_cf, IteratorMode::Start) {
|
||||||
|
let (key, _value) = entry.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||||
|
batch.delete_cf(identity_cf, key);
|
||||||
|
summary.failed_fetch_identity_records += 1;
|
||||||
|
}
|
||||||
|
if summary.vcir_records > 0 || summary.failed_fetch_identity_records > 0 {
|
||||||
|
self.write_batch(batch)?;
|
||||||
|
}
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn summarize_vcir_storage(&self) -> StorageResult<VcirStorageSummary> {
|
pub fn summarize_vcir_storage(&self) -> StorageResult<VcirStorageSummary> {
|
||||||
let cf = self.cf(CF_VCIR)?;
|
let cf = self.cf(CF_VCIR)?;
|
||||||
let mode = IteratorMode::Start;
|
let mode = IteratorMode::Start;
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
|
|||||||
sha256: sha256_hex(b"manifest-object"),
|
sha256: sha256_hex(b"manifest-object"),
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::CurrentCrl,
|
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||||
@@ -265,6 +266,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
|
|||||||
sha256: sha256_hex(b"current-crl"),
|
sha256: sha256_hex(b"current-crl"),
|
||||||
object_type: Some("crl".to_string()),
|
object_type: Some("crl".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
@@ -1308,6 +1310,111 @@ fn vcir_roundtrip_and_validation_failures_are_reported() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_vcir_reuse_records_preserves_manifest_replay_meta() {
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(td.path()).expect("open rocksdb");
|
||||||
|
let vcir = sample_vcir("rsync://example.test/repo/current.mft");
|
||||||
|
store.put_vcir(&vcir).expect("put vcir");
|
||||||
|
let replay_meta = store
|
||||||
|
.get_manifest_replay_meta(&vcir.manifest_rsync_uri)
|
||||||
|
.expect("get replay meta")
|
||||||
|
.expect("replay meta exists");
|
||||||
|
|
||||||
|
let cleared = store
|
||||||
|
.clear_vcir_reuse_records()
|
||||||
|
.expect("clear reusable VCIR records");
|
||||||
|
assert_eq!(cleared.vcir_records, 1);
|
||||||
|
assert_eq!(cleared.failed_fetch_identity_records, 0);
|
||||||
|
assert!(store
|
||||||
|
.get_vcir(&vcir.manifest_rsync_uri)
|
||||||
|
.expect("get cleared vcir")
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_manifest_replay_meta(&vcir.manifest_rsync_uri)
|
||||||
|
.expect("get preserved replay meta"),
|
||||||
|
Some(replay_meta)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verification_only_materializes_only_contract_selected_fallback_vcirs() {
|
||||||
|
fn reuse_identity() -> VcirFailedFetchReuseIdentity {
|
||||||
|
VcirFailedFetchReuseIdentity {
|
||||||
|
current_ca_sha256: [0x11; 32],
|
||||||
|
ta_context_digest: [0x22; 32],
|
||||||
|
ca_validation_context_digest: [0x33; 32],
|
||||||
|
policy_fingerprint: [0x44; 32],
|
||||||
|
effective_not_before: pack_time(0),
|
||||||
|
effective_until: pack_time(12),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = RocksStore::open(td.path()).expect("open rocksdb");
|
||||||
|
let selected = sample_vcir("rsync://example.test/repo/selected.mft");
|
||||||
|
let unselected = sample_vcir("rsync://example.test/repo/unselected.mft");
|
||||||
|
store
|
||||||
|
.put_vcir_with_failed_fetch_reuse_identity(&selected, &reuse_identity())
|
||||||
|
.expect("put selected fallback vcir");
|
||||||
|
store
|
||||||
|
.put_vcir_with_failed_fetch_reuse_identity(&unselected, &reuse_identity())
|
||||||
|
.expect("put unselected fallback vcir");
|
||||||
|
let selection = crate::verification_only::fallback_replay_selection_for_manifest(
|
||||||
|
&store,
|
||||||
|
&selected.manifest_rsync_uri,
|
||||||
|
)
|
||||||
|
.expect("select fallback vcir");
|
||||||
|
let normal_audits = vec![crate::audit::PublicationPointAudit {
|
||||||
|
manifest_rsync_uri: selected.manifest_rsync_uri.clone(),
|
||||||
|
source: "vcir_current_instance".to_string(),
|
||||||
|
..crate::audit::PublicationPointAudit::default()
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
crate::verification_only::collect_fallback_replay_selections(&store, &normal_audits)
|
||||||
|
.expect("collect normal fallback selection"),
|
||||||
|
vec![selection.clone()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tampered = selection.clone();
|
||||||
|
tampered.vcir_sha256 = "00".repeat(32);
|
||||||
|
assert!(crate::verification_only::materialize_fallback_replay_selections(
|
||||||
|
&store,
|
||||||
|
&[tampered]
|
||||||
|
)
|
||||||
|
.expect_err("tampered contract hash must fail before cleanup")
|
||||||
|
.contains("hash mismatch"));
|
||||||
|
assert_eq!(store.list_vcirs().expect("VCIRs remain after hash rejection").len(), 2);
|
||||||
|
|
||||||
|
let summary = crate::verification_only::materialize_fallback_replay_selections(
|
||||||
|
&store,
|
||||||
|
&[selection.clone()],
|
||||||
|
)
|
||||||
|
.expect("materialize contract fallback selection");
|
||||||
|
assert_eq!(summary.contract_selection_count, 1);
|
||||||
|
assert_eq!(summary.source_vcir_records_cleared, 2);
|
||||||
|
assert_eq!(summary.source_reuse_identity_records_cleared, 2);
|
||||||
|
assert_eq!(summary.imported_selection_count, 1);
|
||||||
|
assert_eq!(summary.scratch_vcir_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
crate::verification_only::fallback_replay_selection_for_manifest(
|
||||||
|
&store,
|
||||||
|
&selected.manifest_rsync_uri,
|
||||||
|
)
|
||||||
|
.expect("selected fallback remains"),
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
assert!(store
|
||||||
|
.get_vcir(&unselected.manifest_rsync_uri)
|
||||||
|
.expect("get unselected fallback")
|
||||||
|
.is_none());
|
||||||
|
assert!(store
|
||||||
|
.get_manifest_replay_meta(&unselected.manifest_rsync_uri)
|
||||||
|
.expect("get unselected replay meta")
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_prefetch_snapshot_roundtrips() {
|
fn transport_prefetch_snapshot_roundtrips() {
|
||||||
use crate::parallel::transport_prefetch::{
|
use crate::parallel::transport_prefetch::{
|
||||||
@@ -2394,3 +2501,35 @@ fn repository_blob_verification_detects_missing_external_blob() {
|
|||||||
.expect_err("missing blob must fail");
|
.expect_err("missing blob must fail");
|
||||||
assert!(error.to_string().contains("missing.roa"), "{error}");
|
assert!(error.to_string().contains("missing.roa"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vcir_related_artifact_reject_reason_serde_backward_compatible() {
|
||||||
|
// Legacy cache JSON written before the reject_reason ("e") field existed.
|
||||||
|
let legacy = r#"{"r":"manifest","k":"mft","u":"rsync://example.test/a.mft","h":"0000000000000000000000000000000000000000000000000000000000000000","t":"mft","s":"accepted"}"#;
|
||||||
|
let artifact: VcirRelatedArtifact = serde_json::from_str(legacy).expect("legacy artifact");
|
||||||
|
assert_eq!(artifact.reject_reason, None);
|
||||||
|
|
||||||
|
// None reason is skipped on serialization, keeping cache entries compact.
|
||||||
|
let json_none = serde_json::to_string(&artifact).expect("serialize none");
|
||||||
|
assert!(!json_none.contains("\"e\""));
|
||||||
|
|
||||||
|
// A recorded reason round-trips through the "e" field.
|
||||||
|
let mut with_reason = artifact.clone();
|
||||||
|
with_reason.validation_status = VcirArtifactValidationStatus::Rejected;
|
||||||
|
with_reason.reject_reason = Some("bad object".to_string());
|
||||||
|
let json = serde_json::to_string(&with_reason).expect("serialize");
|
||||||
|
assert!(json.contains("\"e\":\"bad object\""));
|
||||||
|
let decoded: VcirRelatedArtifact = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(decoded.reject_reason.as_deref(), Some("bad object"));
|
||||||
|
|
||||||
|
// PublicationPointCacheObject conversion carries the reason both ways.
|
||||||
|
let cache_object = PublicationPointCacheObject::from_related_artifact(&with_reason);
|
||||||
|
assert_eq!(cache_object.reject_reason.as_deref(), Some("bad object"));
|
||||||
|
let back = cache_object.to_related_artifact();
|
||||||
|
assert_eq!(back.reject_reason.as_deref(), Some("bad object"));
|
||||||
|
|
||||||
|
// Legacy PublicationPointCacheObject JSON without "e" still deserializes.
|
||||||
|
let legacy_object: PublicationPointCacheObject =
|
||||||
|
serde_json::from_str(legacy).expect("legacy cache object");
|
||||||
|
assert_eq!(legacy_object.reject_reason, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -672,7 +672,7 @@ fn rsync_sync_into_current_store(
|
|||||||
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
|
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
|
||||||
if let Some(index) = current_repo_index {
|
if let Some(index) = current_repo_index {
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| RepoSyncError::Storage("current repo index lock poisoned".to_string()))?
|
.map_err(|_| RepoSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||||
.apply_repository_view_entries(&repository_view_entries)
|
.apply_repository_view_entries(&repository_view_entries)
|
||||||
.map_err(RepoSyncError::Storage)?;
|
.map_err(RepoSyncError::Storage)?;
|
||||||
|
|||||||
@@ -1029,7 +1029,7 @@ fn hydrate_current_repo_index_from_rrdp_members(
|
|||||||
}
|
}
|
||||||
|
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||||
.apply_repository_view_entries(&entries)
|
.apply_repository_view_entries(&entries)
|
||||||
.map_err(RrdpSyncError::Storage)?;
|
.map_err(RrdpSyncError::Storage)?;
|
||||||
@@ -1184,7 +1184,7 @@ fn apply_delta(
|
|||||||
¤t_hash,
|
¤t_hash,
|
||||||
);
|
);
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
RrdpSyncError::Storage("current repo index lock poisoned".to_string())
|
RrdpSyncError::Storage("current repo index lock poisoned".to_string())
|
||||||
})?
|
})?
|
||||||
@@ -1240,7 +1240,7 @@ fn apply_delta(
|
|||||||
Some(previous_hash.clone()),
|
Some(previous_hash.clone()),
|
||||||
);
|
);
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
RrdpSyncError::Storage(
|
RrdpSyncError::Storage(
|
||||||
"current repo index lock poisoned".to_string(),
|
"current repo index lock poisoned".to_string(),
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ pub(super) fn apply_snapshot_from_bufread<R: BufRead>(
|
|||||||
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
|
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
|
||||||
if let Some(index) = current_repo_index {
|
if let Some(index) = current_repo_index {
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||||
.apply_repository_view_entries(&repository_view_entries)
|
.apply_repository_view_entries(&repository_view_entries)
|
||||||
.map_err(RrdpSyncError::Storage)?;
|
.map_err(RrdpSyncError::Storage)?;
|
||||||
@@ -365,7 +365,7 @@ fn flush_snapshot_publish_batch(
|
|||||||
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
|
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
|
||||||
if let Some(index) = current_repo_index {
|
if let Some(index) = current_repo_index {
|
||||||
index
|
index
|
||||||
.lock()
|
.write()
|
||||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||||
.apply_repository_view_entries(&repository_view_entries)
|
.apply_repository_view_entries(&repository_view_entries)
|
||||||
.map_err(RrdpSyncError::Storage)?;
|
.map_err(RrdpSyncError::Storage)?;
|
||||||
|
|||||||
@@ -1025,7 +1025,7 @@ fn sync_from_notification_same_serial_hydrates_current_repo_index() {
|
|||||||
.expect("same serial no-op");
|
.expect("same serial no-op");
|
||||||
assert_eq!(applied, 0);
|
assert_eq!(applied, 0);
|
||||||
|
|
||||||
let index = index.lock().expect("lock index");
|
let index = index.read().expect("read-lock index");
|
||||||
assert_eq!(index.active_uri_count(), 2);
|
assert_eq!(index.active_uri_count(), 2);
|
||||||
assert!(index.get_by_uri(uri_a).is_some());
|
assert!(index.get_by_uri(uri_a).is_some());
|
||||||
assert!(index.get_by_uri(uri_b).is_some());
|
assert!(index.get_by_uri(uri_b).is_some());
|
||||||
@@ -1089,7 +1089,7 @@ fn sync_from_notification_delta_hydrates_unchanged_current_repo_entries() {
|
|||||||
.expect("delta sync");
|
.expect("delta sync");
|
||||||
assert_eq!(applied, 1);
|
assert_eq!(applied, 1);
|
||||||
|
|
||||||
let index = index.lock().expect("lock index");
|
let index = index.read().expect("read-lock index");
|
||||||
assert_eq!(index.active_uri_count(), 3);
|
assert_eq!(index.active_uri_count(), 3);
|
||||||
assert!(
|
assert!(
|
||||||
index.get_by_uri(uri_a).is_some(),
|
index.get_by_uri(uri_a).is_some(),
|
||||||
|
|||||||
@@ -433,6 +433,8 @@ struct LatestRunMetrics {
|
|||||||
download_bytes: Option<u64>,
|
download_bytes: Option<u64>,
|
||||||
artifact_sizes: BTreeMap<String, u64>,
|
artifact_sizes: BTreeMap<String, u64>,
|
||||||
state_path_sizes: BTreeMap<String, PathSize>,
|
state_path_sizes: BTreeMap<String, PathSize>,
|
||||||
|
dead_repo_blacklist_entries: Option<u64>,
|
||||||
|
dead_repo_blacklist_blacklisted: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
@@ -1377,6 +1379,10 @@ fn build_latest_metrics(record: &RunRecord, snapshot: &mut MetricsSnapshot) {
|
|||||||
latest.download_bytes = json_u64(summary, &["stageTiming", "download_bytes_total"]);
|
latest.download_bytes = json_u64(summary, &["stageTiming", "download_bytes_total"]);
|
||||||
latest.artifact_sizes = extract_artifact_sizes(summary.get("artifacts"));
|
latest.artifact_sizes = extract_artifact_sizes(summary.get("artifacts"));
|
||||||
latest.state_path_sizes = extract_path_sizes(summary.get("pathStats"));
|
latest.state_path_sizes = extract_path_sizes(summary.get("pathStats"));
|
||||||
|
latest.dead_repo_blacklist_entries =
|
||||||
|
json_u64(summary, &["deadRepoBlacklist", "entries"]);
|
||||||
|
latest.dead_repo_blacklist_blacklisted =
|
||||||
|
json_u64(summary, &["deadRepoBlacklist", "blacklisted"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_report(&record.path.join("report.json"), snapshot, &mut latest);
|
parse_report(&record.path.join("report.json"), snapshot, &mut latest);
|
||||||
@@ -2419,6 +2425,22 @@ fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &L
|
|||||||
value as f64,
|
value as f64,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(value) = latest.dead_repo_blacklist_entries {
|
||||||
|
writer.gauge(
|
||||||
|
"ours_rp_dead_repo_blacklist_entries",
|
||||||
|
"Dead-repo transport blacklist entries (latest run)",
|
||||||
|
&[label("instance", instance), label("state", "total")],
|
||||||
|
value as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(value) = latest.dead_repo_blacklist_blacklisted {
|
||||||
|
writer.gauge(
|
||||||
|
"ours_rp_dead_repo_blacklist_entries",
|
||||||
|
"Dead-repo transport blacklist entries (latest run)",
|
||||||
|
&[label("instance", instance), label("state", "blacklisted")],
|
||||||
|
value as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
for (stage, value) in &latest.stage_seconds {
|
for (stage, value) in &latest.stage_seconds {
|
||||||
writer.gauge(
|
writer.gauge(
|
||||||
"ours_rp_run_stage_duration_seconds",
|
"ours_rp_run_stage_duration_seconds",
|
||||||
@@ -3893,4 +3915,37 @@ mod tests {
|
|||||||
});
|
});
|
||||||
encode_content_info(&ci).expect("encode ccr")
|
encode_content_info(&ci).expect("encode ccr")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_repo_blacklist_gauges_render_from_latest_summary() {
|
||||||
|
let td = TempDir::new().expect("tempdir");
|
||||||
|
write_success_run(td.path(), "run_0001", 1);
|
||||||
|
// Rewrite the summary with the blacklist section present.
|
||||||
|
let run = td.path().join("runs").join("run_0001");
|
||||||
|
fs::write(
|
||||||
|
run.join("run-summary.json"),
|
||||||
|
r#"{"runSeq":1,"runId":"run_0001","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0,"deadRepoBlacklist":{"path":"/state/dead-repo-blacklist.json","entries":5,"blacklisted":3}}"#,
|
||||||
|
)
|
||||||
|
.expect("summary");
|
||||||
|
let snapshot = scan_run_root(td.path(), "test").expect("scan");
|
||||||
|
let latest = snapshot.latest_run.as_ref().expect("latest run");
|
||||||
|
assert_eq!(latest.dead_repo_blacklist_entries, Some(5));
|
||||||
|
assert_eq!(latest.dead_repo_blacklist_blacklisted, Some(3));
|
||||||
|
let metrics = render_metrics(&snapshot);
|
||||||
|
assert!(metrics.contains(
|
||||||
|
r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="total"} 5"#
|
||||||
|
));
|
||||||
|
assert!(metrics.contains(
|
||||||
|
r#"ours_rp_dead_repo_blacklist_entries{instance="test",state="blacklisted"} 3"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_repo_blacklist_gauges_absent_without_section() {
|
||||||
|
let td = TempDir::new().expect("tempdir");
|
||||||
|
write_success_run(td.path(), "run_0001", 1);
|
||||||
|
let snapshot = scan_run_root(td.path(), "test").expect("scan");
|
||||||
|
let metrics = render_metrics(&snapshot);
|
||||||
|
assert!(!metrics.contains("ours_rp_dead_repo_blacklist_entries"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
|
||||||
|
use crate::parallel::types::RepoTransportMode;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct Args {
|
struct Args {
|
||||||
state_root: PathBuf,
|
state_root: PathBuf,
|
||||||
@@ -21,6 +24,12 @@ struct Args {
|
|||||||
db_stats_bin: Option<PathBuf>,
|
db_stats_bin: Option<PathBuf>,
|
||||||
db_stats_exact_every: Option<u64>,
|
db_stats_exact_every: Option<u64>,
|
||||||
time_bin: Option<PathBuf>,
|
time_bin: Option<PathBuf>,
|
||||||
|
/// Dead-repo blacklist (#141): enables daemon-side health checks and
|
||||||
|
/// auto-injects the child `--dead-repo-blacklist` flag when absent.
|
||||||
|
dead_repo_blacklist: Option<PathBuf>,
|
||||||
|
dead_repo_blacklist_fail_threshold: Option<u32>,
|
||||||
|
dead_repo_health_check_interval_secs: u64,
|
||||||
|
dead_repo_probe_rsync_bin: PathBuf,
|
||||||
child_args: Vec<String>,
|
child_args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +61,23 @@ struct DaemonStatus {
|
|||||||
current_run_seq: Option<u64>,
|
current_run_seq: Option<u64>,
|
||||||
current_run_id: Option<String>,
|
current_run_id: Option<String>,
|
||||||
last_run_id: Option<String>,
|
last_run_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
dead_repo_blacklist: Option<DeadRepoBlacklistStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeadRepoBlacklistStatus {
|
||||||
|
path: String,
|
||||||
|
entries: usize,
|
||||||
|
blacklisted: usize,
|
||||||
|
last_health_check_at_rfc3339_utc: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable daemon-side health check bookkeeping (#141).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
struct DeadRepoHealthRuntime {
|
||||||
|
last_health_check_at: Option<time::OffsetDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||||
@@ -145,6 +171,16 @@ struct RunSummary {
|
|||||||
db_stats: Vec<DbStatsSummary>,
|
db_stats: Vec<DbStatsSummary>,
|
||||||
retention_deleted_runs: Vec<String>,
|
retention_deleted_runs: Vec<String>,
|
||||||
artifacts: Vec<ArtifactInfo>,
|
artifacts: Vec<ArtifactInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
dead_repo_blacklist: Option<DeadRepoBlacklistRunSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeadRepoBlacklistRunSummary {
|
||||||
|
path: String,
|
||||||
|
entries: usize,
|
||||||
|
blacklisted: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
@@ -170,6 +206,17 @@ Options:
|
|||||||
Run db_stats --exact every n runs (default: disabled)
|
Run db_stats --exact every n runs (default: disabled)
|
||||||
--time-bin <path> GNU time binary for child process metrics (default: /usr/bin/time when present)
|
--time-bin <path> GNU time binary for child process metrics (default: /usr/bin/time when present)
|
||||||
--no-time-wrapper Disable GNU time wrapper
|
--no-time-wrapper Disable GNU time wrapper
|
||||||
|
--dead-repo-blacklist <path>
|
||||||
|
Enable the dead-repo transport blacklist (#141): daemon probes
|
||||||
|
blacklisted entries between runs and removes revived repos;
|
||||||
|
auto-injects the child flag when not already present
|
||||||
|
--dead-repo-blacklist-fail-threshold <n>
|
||||||
|
Admission threshold forwarded to the child when its args do not
|
||||||
|
already set it (default: child default 3)
|
||||||
|
--dead-repo-health-check-interval-secs <n>
|
||||||
|
Minimum seconds between health check sweeps (default: 600)
|
||||||
|
--dead-repo-probe-rsync-bin <path>
|
||||||
|
rsync binary used for health probes (default: rsync)
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
Child argument placeholders:
|
Child argument placeholders:
|
||||||
@@ -211,12 +258,16 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
|||||||
let mut db_stats_exact_every = None;
|
let mut db_stats_exact_every = None;
|
||||||
let mut time_bin = default_time_bin();
|
let mut time_bin = default_time_bin();
|
||||||
let mut no_time_wrapper = false;
|
let mut no_time_wrapper = false;
|
||||||
|
let mut dead_repo_blacklist: Option<PathBuf> = None;
|
||||||
|
let mut dead_repo_blacklist_fail_threshold: Option<u32> = None;
|
||||||
|
let mut dead_repo_health_check_interval_secs = 600u64;
|
||||||
|
let mut dead_repo_probe_rsync_bin = PathBuf::from("rsync");
|
||||||
|
|
||||||
let mut i = 1usize;
|
let mut i = 1usize;
|
||||||
while i < argv.len() {
|
while i < argv.len() {
|
||||||
match argv[i].as_str() {
|
match argv[i].as_str() {
|
||||||
"--" => {
|
"--" => {
|
||||||
let child_args = argv[i + 1..].to_vec();
|
let mut child_args = argv[i + 1..].to_vec();
|
||||||
let state_root =
|
let state_root =
|
||||||
state_root.ok_or_else(|| format!("--state-root is required\n\n{}", usage()))?;
|
state_root.ok_or_else(|| format!("--state-root is required\n\n{}", usage()))?;
|
||||||
let work_db = work_db.unwrap_or_else(|| state_root.join("state").join("work-db"));
|
let work_db = work_db.unwrap_or_else(|| state_root.join("state").join("work-db"));
|
||||||
@@ -225,6 +276,23 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
|||||||
if no_time_wrapper {
|
if no_time_wrapper {
|
||||||
time_bin = None;
|
time_bin = None;
|
||||||
}
|
}
|
||||||
|
// Dead-repo blacklist (#141): forward enablement to the child
|
||||||
|
// unless its args already carry the flag explicitly.
|
||||||
|
if let Some(path) = dead_repo_blacklist.as_ref() {
|
||||||
|
if !child_args.iter().any(|arg| arg == "--dead-repo-blacklist") {
|
||||||
|
child_args.push("--dead-repo-blacklist".to_string());
|
||||||
|
child_args.push(path_string(path));
|
||||||
|
}
|
||||||
|
if let Some(threshold) = dead_repo_blacklist_fail_threshold {
|
||||||
|
if !child_args
|
||||||
|
.iter()
|
||||||
|
.any(|arg| arg == "--dead-repo-blacklist-fail-threshold")
|
||||||
|
{
|
||||||
|
child_args.push("--dead-repo-blacklist-fail-threshold".to_string());
|
||||||
|
child_args.push(threshold.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let args = Args {
|
let args = Args {
|
||||||
state_root,
|
state_root,
|
||||||
rpki_bin: rpki_bin
|
rpki_bin: rpki_bin
|
||||||
@@ -240,6 +308,10 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
|||||||
db_stats_bin,
|
db_stats_bin,
|
||||||
db_stats_exact_every,
|
db_stats_exact_every,
|
||||||
time_bin,
|
time_bin,
|
||||||
|
dead_repo_blacklist,
|
||||||
|
dead_repo_blacklist_fail_threshold,
|
||||||
|
dead_repo_health_check_interval_secs,
|
||||||
|
dead_repo_probe_rsync_bin,
|
||||||
child_args,
|
child_args,
|
||||||
};
|
};
|
||||||
return validate_args(args);
|
return validate_args(args);
|
||||||
@@ -315,6 +387,33 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
|||||||
"--no-time-wrapper" => {
|
"--no-time-wrapper" => {
|
||||||
no_time_wrapper = true;
|
no_time_wrapper = true;
|
||||||
}
|
}
|
||||||
|
"--dead-repo-blacklist" => {
|
||||||
|
i += 1;
|
||||||
|
dead_repo_blacklist = Some(PathBuf::from(value_at(argv, i, "--dead-repo-blacklist")?));
|
||||||
|
}
|
||||||
|
"--dead-repo-blacklist-fail-threshold" => {
|
||||||
|
i += 1;
|
||||||
|
let parsed = parse_u64(
|
||||||
|
value_at(argv, i, "--dead-repo-blacklist-fail-threshold")?,
|
||||||
|
"--dead-repo-blacklist-fail-threshold",
|
||||||
|
)?;
|
||||||
|
if parsed == 0 || parsed > u32::MAX as u64 {
|
||||||
|
return Err("--dead-repo-blacklist-fail-threshold must be in 1..=u32::MAX".to_string());
|
||||||
|
}
|
||||||
|
dead_repo_blacklist_fail_threshold = Some(parsed as u32);
|
||||||
|
}
|
||||||
|
"--dead-repo-health-check-interval-secs" => {
|
||||||
|
i += 1;
|
||||||
|
dead_repo_health_check_interval_secs = parse_u64(
|
||||||
|
value_at(argv, i, "--dead-repo-health-check-interval-secs")?,
|
||||||
|
"--dead-repo-health-check-interval-secs",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"--dead-repo-probe-rsync-bin" => {
|
||||||
|
i += 1;
|
||||||
|
dead_repo_probe_rsync_bin =
|
||||||
|
PathBuf::from(value_at(argv, i, "--dead-repo-probe-rsync-bin")?);
|
||||||
|
}
|
||||||
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
|
other => return Err(format!("unknown argument: {other}\n\n{}", usage())),
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -330,6 +429,11 @@ fn validate_args(args: Args) -> Result<Args, String> {
|
|||||||
usage()
|
usage()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if args.dead_repo_blacklist_fail_threshold.is_some() && args.dead_repo_blacklist.is_none() {
|
||||||
|
return Err(
|
||||||
|
"--dead-repo-blacklist-fail-threshold requires --dead-repo-blacklist".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(args)
|
Ok(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,10 +491,19 @@ fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String>
|
|||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
|
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let file =
|
let bytes = serde_json::to_vec_pretty(value)
|
||||||
File::create(path).map_err(|e| format!("create json failed: {}: {e}", path.display()))?;
|
.map_err(|e| format!("serialize json failed: {}: {e}", path.display()))?;
|
||||||
serde_json::to_writer_pretty(file, value)
|
// Atomic write (tmp + rename) so concurrent readers never see a torn file.
|
||||||
.map_err(|e| format!("write json failed: {}: {e}", path.display()))
|
let mut tmp_name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_os_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
let tmp_path = path.with_file_name(tmp_name);
|
||||||
|
fs::write(&tmp_path, &bytes)
|
||||||
|
.map_err(|e| format!("write json tmp failed: {}: {e}", tmp_path.display()))?;
|
||||||
|
fs::rename(&tmp_path, path)
|
||||||
|
.map_err(|e| format!("rename json tmp failed: {} -> {}: {e}", tmp_path.display(), path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||||
@@ -409,12 +522,30 @@ fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String>
|
|||||||
.map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
|
.map_err(|e| format!("flush jsonl failed: {}: {e}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dead_repo_blacklist_status(
|
||||||
|
args: &Args,
|
||||||
|
health: &DeadRepoHealthRuntime,
|
||||||
|
) -> Option<DeadRepoBlacklistStatus> {
|
||||||
|
let path = args.dead_repo_blacklist.as_ref()?;
|
||||||
|
let (blacklist, _) = DeadRepoBlacklist::load(path);
|
||||||
|
let last_health_check_at_rfc3339_utc = health
|
||||||
|
.last_health_check_at
|
||||||
|
.and_then(|t| format_rfc3339(t).ok());
|
||||||
|
Some(DeadRepoBlacklistStatus {
|
||||||
|
path: path_string(path),
|
||||||
|
entries: blacklist.len(),
|
||||||
|
blacklisted: blacklist.blacklisted_len(),
|
||||||
|
last_health_check_at_rfc3339_utc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn write_status(
|
fn write_status(
|
||||||
args: &Args,
|
args: &Args,
|
||||||
state: DaemonState,
|
state: DaemonState,
|
||||||
runs_completed: u64,
|
runs_completed: u64,
|
||||||
current: Option<&RunContext>,
|
current: Option<&RunContext>,
|
||||||
last_run_id: Option<String>,
|
last_run_id: Option<String>,
|
||||||
|
health: &DeadRepoHealthRuntime,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
|
let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
|
||||||
let status = DaemonStatus {
|
let status = DaemonStatus {
|
||||||
@@ -425,10 +556,131 @@ fn write_status(
|
|||||||
current_run_seq: current.map(|ctx| ctx.seq),
|
current_run_seq: current.map(|ctx| ctx.seq),
|
||||||
current_run_id: current.map(|ctx| ctx.run_id.clone()),
|
current_run_id: current.map(|ctx| ctx.run_id.clone()),
|
||||||
last_run_id,
|
last_run_id,
|
||||||
|
dead_repo_blacklist: dead_repo_blacklist_status(args, health),
|
||||||
};
|
};
|
||||||
write_json_pretty(&status_path(args), &status)
|
write_json_pretty(&status_path(args), &status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short-timeout HTTP GET of the RRDP notification file. Any HTTP status
|
||||||
|
/// (even 4xx) proves the transport is alive; only transport errors keep the
|
||||||
|
/// entry blacklisted.
|
||||||
|
fn probe_rrdp_transport(uri: &str) -> bool {
|
||||||
|
let config = crate::fetch::http::HttpFetcherConfig {
|
||||||
|
connect_timeout: Duration::from_secs(3),
|
||||||
|
timeout: Duration::from_secs(6),
|
||||||
|
large_body_timeout: Duration::from_secs(6),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let fetcher = match crate::fetch::http::BlockingHttpFetcher::new(config) {
|
||||||
|
Ok(fetcher) => fetcher,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
match fetcher.fetch_bytes(uri) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(err) => err.starts_with("http status"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rsync list-only probe: connecting to the daemon and listing the module
|
||||||
|
/// root is enough to prove the transport is alive.
|
||||||
|
fn probe_rsync_transport(rsync_bin: &Path, base_uri: &str) -> bool {
|
||||||
|
Command::new(rsync_bin)
|
||||||
|
.arg("--contimeout=5")
|
||||||
|
.arg("--timeout=8")
|
||||||
|
.arg(base_uri)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe due blacklist entries between runs (#141). Runs in the supervisor
|
||||||
|
/// loop while no child is active, preserving the single-writer assumption.
|
||||||
|
fn maybe_run_dead_repo_health_check(args: &Args, health: &mut DeadRepoHealthRuntime) {
|
||||||
|
let Some(path) = args.dead_repo_blacklist.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = utc_now();
|
||||||
|
if let Some(last) = health.last_health_check_at {
|
||||||
|
let elapsed = (now - last).whole_seconds();
|
||||||
|
if elapsed >= 0 && (elapsed as u64) < args.dead_repo_health_check_interval_secs {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut blacklist, warning) = DeadRepoBlacklist::load(&path);
|
||||||
|
if let Some(warning) = warning {
|
||||||
|
eprintln!("[dead-repo-health] {warning}");
|
||||||
|
}
|
||||||
|
let now_unix = now.unix_timestamp().max(0) as u64;
|
||||||
|
let due = blacklist.probe_due_entries(now_unix, args.dead_repo_health_check_interval_secs);
|
||||||
|
if due.is_empty() {
|
||||||
|
health.last_health_check_at = Some(now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Probe in parallel: in the docker soak the daemon runs once per run
|
||||||
|
// (--max-runs 1), so a serial sweep of ~12 dead entries would add up to a
|
||||||
|
// minute of latency to every run.
|
||||||
|
let rsync_bin = args.dead_repo_probe_rsync_bin.clone();
|
||||||
|
let probe_results: Vec<(RepoTransportMode, String, bool)> = due
|
||||||
|
.iter()
|
||||||
|
.map(|entry| (entry.transport, entry.uri.clone()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(transport, uri)| {
|
||||||
|
let rsync_bin = rsync_bin.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let alive = match transport {
|
||||||
|
RepoTransportMode::Rrdp => probe_rrdp_transport(&uri),
|
||||||
|
RepoTransportMode::Rsync => probe_rsync_transport(&rsync_bin, &uri),
|
||||||
|
};
|
||||||
|
(transport, uri, alive)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter_map(|handle| handle.join().ok())
|
||||||
|
.collect();
|
||||||
|
let mut removed = 0usize;
|
||||||
|
let mut still_dead = 0usize;
|
||||||
|
for (transport, uri, alive) in &probe_results {
|
||||||
|
if *alive {
|
||||||
|
blacklist.record_probe_success(*transport, uri);
|
||||||
|
removed += 1;
|
||||||
|
eprintln!("[dead-repo-health] removed {} {} (probe ok)", transport.as_str(), uri);
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_remove",
|
||||||
|
serde_json::json!({
|
||||||
|
"transport": transport.as_str(),
|
||||||
|
"uri": uri,
|
||||||
|
"reason": "health_probe_ok",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
blacklist.record_probe_failure(*transport, uri, now_unix);
|
||||||
|
still_dead += 1;
|
||||||
|
eprintln!(
|
||||||
|
"[dead-repo-health] still dead {} {} (probe failed)",
|
||||||
|
transport.as_str(),
|
||||||
|
uri
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(err) = blacklist.store_atomic(&path, now_unix) {
|
||||||
|
eprintln!(
|
||||||
|
"[dead-repo-health] persist blacklist failed: {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_health_check",
|
||||||
|
serde_json::json!({
|
||||||
|
"probed": probe_results.len(),
|
||||||
|
"removed": removed,
|
||||||
|
"still_dead": still_dead,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
health.last_health_check_at = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
fn render_child_args(args: &[String], daemon_args: &Args, ctx: &RunContext) -> Vec<String> {
|
fn render_child_args(args: &[String], daemon_args: &Args, ctx: &RunContext) -> Vec<String> {
|
||||||
args.iter()
|
args.iter()
|
||||||
.map(|arg| {
|
.map(|arg| {
|
||||||
@@ -634,6 +886,7 @@ fn run_child_once(args: &Args, ctx: &RunContext) -> Result<RunSummary, String> {
|
|||||||
db_stats: Vec::new(),
|
db_stats: Vec::new(),
|
||||||
retention_deleted_runs: Vec::new(),
|
retention_deleted_runs: Vec::new(),
|
||||||
artifacts,
|
artifacts,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
};
|
};
|
||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1284,14 @@ fn collect_post_run_metrics(args: &Args, ctx: &RunContext, summary: &mut RunSumm
|
|||||||
summary.path_stats = collect_state_path_stats(args, ctx);
|
summary.path_stats = collect_state_path_stats(args, ctx);
|
||||||
summary.db_stats = collect_db_stats(args, ctx);
|
summary.db_stats = collect_db_stats(args, ctx);
|
||||||
summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
|
summary.artifacts = collect_artifacts(&ctx.run_dir).unwrap_or_default();
|
||||||
|
if let Some(path) = args.dead_repo_blacklist.as_ref() {
|
||||||
|
let (blacklist, _) = DeadRepoBlacklist::load(path);
|
||||||
|
summary.dead_repo_blacklist = Some(DeadRepoBlacklistRunSummary {
|
||||||
|
path: path_string(path),
|
||||||
|
entries: blacklist.len(),
|
||||||
|
blacklisted: blacklist.blacklisted_len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_daemon(args: &Args) -> Result<(), String> {
|
fn run_daemon(args: &Args) -> Result<(), String> {
|
||||||
@@ -1050,13 +1311,17 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
|||||||
let mut runs_completed = 0u64;
|
let mut runs_completed = 0u64;
|
||||||
let mut next_seq = 1u64;
|
let mut next_seq = 1u64;
|
||||||
let mut last_run_id = None;
|
let mut last_run_id = None;
|
||||||
|
let mut health = DeadRepoHealthRuntime::default();
|
||||||
write_status(
|
write_status(
|
||||||
args,
|
args,
|
||||||
DaemonState::Starting,
|
DaemonState::Starting,
|
||||||
runs_completed,
|
runs_completed,
|
||||||
None,
|
None,
|
||||||
last_run_id.clone(),
|
last_run_id.clone(),
|
||||||
|
&health,
|
||||||
)?;
|
)?;
|
||||||
|
// Probe once at startup so revived repos are removed before the first run.
|
||||||
|
maybe_run_dead_repo_health_check(args, &mut health);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if args.max_runs.is_some_and(|max| runs_completed >= max) {
|
if args.max_runs.is_some_and(|max| runs_completed >= max) {
|
||||||
@@ -1069,6 +1334,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
|||||||
runs_completed,
|
runs_completed,
|
||||||
None,
|
None,
|
||||||
last_run_id.clone(),
|
last_run_id.clone(),
|
||||||
|
&health,
|
||||||
)?;
|
)?;
|
||||||
let ctx = make_run_context(args, next_seq, utc_now());
|
let ctx = make_run_context(args, next_seq, utc_now());
|
||||||
write_status(
|
write_status(
|
||||||
@@ -1077,6 +1343,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
|||||||
runs_completed,
|
runs_completed,
|
||||||
Some(&ctx),
|
Some(&ctx),
|
||||||
last_run_id.clone(),
|
last_run_id.clone(),
|
||||||
|
&health,
|
||||||
)?;
|
)?;
|
||||||
let mut summary = run_child_once(args, &ctx)?;
|
let mut summary = run_child_once(args, &ctx)?;
|
||||||
write_status(
|
write_status(
|
||||||
@@ -1085,6 +1352,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
|||||||
runs_completed,
|
runs_completed,
|
||||||
Some(&ctx),
|
Some(&ctx),
|
||||||
last_run_id.clone(),
|
last_run_id.clone(),
|
||||||
|
&health,
|
||||||
)?;
|
)?;
|
||||||
collect_post_run_metrics(args, &ctx, &mut summary);
|
collect_post_run_metrics(args, &ctx, &mut summary);
|
||||||
let removed = apply_retention(&args.state_root.join("runs"), args.retain_runs)?;
|
let removed = apply_retention(&args.state_root.join("runs"), args.retain_runs)?;
|
||||||
@@ -1099,19 +1367,29 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
|||||||
if args.max_runs.is_some_and(|max| runs_completed >= max) {
|
if args.max_runs.is_some_and(|max| runs_completed >= max) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Health probes run only while no child is active (single-writer).
|
||||||
|
maybe_run_dead_repo_health_check(args, &mut health);
|
||||||
write_status(
|
write_status(
|
||||||
args,
|
args,
|
||||||
DaemonState::Sleeping,
|
DaemonState::Sleeping,
|
||||||
runs_completed,
|
runs_completed,
|
||||||
None,
|
None,
|
||||||
last_run_id.clone(),
|
last_run_id.clone(),
|
||||||
|
&health,
|
||||||
)?;
|
)?;
|
||||||
if args.interval_secs > 0 {
|
if args.interval_secs > 0 {
|
||||||
std::thread::sleep(Duration::from_secs(args.interval_secs));
|
std::thread::sleep(Duration::from_secs(args.interval_secs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
write_status(args, DaemonState::Exited, runs_completed, None, last_run_id)
|
write_status(
|
||||||
|
args,
|
||||||
|
DaemonState::Exited,
|
||||||
|
runs_completed,
|
||||||
|
None,
|
||||||
|
last_run_id,
|
||||||
|
&health,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main_entry() -> i32 {
|
pub fn main_entry() -> i32 {
|
||||||
@@ -1154,6 +1432,10 @@ mod tests {
|
|||||||
db_stats_bin: None,
|
db_stats_bin: None,
|
||||||
db_stats_exact_every: None,
|
db_stats_exact_every: None,
|
||||||
time_bin: None,
|
time_bin: None,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
|
dead_repo_blacklist_fail_threshold: None,
|
||||||
|
dead_repo_health_check_interval_secs: 600,
|
||||||
|
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
|
||||||
child_args: vec!["--version".to_string()],
|
child_args: vec!["--version".to_string()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1330,6 +1612,10 @@ mod tests {
|
|||||||
db_stats_bin: None,
|
db_stats_bin: None,
|
||||||
db_stats_exact_every: None,
|
db_stats_exact_every: None,
|
||||||
time_bin: None,
|
time_bin: None,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
|
dead_repo_blacklist_fail_threshold: None,
|
||||||
|
dead_repo_health_check_interval_secs: 600,
|
||||||
|
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
|
||||||
child_args: vec![
|
child_args: vec![
|
||||||
"{state_root}/state/work-db".to_string(),
|
"{state_root}/state/work-db".to_string(),
|
||||||
"{run_out}/result.ccr".to_string(),
|
"{run_out}/result.ccr".to_string(),
|
||||||
@@ -1652,6 +1938,10 @@ mod tests {
|
|||||||
db_stats_bin: None,
|
db_stats_bin: None,
|
||||||
db_stats_exact_every: None,
|
db_stats_exact_every: None,
|
||||||
time_bin: None,
|
time_bin: None,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
|
dead_repo_blacklist_fail_threshold: None,
|
||||||
|
dead_repo_health_check_interval_secs: 600,
|
||||||
|
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
|
||||||
child_args: vec![
|
child_args: vec![
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
"echo stdout-{run_seq}; echo stderr-{run_seq} >&2; echo marker > {run_out}/marker.txt".to_string(),
|
"echo stdout-{run_seq}; echo stderr-{run_seq} >&2; echo marker > {run_out}/marker.txt".to_string(),
|
||||||
@@ -1743,6 +2033,10 @@ mod tests {
|
|||||||
db_stats_bin: Some(db_stats_bin),
|
db_stats_bin: Some(db_stats_bin),
|
||||||
db_stats_exact_every: Some(1),
|
db_stats_exact_every: Some(1),
|
||||||
time_bin: None,
|
time_bin: None,
|
||||||
|
dead_repo_blacklist: None,
|
||||||
|
dead_repo_blacklist_fail_threshold: None,
|
||||||
|
dead_repo_health_check_interval_secs: 600,
|
||||||
|
dead_repo_probe_rsync_bin: PathBuf::from("rsync"),
|
||||||
child_args: vec![
|
child_args: vec![
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
"mkdir -p {state_root}/state/work-db {state_root}/state/repo-bytes.db; \
|
"mkdir -p {state_root}/state/work-db {state_root}/state/repo-bytes.db; \
|
||||||
@@ -1781,4 +2075,130 @@ mod tests {
|
|||||||
.any(|item| item["label"] == "work_db" && item["exists"] == true)
|
.any(|item| item["label"] == "work_db" && item["exists"] == true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_repo_blacklist_flag_is_injected_into_child_args() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki_daemon".to_string(),
|
||||||
|
"--state-root".to_string(),
|
||||||
|
"/tmp/daemon".to_string(),
|
||||||
|
"--rpki-bin".to_string(),
|
||||||
|
"/bin/echo".to_string(),
|
||||||
|
"--dead-repo-blacklist".to_string(),
|
||||||
|
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
|
||||||
|
"--dead-repo-blacklist-fail-threshold".to_string(),
|
||||||
|
"2".to_string(),
|
||||||
|
"--".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"{state_root}/state/work-db".to_string(),
|
||||||
|
];
|
||||||
|
let args = parse_args(&argv).expect("parse");
|
||||||
|
assert_eq!(
|
||||||
|
args.child_args,
|
||||||
|
vec![
|
||||||
|
"--db".to_string(),
|
||||||
|
"{state_root}/state/work-db".to_string(),
|
||||||
|
"--dead-repo-blacklist".to_string(),
|
||||||
|
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
|
||||||
|
"--dead-repo-blacklist-fail-threshold".to_string(),
|
||||||
|
"2".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_repo_blacklist_injection_respects_existing_child_flags() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki_daemon".to_string(),
|
||||||
|
"--state-root".to_string(),
|
||||||
|
"/tmp/daemon".to_string(),
|
||||||
|
"--rpki-bin".to_string(),
|
||||||
|
"/bin/echo".to_string(),
|
||||||
|
"--dead-repo-blacklist".to_string(),
|
||||||
|
"/tmp/daemon/state/dead-repo-blacklist.json".to_string(),
|
||||||
|
"--".to_string(),
|
||||||
|
"--dead-repo-blacklist".to_string(),
|
||||||
|
"/custom/path.json".to_string(),
|
||||||
|
];
|
||||||
|
let args = parse_args(&argv).expect("parse");
|
||||||
|
assert_eq!(
|
||||||
|
args.child_args,
|
||||||
|
vec![
|
||||||
|
"--dead-repo-blacklist".to_string(),
|
||||||
|
"/custom/path.json".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dead_repo_threshold_without_blacklist_is_rejected() {
|
||||||
|
let argv = vec![
|
||||||
|
"rpki_daemon".to_string(),
|
||||||
|
"--state-root".to_string(),
|
||||||
|
"/tmp/daemon".to_string(),
|
||||||
|
"--rpki-bin".to_string(),
|
||||||
|
"/bin/echo".to_string(),
|
||||||
|
"--dead-repo-blacklist-fail-threshold".to_string(),
|
||||||
|
"2".to_string(),
|
||||||
|
"--".to_string(),
|
||||||
|
"--version".to_string(),
|
||||||
|
];
|
||||||
|
let err = parse_args(&argv).expect_err("parse should fail");
|
||||||
|
assert!(err.contains("requires --dead-repo-blacklist"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_check_keeps_dead_entry_and_removes_revived_entry() {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
// Revived RRDP host: one-shot HTTP server answering 200.
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let port = listener.local_addr().expect("local addr").port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
let _ = stream.read(&mut buf);
|
||||||
|
let body = b"<notification/>";
|
||||||
|
let _ = stream.write_all(
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let blacklist_path = td.path().join("dead-repo-blacklist.json");
|
||||||
|
let revived_uri = format!("http://127.0.0.1:{port}/notification.xml");
|
||||||
|
let dead_uri = "http://127.0.0.1:1/notification.xml".to_string();
|
||||||
|
let mut blacklist = DeadRepoBlacklist::new();
|
||||||
|
for uri in [&revived_uri, &dead_uri] {
|
||||||
|
blacklist.record_transport_failure(RepoTransportMode::Rrdp, uri, 100, 1, 256);
|
||||||
|
}
|
||||||
|
blacklist
|
||||||
|
.store_atomic(&blacklist_path, 100)
|
||||||
|
.expect("store blacklist");
|
||||||
|
|
||||||
|
let mut args = test_args(td.path().join("daemon"));
|
||||||
|
args.dead_repo_blacklist = Some(blacklist_path.clone());
|
||||||
|
args.dead_repo_health_check_interval_secs = 0;
|
||||||
|
let mut health = DeadRepoHealthRuntime::default();
|
||||||
|
maybe_run_dead_repo_health_check(&args, &mut health);
|
||||||
|
|
||||||
|
let (after, warning) = DeadRepoBlacklist::load(&blacklist_path);
|
||||||
|
assert!(warning.is_none());
|
||||||
|
assert!(!after.is_blacklisted(RepoTransportMode::Rrdp, &revived_uri));
|
||||||
|
assert!(after.is_blacklisted(RepoTransportMode::Rrdp, &dead_uri));
|
||||||
|
assert!(health.last_health_check_at.is_some());
|
||||||
|
|
||||||
|
// Status JSON exposes the blacklist section.
|
||||||
|
let status = dead_repo_blacklist_status(&args, &health).expect("status");
|
||||||
|
assert_eq!(status.entries, 1);
|
||||||
|
assert_eq!(status.blacklisted, 1);
|
||||||
|
assert!(status.last_health_check_at_rfc3339_utc.is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,9 +485,17 @@ fn verify_child_signature(
|
|||||||
child: &X509Certificate<'_>,
|
child: &X509Certificate<'_>,
|
||||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||||
) -> Result<(), CaPathError> {
|
) -> Result<(), CaPathError> {
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::ChildCaCert,
|
||||||
|
child.tbs_certificate.as_ref(),
|
||||||
|
child.signature_value.data.as_ref(),
|
||||||
|
issuer_spki.raw,
|
||||||
|
|| {
|
||||||
child
|
child
|
||||||
.verify_signature(Some(issuer_spki))
|
.verify_signature(Some(issuer_spki))
|
||||||
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
|
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_child_aki_matches_issuer_ski(
|
fn validate_child_aki_matches_issuer_ski(
|
||||||
@@ -2118,6 +2126,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_cache_stores_real_verify_success_and_skips_on_hit() {
|
||||||
|
let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock");
|
||||||
|
crate::crypto_sig_cache::clear_global();
|
||||||
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
|
let (issuer, child, other) = gen_issuer_and_child_der(td.path());
|
||||||
|
let issuer_cert = parse_x509_cert(&issuer).expect("x509 parse issuer");
|
||||||
|
let child_cert = parse_x509_cert(&child).expect("x509 parse child");
|
||||||
|
let other_cert = parse_x509_cert(&other).expect("x509 parse other");
|
||||||
|
|
||||||
|
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cache = std::sync::Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild(
|
||||||
|
cache_dir.path().join("work-db.crypto-sig-cache"),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache));
|
||||||
|
|
||||||
|
// Miss -> real verification runs and passes -> positive conclusion written.
|
||||||
|
verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki)
|
||||||
|
.expect("signature ok");
|
||||||
|
// Hit -> skipped (a second execution would also pass, so assert via stats).
|
||||||
|
verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki)
|
||||||
|
.expect("signature ok");
|
||||||
|
// Wrong issuer: real verification fails, nothing is written (no negative caching),
|
||||||
|
// so a retry executes the real verification again instead of inheriting anything.
|
||||||
|
for _ in 0..2 {
|
||||||
|
let err = verify_child_signature(&child_cert, &other_cert.tbs_certificate.subject_pki)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, CaPathError::ChildSignatureInvalid(_)),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = cache.summary();
|
||||||
|
let stats = summary
|
||||||
|
.per_point
|
||||||
|
.get("child_ca_cert")
|
||||||
|
.expect("child_ca_cert stats");
|
||||||
|
assert_eq!(stats.calls, 4);
|
||||||
|
assert_eq!(stats.would_hit, 1);
|
||||||
|
assert_eq!(stats.new_keys, 3);
|
||||||
|
assert_eq!(stats.verify_executed, 3);
|
||||||
|
assert_eq!(stats.verify_skipped, 1);
|
||||||
|
crate::crypto_sig_cache::clear_global();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() {
|
fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() {
|
||||||
use crate::data_model::rc::{AsIdOrRange, IpPrefix};
|
use crate::data_model::rc::{AsIdOrRange, IpPrefix};
|
||||||
|
|||||||
@@ -385,8 +385,16 @@ fn verify_ee_cert_signature(
|
|||||||
ee: &X509Certificate<'_>,
|
ee: &X509Certificate<'_>,
|
||||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||||
) -> Result<(), CertPathError> {
|
) -> Result<(), CertPathError> {
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertX509,
|
||||||
|
ee.tbs_certificate.as_ref(),
|
||||||
|
ee.signature_value.data.as_ref(),
|
||||||
|
issuer_spki.raw,
|
||||||
|
|| {
|
||||||
ee.verify_signature(Some(issuer_spki))
|
ee.verify_signature(Some(issuer_spki))
|
||||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_ee_cert_signature_fast(
|
fn verify_ee_cert_signature_fast(
|
||||||
@@ -394,12 +402,20 @@ fn verify_ee_cert_signature_fast(
|
|||||||
ee_signature_bytes: &[u8],
|
ee_signature_bytes: &[u8],
|
||||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||||
) -> Result<(), CertPathError> {
|
) -> Result<(), CertPathError> {
|
||||||
|
crate::crypto_sig_cache::verify_with_cache(
|
||||||
|
crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertFast,
|
||||||
|
ee_tbs_der,
|
||||||
|
ee_signature_bytes,
|
||||||
|
issuer_spki.raw,
|
||||||
|
|| {
|
||||||
let key = signature::UnparsedPublicKey::new(
|
let key = signature::UnparsedPublicKey::new(
|
||||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||||
&issuer_spki.subject_public_key.data,
|
&issuer_spki.subject_public_key.data,
|
||||||
);
|
);
|
||||||
key.verify(ee_tbs_der, ee_signature_bytes)
|
key.verify(ee_tbs_der, ee_signature_bytes)
|
||||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_ee_aki_matches_issuer_ski(
|
fn validate_ee_aki_matches_issuer_ski(
|
||||||
@@ -720,4 +736,145 @@ mod tests {
|
|||||||
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
|
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
|
||||||
.expect("crldp ok");
|
.expect("crldp ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_cache_hit_does_not_skip_crl_revocation_check() {
|
||||||
|
use crate::crypto_sig_cache::{
|
||||||
|
CryptoSigCache, CryptoSigVerifyPoint, compute_cache_key,
|
||||||
|
};
|
||||||
|
use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc};
|
||||||
|
use crate::data_model::crl::CrlExtensions;
|
||||||
|
|
||||||
|
let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock");
|
||||||
|
crate::crypto_sig_cache::clear_global();
|
||||||
|
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
let mut issuer = dummy_cert(
|
||||||
|
ResourceCertKind::Ca,
|
||||||
|
"CN=issuer",
|
||||||
|
"CN=issuer",
|
||||||
|
Some(vec![1]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
issuer.tbs.validity_not_before = now - time::Duration::days(1);
|
||||||
|
issuer.tbs.validity_not_after = now + time::Duration::days(1);
|
||||||
|
let mut ee_rc = dummy_cert(
|
||||||
|
ResourceCertKind::Ee,
|
||||||
|
"CN=ee",
|
||||||
|
"CN=issuer",
|
||||||
|
Some(vec![2]),
|
||||||
|
Some(vec![1]),
|
||||||
|
Some(vec!["rsync://example.test/issuer.cer"]),
|
||||||
|
Some(vec!["rsync://example.test/issuer.crl"]),
|
||||||
|
);
|
||||||
|
ee_rc.tbs.validity_not_before = now - time::Duration::days(1);
|
||||||
|
ee_rc.tbs.validity_not_after = now + time::Duration::days(1);
|
||||||
|
|
||||||
|
// Tampered "object": these bytes could never pass the real RSA verification, so an
|
||||||
|
// Ok outcome below proves the crypto step was skipped by the cache hit.
|
||||||
|
let ee = ResourceEeCertificate {
|
||||||
|
raw_der: Vec::new(),
|
||||||
|
subject_key_identifier: vec![2],
|
||||||
|
spki_der: Vec::new(),
|
||||||
|
rsa_public_modulus: Vec::new(),
|
||||||
|
rsa_public_exponent: Vec::new(),
|
||||||
|
tbs_certificate_der: b"tampered-tbs-bytes".to_vec(),
|
||||||
|
signature_bytes: b"tampered-signature-bytes".to_vec(),
|
||||||
|
key_usage_summary: EeKeyUsageSummary::DigitalSignatureOnly,
|
||||||
|
sia_signed_object_uris: Vec::new(),
|
||||||
|
resource_cert: ee_rc,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Any real RSA SPKI works as the verifier identity (the TA fixture is a valid RSA
|
||||||
|
// certificate); the SPKI DER only feeds the cache key here.
|
||||||
|
let ta_der = std::fs::read(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixtures/ta/apnic-ta.cer"
|
||||||
|
))
|
||||||
|
.expect("read ta fixture");
|
||||||
|
let (_, ta_x509) = X509Certificate::from_der(&ta_der).expect("parse ta fixture");
|
||||||
|
let issuer_spki = &ta_x509.tbs_certificate.subject_pki;
|
||||||
|
|
||||||
|
let crl = RpkixCrl {
|
||||||
|
raw_der: Vec::new(),
|
||||||
|
version: 2,
|
||||||
|
issuer_dn: "CN=issuer".to_string(),
|
||||||
|
signature_algorithm_oid: "1.2.840.113549.1.1.11".to_string(),
|
||||||
|
this_update: Asn1TimeUtc {
|
||||||
|
utc: now - time::Duration::hours(1),
|
||||||
|
encoding: Asn1TimeEncoding::UtcTime,
|
||||||
|
},
|
||||||
|
next_update: Asn1TimeUtc {
|
||||||
|
utc: now + time::Duration::hours(1),
|
||||||
|
encoding: Asn1TimeEncoding::UtcTime,
|
||||||
|
},
|
||||||
|
revoked_certs: Vec::new(),
|
||||||
|
extensions: CrlExtensions {
|
||||||
|
authority_key_identifier: vec![1],
|
||||||
|
crl_number: BigUnsigned { bytes_be: vec![1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cache = std::sync::Arc::new(CryptoSigCache::load_or_rebuild(
|
||||||
|
dir.path().join("work-db.crypto-sig-cache"),
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
cache.insert_positive(compute_cache_key(
|
||||||
|
CryptoSigVerifyPoint::EeCertFast,
|
||||||
|
b"tampered-tbs-bytes",
|
||||||
|
b"tampered-signature-bytes",
|
||||||
|
issuer_spki.raw,
|
||||||
|
));
|
||||||
|
crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache));
|
||||||
|
|
||||||
|
// EE serial is 1 (dummy_cert); revoke it. The revocation check runs before the
|
||||||
|
// signature step in this code path, so the result must be EeRevoked — never Ok —
|
||||||
|
// even though a positive signature conclusion is cached.
|
||||||
|
let revoked_serials: HashSet<Vec<u8>> = HashSet::from([vec![1u8]]);
|
||||||
|
let err = validate_signed_object_ee_cert_path_fast(
|
||||||
|
&ee,
|
||||||
|
&issuer,
|
||||||
|
issuer_spki,
|
||||||
|
&crl,
|
||||||
|
&revoked_serials,
|
||||||
|
Some("rsync://example.test/issuer.cer"),
|
||||||
|
Some("rsync://example.test/issuer.crl"),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, CertPathError::EeRevoked),
|
||||||
|
"cached signature conclusion must not bypass revocation: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not revoked: the cached positive conclusion lets validation pass even though the
|
||||||
|
// tampered bytes could never verify — proving the crypto step was skipped.
|
||||||
|
let not_revoked: HashSet<Vec<u8>> = HashSet::new();
|
||||||
|
validate_signed_object_ee_cert_path_fast(
|
||||||
|
&ee,
|
||||||
|
&issuer,
|
||||||
|
issuer_spki,
|
||||||
|
&crl,
|
||||||
|
¬_revoked,
|
||||||
|
Some("rsync://example.test/issuer.cer"),
|
||||||
|
Some("rsync://example.test/issuer.crl"),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.expect("cache hit skips the impossible verification");
|
||||||
|
|
||||||
|
let summary = cache.summary();
|
||||||
|
let stats = summary
|
||||||
|
.per_point
|
||||||
|
.get("ee_cert_fast")
|
||||||
|
.expect("ee_cert_fast stats");
|
||||||
|
assert_eq!(stats.verify_skipped, 1);
|
||||||
|
assert_eq!(
|
||||||
|
stats.verify_executed, 0,
|
||||||
|
"real verification must never run on cache hits"
|
||||||
|
);
|
||||||
|
crate::crypto_sig_cache::clear_global();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ impl PublicationPointData for PublicationPointSnapshot {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ManifestFreshError {
|
pub enum ManifestFreshError {
|
||||||
|
#[error(
|
||||||
|
"verification-only fallback replay selected current-instance VCIR: {manifest_rsync_uri}"
|
||||||
|
)]
|
||||||
|
VerificationOnlyForcedCurrentInstanceVcir { manifest_rsync_uri: String },
|
||||||
|
|
||||||
#[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")]
|
#[error("repo sync failed: {detail} (RFC 8182 §3.4.5; RFC 9286 §6.6)")]
|
||||||
RepoSyncFailed { detail: String },
|
RepoSyncFailed { detail: String },
|
||||||
|
|
||||||
@@ -222,7 +227,8 @@ impl ManifestFreshError {
|
|||||||
pub(crate) fn should_warn_when_current_instance_reused(&self) -> bool {
|
pub(crate) fn should_warn_when_current_instance_reused(&self) -> bool {
|
||||||
!matches!(
|
!matches!(
|
||||||
self,
|
self,
|
||||||
ManifestFreshError::RepoSyncFailed { .. }
|
ManifestFreshError::VerificationOnlyForcedCurrentInstanceVcir { .. }
|
||||||
|
| ManifestFreshError::RepoSyncFailed { .. }
|
||||||
| ManifestFreshError::MissingManifest { .. }
|
| ManifestFreshError::MissingManifest { .. }
|
||||||
| ManifestFreshError::MissingFile { .. }
|
| ManifestFreshError::MissingFile { .. }
|
||||||
)
|
)
|
||||||
@@ -633,7 +639,7 @@ pub(crate) fn try_build_fresh_publication_point_with_timing(
|
|||||||
> {
|
> {
|
||||||
let mut timing = FreshPublicationPointTimingBreakdown::default();
|
let mut timing = FreshPublicationPointTimingBreakdown::default();
|
||||||
let current_index_lock_started = std::time::Instant::now();
|
let current_index_lock_started = std::time::Instant::now();
|
||||||
let current_index_guard = current_repo_index.and_then(|handle| handle.lock().ok());
|
let current_index_guard = current_repo_index.and_then(|handle| handle.read().ok());
|
||||||
timing.current_index_lock_ms = current_index_lock_started.elapsed().as_millis() as u64;
|
timing.current_index_lock_ms = current_index_lock_started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
if !rsync_uri_is_under_publication_point(manifest_rsync_uri, publication_point_rsync_uri) {
|
if !rsync_uri_is_under_publication_point(manifest_rsync_uri, publication_point_rsync_uri) {
|
||||||
@@ -1199,6 +1205,7 @@ mod tests {
|
|||||||
sha256: manifest_sha256.to_string(),
|
sha256: manifest_sha256.to_string(),
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::SignedObject,
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
@@ -1207,6 +1214,7 @@ mod tests {
|
|||||||
sha256: locked_object_sha256.to_string(),
|
sha256: locked_object_sha256.to_string(),
|
||||||
object_type: Some("roa".to_string()),
|
object_type: Some("roa".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
@@ -1437,8 +1445,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
current_index
|
current_index
|
||||||
.lock()
|
.write()
|
||||||
.expect("index lock")
|
.expect("index write lock")
|
||||||
.apply_repository_view_entries(&entries)
|
.apply_repository_view_entries(&entries)
|
||||||
.expect("apply current index");
|
.expect("apply current index");
|
||||||
|
|
||||||
|
|||||||
@@ -4023,6 +4023,7 @@ mod tests {
|
|||||||
sha256: sha256_hex(issuer_der),
|
sha256: sha256_hex(issuer_der),
|
||||||
object_type: Some("cer".to_string()),
|
object_type: Some("cer".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::CurrentCrl,
|
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||||
@@ -4031,6 +4032,7 @@ mod tests {
|
|||||||
sha256: sha256_hex_from_32(&crl_hash),
|
sha256: sha256_hex_from_32(&crl_hash),
|
||||||
object_type: Some("crl".to_string()),
|
object_type: Some("crl".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
@@ -4543,6 +4545,7 @@ mod tests {
|
|||||||
sha256: "00".repeat(32),
|
sha256: "00".repeat(32),
|
||||||
object_type: Some("cer".to_string()),
|
object_type: Some("cer".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Rejected,
|
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
vcir.local_outputs.push(VcirLocalOutput {
|
vcir.local_outputs.push(VcirLocalOutput {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ fn snapshot_current_repo_objects(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
current_repo_index
|
current_repo_index
|
||||||
.and_then(|handle| handle.lock().ok().map(|idx| idx.snapshot_objects()))
|
.and_then(|handle| handle.read().ok().map(|idx| idx.snapshot_objects()))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,20 +328,73 @@ fn persist_transport_request_prefetch_snapshot(
|
|||||||
}
|
}
|
||||||
let snapshot = runtime.transport_prefetch_snapshot();
|
let snapshot = runtime.transport_prefetch_snapshot();
|
||||||
let recorded = snapshot.requests.len() as u64;
|
let recorded = snapshot.requests.len() as u64;
|
||||||
|
let persist_started = std::time::Instant::now();
|
||||||
store
|
store
|
||||||
.put_transport_prefetch_snapshot(&snapshot)
|
.put_transport_prefetch_snapshot(&snapshot)
|
||||||
.map_err(|err| TreeRunError::Runner(err.to_string()))?;
|
.map_err(|err| TreeRunError::Runner(err.to_string()))?;
|
||||||
|
let persist_ms = persist_started.elapsed().as_millis() as u64;
|
||||||
record_transport_prefetch_count(timing, "transport_prefetch_recorded_requests", recorded);
|
record_transport_prefetch_count(timing, "transport_prefetch_recorded_requests", recorded);
|
||||||
crate::progress_log::emit(
|
crate::progress_log::emit(
|
||||||
"phase1_repo_prefetch_persisted",
|
"phase1_repo_prefetch_persisted",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"recorded_requests": recorded,
|
"recorded_requests": recorded,
|
||||||
"schema_version": snapshot.schema_version,
|
"schema_version": snapshot.schema_version,
|
||||||
|
"persist_ms": persist_ms,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the dead-repo blacklist working copy at run end (#141). Failures
|
||||||
|
/// degrade to a progress event; a broken blacklist file must never fail a run.
|
||||||
|
fn persist_dead_repo_blacklist(
|
||||||
|
runtime: &Arc<dyn RepoSyncRuntime>,
|
||||||
|
timing: Option<&TimingHandle>,
|
||||||
|
) -> Result<(), RunTreeFromTalError> {
|
||||||
|
let Some((blacklist_config, blacklist)) = runtime.dead_repo_blacklist_state() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let persist_started = std::time::Instant::now();
|
||||||
|
let now_unix = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
match blacklist.store_atomic(&blacklist_config.path, now_unix) {
|
||||||
|
Ok(()) => {
|
||||||
|
let persist_ms = persist_started.elapsed().as_millis() as u64;
|
||||||
|
record_transport_prefetch_count(
|
||||||
|
timing,
|
||||||
|
"dead_repo_blacklist_entries",
|
||||||
|
blacklist.len() as u64,
|
||||||
|
);
|
||||||
|
record_transport_prefetch_count(
|
||||||
|
timing,
|
||||||
|
"dead_repo_blacklist_blacklisted",
|
||||||
|
blacklist.blacklisted_len() as u64,
|
||||||
|
);
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_persisted",
|
||||||
|
serde_json::json!({
|
||||||
|
"path": blacklist_config.path.display().to_string(),
|
||||||
|
"entries": blacklist.len(),
|
||||||
|
"blacklisted": blacklist.blacklisted_len(),
|
||||||
|
"persist_ms": persist_ms,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
crate::progress_log::emit(
|
||||||
|
"dead_repo_blacklist_persist_error",
|
||||||
|
serde_json::json!({
|
||||||
|
"path": blacklist_config.path.display().to_string(),
|
||||||
|
"error": err.to_string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn root_discovery_from_tal_input(
|
fn root_discovery_from_tal_input(
|
||||||
tal_input: &TalInputSpec,
|
tal_input: &TalInputSpec,
|
||||||
http_fetcher: &dyn Fetcher,
|
http_fetcher: &dyn Fetcher,
|
||||||
@@ -810,6 +863,7 @@ where
|
|||||||
run_tree_serial_audit(root, &runner, config)?
|
run_tree_serial_audit(root, &runner, config)?
|
||||||
};
|
};
|
||||||
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
|
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
|
||||||
|
persist_dead_repo_blacklist(&runtime, timing.as_ref())?;
|
||||||
let downloads = download_log.snapshot_events();
|
let downloads = download_log.snapshot_events();
|
||||||
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
||||||
Ok(RunTreeFromTalAuditOutput {
|
Ok(RunTreeFromTalAuditOutput {
|
||||||
@@ -933,6 +987,7 @@ where
|
|||||||
run_tree_serial_audit_multi_root(root_handles, &runner, config)?
|
run_tree_serial_audit_multi_root(root_handles, &runner, config)?
|
||||||
};
|
};
|
||||||
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
|
persist_transport_request_prefetch_snapshot(store.as_ref(), &runtime, config, timing.as_ref())?;
|
||||||
|
persist_dead_repo_blacklist(&runtime, timing.as_ref())?;
|
||||||
let downloads = download_log.snapshot_events();
|
let downloads = download_log.snapshot_events();
|
||||||
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
let download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
||||||
Ok(RunTreeFromTalAuditOutput {
|
Ok(RunTreeFromTalAuditOutput {
|
||||||
@@ -2392,8 +2447,8 @@ mod multi_tal_tests {
|
|||||||
fn snapshot_current_repo_objects_is_on_demand() {
|
fn snapshot_current_repo_objects_is_on_demand() {
|
||||||
let handle = CurrentRepoIndex::shared();
|
let handle = CurrentRepoIndex::shared();
|
||||||
handle
|
handle
|
||||||
.lock()
|
.write()
|
||||||
.expect("lock index")
|
.expect("write-lock index")
|
||||||
.apply_repository_view_entries(&[RepositoryViewEntry {
|
.apply_repository_view_entries(&[RepositoryViewEntry {
|
||||||
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||||
current_hash: Some("11".repeat(32)),
|
current_hash: Some("11".repeat(32)),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -511,7 +511,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
|||||||
|
|
||||||
fn current_hash_for_uri(&self, uri: &str) -> Option<[u8; 32]> {
|
fn current_hash_for_uri(&self, uri: &str) -> Option<[u8; 32]> {
|
||||||
if let Some(index) = self.current_repo_index.as_ref() {
|
if let Some(index) = self.current_repo_index.as_ref() {
|
||||||
if let Ok(index) = index.lock() {
|
if let Ok(index) = index.read() {
|
||||||
if let Some(entry) = index.get_by_uri(uri) {
|
if let Some(entry) = index.get_by_uri(uri) {
|
||||||
return Some(entry.current_hash);
|
return Some(entry.current_hash);
|
||||||
}
|
}
|
||||||
@@ -760,7 +760,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
|||||||
|
|
||||||
fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option<String> {
|
fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option<String> {
|
||||||
if let Some(index_handle) = self.current_repo_index.as_ref()
|
if let Some(index_handle) = self.current_repo_index.as_ref()
|
||||||
&& let Ok(index) = index_handle.lock()
|
&& let Ok(index) = index_handle.read()
|
||||||
&& let Some(entry) = index.get_by_uri(&ca.manifest_rsync_uri)
|
&& let Some(entry) = index.get_by_uri(&ca.manifest_rsync_uri)
|
||||||
{
|
{
|
||||||
return Some(entry.current_hash_hex.clone());
|
return Some(entry.current_hash_hex.clone());
|
||||||
@@ -807,6 +807,21 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
|||||||
repo_sync_ok: bool,
|
repo_sync_ok: bool,
|
||||||
repo_sync_err: Option<&str>,
|
repo_sync_err: Option<&str>,
|
||||||
) -> Result<FreshPublicationPointStage, FreshPublicationPointStageError> {
|
) -> Result<FreshPublicationPointStage, FreshPublicationPointStageError> {
|
||||||
|
if self
|
||||||
|
.policy
|
||||||
|
.verification_forced_vcir_reuse_manifest_uris
|
||||||
|
.contains(&ca.manifest_rsync_uri)
|
||||||
|
{
|
||||||
|
if let Some(timing) = self.timing.as_ref() {
|
||||||
|
timing.record_count("verification_forced_vcir_reuse_publication_points", 1);
|
||||||
|
}
|
||||||
|
return Err(FreshPublicationPointStageError {
|
||||||
|
error: ManifestFreshError::VerificationOnlyForcedCurrentInstanceVcir {
|
||||||
|
manifest_rsync_uri: ca.manifest_rsync_uri.clone(),
|
||||||
|
},
|
||||||
|
snapshot_prepare_ms: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
let snapshot_prepare_started = std::time::Instant::now();
|
let snapshot_prepare_started = std::time::Instant::now();
|
||||||
let issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
|
let issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
|
||||||
.map_err(|detail| FreshPublicationPointStageError {
|
.map_err(|detail| FreshPublicationPointStageError {
|
||||||
@@ -3840,6 +3855,28 @@ fn audit_result_from_vcir_status(status: VcirArtifactValidationStatus) -> AuditO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback detail for cached artifacts rejected by an earlier run whose cache
|
||||||
|
/// entry predates reject-reason recording (field added 2026-07-27).
|
||||||
|
const CACHED_REJECT_REASON_NOT_RECORDED: &str =
|
||||||
|
"rejected in an earlier validation run (reject reason not recorded in cache)";
|
||||||
|
|
||||||
|
/// Detail for an audit entry rebuilt from a cached artifact: the real reject
|
||||||
|
/// reason when the cache recorded one, an explicit fallback for legacy cache
|
||||||
|
/// entries, and `None` for non-rejected artifacts.
|
||||||
|
fn audit_detail_from_vcir_status(
|
||||||
|
status: VcirArtifactValidationStatus,
|
||||||
|
reject_reason: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
match status {
|
||||||
|
VcirArtifactValidationStatus::Rejected => Some(
|
||||||
|
reject_reason
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| CACHED_REJECT_REASON_NOT_RECORDED.to_string()),
|
||||||
|
),
|
||||||
|
VcirArtifactValidationStatus::Accepted | VcirArtifactValidationStatus::WarningOnly => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_publication_point_audit_from_snapshot(
|
fn build_publication_point_audit_from_snapshot(
|
||||||
ca: &CaInstanceHandle,
|
ca: &CaInstanceHandle,
|
||||||
source: PublicationPointSource,
|
source: PublicationPointSource,
|
||||||
@@ -4059,7 +4096,10 @@ fn build_publication_point_audit_from_vcir(
|
|||||||
sha256_hex: artifact.sha256.clone(),
|
sha256_hex: artifact.sha256.clone(),
|
||||||
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
||||||
result: audit_result_from_vcir_status(artifact.validation_status),
|
result: audit_result_from_vcir_status(artifact.validation_status),
|
||||||
detail: None,
|
detail: audit_detail_from_vcir_status(
|
||||||
|
artifact.validation_status,
|
||||||
|
artifact.reject_reason.as_deref(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4170,7 +4210,10 @@ fn build_publication_point_audit_from_publication_point_cache_projection(
|
|||||||
sha256_hex: artifact.sha256.clone(),
|
sha256_hex: artifact.sha256.clone(),
|
||||||
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
||||||
result: audit_result_from_vcir_status(artifact.validation_status),
|
result: audit_result_from_vcir_status(artifact.validation_status),
|
||||||
detail: None,
|
detail: audit_detail_from_vcir_status(
|
||||||
|
artifact.validation_status,
|
||||||
|
artifact.reject_reason.as_deref(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6003,9 +6046,9 @@ fn build_vcir_related_artifacts(
|
|||||||
objects: &crate::validation::objects::ObjectsOutput,
|
objects: &crate::validation::objects::ObjectsOutput,
|
||||||
child_audits: &[ObjectAuditEntry],
|
child_audits: &[ObjectAuditEntry],
|
||||||
) -> Vec<VcirRelatedArtifact> {
|
) -> Vec<VcirRelatedArtifact> {
|
||||||
let mut audit_by_uri: HashMap<&str, AuditObjectResult> = HashMap::new();
|
let mut audit_by_uri: HashMap<&str, &ObjectAuditEntry> = HashMap::new();
|
||||||
for entry in child_audits.iter().chain(objects.audit.iter()) {
|
for entry in child_audits.iter().chain(objects.audit.iter()) {
|
||||||
audit_by_uri.insert(entry.rsync_uri.as_str(), entry.result.clone());
|
audit_by_uri.insert(entry.rsync_uri.as_str(), entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut artifacts = Vec::with_capacity(pack.files.len() + 2);
|
let mut artifacts = Vec::with_capacity(pack.files.len() + 2);
|
||||||
@@ -6016,6 +6059,7 @@ fn build_vcir_related_artifacts(
|
|||||||
sha256: sha256_hex(&pack.manifest_bytes),
|
sha256: sha256_hex(&pack.manifest_bytes),
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
artifacts.push(VcirRelatedArtifact {
|
artifacts.push(VcirRelatedArtifact {
|
||||||
artifact_role: if ca.parent_manifest_rsync_uri.is_none() {
|
artifact_role: if ca.parent_manifest_rsync_uri.is_none() {
|
||||||
@@ -6035,13 +6079,20 @@ fn build_vcir_related_artifacts(
|
|||||||
}),
|
}),
|
||||||
object_type: Some("cer".to_string()),
|
object_type: Some("cer".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
for file in &pack.files {
|
for file in &pack.files {
|
||||||
let result = audit_by_uri
|
let audit_entry = audit_by_uri.get(file.rsync_uri.as_str()).copied();
|
||||||
.get(file.rsync_uri.as_str())
|
let result = audit_entry
|
||||||
.cloned()
|
.map(|entry| entry.result.clone())
|
||||||
.unwrap_or(AuditObjectResult::Ok);
|
.unwrap_or(AuditObjectResult::Ok);
|
||||||
|
let validation_status = audit_result_to_vcir_status(&result);
|
||||||
|
let reject_reason = if validation_status == VcirArtifactValidationStatus::Rejected {
|
||||||
|
audit_entry.and_then(|entry| entry.detail.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let (artifact_role, artifact_kind) = artifact_role_and_kind(file, current_crl_rsync_uri);
|
let (artifact_role, artifact_kind) = artifact_role_and_kind(file, current_crl_rsync_uri);
|
||||||
artifacts.push(VcirRelatedArtifact {
|
artifacts.push(VcirRelatedArtifact {
|
||||||
artifact_role,
|
artifact_role,
|
||||||
@@ -6049,7 +6100,8 @@ fn build_vcir_related_artifacts(
|
|||||||
uri: Some(file.rsync_uri.clone()),
|
uri: Some(file.rsync_uri.clone()),
|
||||||
sha256: sha256_hex_from_32(&file.sha256),
|
sha256: sha256_hex_from_32(&file.sha256),
|
||||||
object_type: object_type_from_uri(file.rsync_uri.as_str()),
|
object_type: object_type_from_uri(file.rsync_uri.as_str()),
|
||||||
validation_status: audit_result_to_vcir_status(&result),
|
validation_status,
|
||||||
|
reject_reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ fn sample_vcir_for_projection(
|
|||||||
sha256: manifest_hash,
|
sha256: manifest_hash,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::CurrentCrl,
|
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||||
@@ -716,6 +717,7 @@ fn sample_vcir_for_projection(
|
|||||||
sha256: current_crl_hash,
|
sha256: current_crl_hash,
|
||||||
object_type: Some("crl".to_string()),
|
object_type: Some("crl".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::ChildCaCert,
|
artifact_role: VcirArtifactRole::ChildCaCert,
|
||||||
@@ -724,6 +726,7 @@ fn sample_vcir_for_projection(
|
|||||||
sha256: child_cert_hash.to_string(),
|
sha256: child_cert_hash.to_string(),
|
||||||
object_type: Some("cer".to_string()),
|
object_type: Some("cer".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::SignedObject,
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
@@ -732,6 +735,7 @@ fn sample_vcir_for_projection(
|
|||||||
sha256: roa_hash,
|
sha256: roa_hash,
|
||||||
object_type: Some("roa".to_string()),
|
object_type: Some("roa".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
VcirRelatedArtifact {
|
VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::SignedObject,
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
@@ -740,6 +744,7 @@ fn sample_vcir_for_projection(
|
|||||||
sha256: aspa_hash,
|
sha256: aspa_hash,
|
||||||
object_type: Some("aspa".to_string()),
|
object_type: Some("aspa".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
@@ -1455,10 +1460,12 @@ fn build_vcir_related_artifacts_classifies_snapshot_files_and_audit_statuses() {
|
|||||||
&& artifact.artifact_role == VcirArtifactRole::ChildCaCert));
|
&& artifact.artifact_role == VcirArtifactRole::ChildCaCert));
|
||||||
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
||||||
== Some("rsync://example.test/repo/issuer/a.roa")
|
== Some("rsync://example.test/repo/issuer/a.roa")
|
||||||
&& artifact.validation_status == VcirArtifactValidationStatus::Rejected));
|
&& artifact.validation_status == VcirArtifactValidationStatus::Rejected
|
||||||
|
&& artifact.reject_reason.as_deref() == Some("bad roa")));
|
||||||
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
||||||
== Some("rsync://example.test/repo/issuer/a.asa")
|
== Some("rsync://example.test/repo/issuer/a.asa")
|
||||||
&& artifact.validation_status == VcirArtifactValidationStatus::WarningOnly));
|
&& artifact.validation_status == VcirArtifactValidationStatus::WarningOnly
|
||||||
|
&& artifact.reject_reason.is_none()));
|
||||||
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
assert!(artifacts.iter().any(|artifact| artifact.uri.as_deref()
|
||||||
== Some("rsync://example.test/repo/issuer/a.gbr")
|
== Some("rsync://example.test/repo/issuer/a.gbr")
|
||||||
&& artifact.artifact_kind == VcirArtifactKind::Gbr));
|
&& artifact.artifact_kind == VcirArtifactKind::Gbr));
|
||||||
@@ -3419,6 +3426,45 @@ fn runner_when_repo_sync_fails_uses_current_instance_vcir_and_keeps_children_emp
|
|||||||
.any(|w| w.message.contains("repo sync failed")),
|
.any(|w| w.message.contains("repo sync failed")),
|
||||||
"expected warning about repo sync failure"
|
"expected warning about repo sync failure"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verification-only replays have the frozen repository bytes available,
|
||||||
|
// so a fresh read would succeed and never naturally enter the fallback
|
||||||
|
// branch. A contract-selected URI must therefore select the same VCIR
|
||||||
|
// projection without attempting fresh manifest/object validation.
|
||||||
|
let mut forced_policy = policy.clone();
|
||||||
|
forced_policy
|
||||||
|
.verification_forced_vcir_reuse_manifest_uris
|
||||||
|
.insert(manifest_rsync_uri.clone());
|
||||||
|
let forced_runner = Rpkiv1PublicationPointRunner {
|
||||||
|
store: &store,
|
||||||
|
policy: &forced_policy,
|
||||||
|
http_fetcher: &NeverHttpFetcher,
|
||||||
|
rsync_fetcher: &LocalDirRsyncFetcher::new(&fixture_dir),
|
||||||
|
validation_time,
|
||||||
|
timing: None,
|
||||||
|
download_log: None,
|
||||||
|
replay_archive_index: None,
|
||||||
|
replay_delta_index: None,
|
||||||
|
rrdp_dedup: false,
|
||||||
|
rrdp_repo_cache: Mutex::new(HashMap::new()),
|
||||||
|
rsync_dedup: false,
|
||||||
|
rsync_repo_cache: Mutex::new(HashMap::new()),
|
||||||
|
current_repo_index: None,
|
||||||
|
repo_sync_runtime: None,
|
||||||
|
parallel_phase2_config: None,
|
||||||
|
parallel_roa_worker_pool: None,
|
||||||
|
ccr_accumulator: None,
|
||||||
|
persist_vcir: true,
|
||||||
|
enable_roa_validation_cache: false,
|
||||||
|
enable_child_certificate_validation_cache: false,
|
||||||
|
publication_point_cache_observe_only: false,
|
||||||
|
enable_publication_point_validation_cache: false,
|
||||||
|
};
|
||||||
|
let forced = forced_runner
|
||||||
|
.run_publication_point(&handle)
|
||||||
|
.expect("contract-selected fallback should reuse current-instance VCIR");
|
||||||
|
assert_eq!(forced.source, PublicationPointSource::VcirCurrentInstance);
|
||||||
|
assert!(forced.discovered_children.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4510,6 +4556,7 @@ fn build_objects_output_from_vcir_tracks_expired_and_invalid_cached_outputs() {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4732,6 +4779,173 @@ fn build_publication_point_audit_from_vcir_uses_vcir_metadata_and_overlays_child
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_publication_point_audit_from_vcir_restores_reject_reason_with_legacy_fallback() {
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
let child_cert_hash = sha256_hex(b"child-cert");
|
||||||
|
let mut vcir = sample_vcir_for_projection(now, &child_cert_hash);
|
||||||
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
|
artifact_kind: VcirArtifactKind::Roa,
|
||||||
|
uri: Some("rsync://example.test/repo/issuer/rejected-with-reason.roa".to_string()),
|
||||||
|
sha256: sha256_hex(b"rejected-with-reason"),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||||
|
reject_reason: Some("EE certificate path validation failed: test".to_string()),
|
||||||
|
});
|
||||||
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
|
artifact_kind: VcirArtifactKind::Roa,
|
||||||
|
uri: Some("rsync://example.test/repo/issuer/rejected-legacy.roa".to_string()),
|
||||||
|
sha256: sha256_hex(b"rejected-legacy"),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||||
|
reject_reason: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let ca = CaInstanceHandle {
|
||||||
|
depth: 0,
|
||||||
|
tal_id: "test-tal".to_string(),
|
||||||
|
parent_manifest_rsync_uri: None,
|
||||||
|
ca_certificate: CaCertificateRef::inline_der(Vec::new()),
|
||||||
|
ca_certificate_rsync_uri: None,
|
||||||
|
effective_ip_resources: None,
|
||||||
|
effective_as_resources: None,
|
||||||
|
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
|
||||||
|
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||||
|
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
|
||||||
|
rrdp_notification_uri: None,
|
||||||
|
};
|
||||||
|
let objects = crate::validation::objects::ObjectsOutput {
|
||||||
|
vrps: Vec::new(),
|
||||||
|
aspas: Vec::new(),
|
||||||
|
router_keys: Vec::new(),
|
||||||
|
local_outputs_cache: Vec::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
stats: crate::validation::objects::ObjectsStats::default(),
|
||||||
|
audit: Vec::new(),
|
||||||
|
roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(),
|
||||||
|
roa_cache_object_meta: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let audit = build_publication_point_audit_from_vcir(
|
||||||
|
&ca,
|
||||||
|
PublicationPointSource::VcirCurrentInstance,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&vcir),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&objects,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(audit.objects.iter().any(|entry| {
|
||||||
|
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-with-reason.roa"
|
||||||
|
&& matches!(entry.result, AuditObjectResult::Error)
|
||||||
|
&& entry.detail.as_deref() == Some("EE certificate path validation failed: test")
|
||||||
|
}));
|
||||||
|
assert!(audit.objects.iter().any(|entry| {
|
||||||
|
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-legacy.roa"
|
||||||
|
&& matches!(entry.result, AuditObjectResult::Error)
|
||||||
|
&& entry.detail.as_deref() == Some(CACHED_REJECT_REASON_NOT_RECORDED)
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
audit.objects.iter().all(|entry| {
|
||||||
|
!matches!(entry.result, AuditObjectResult::Ok) || entry.detail.is_none()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_publication_point_audit_from_pp_cache_projection_restores_reject_reason() {
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
let child_cert_hash = sha256_hex(b"child-cert");
|
||||||
|
let mut vcir = sample_vcir_for_projection(now, &child_cert_hash);
|
||||||
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
|
artifact_kind: VcirArtifactKind::Roa,
|
||||||
|
uri: Some("rsync://example.test/repo/issuer/rejected-with-reason.roa".to_string()),
|
||||||
|
sha256: sha256_hex(b"rejected-with-reason"),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||||
|
reject_reason: Some("EE certificate path validation failed: test".to_string()),
|
||||||
|
});
|
||||||
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
|
artifact_kind: VcirArtifactKind::Roa,
|
||||||
|
uri: Some("rsync://example.test/repo/issuer/rejected-legacy.roa".to_string()),
|
||||||
|
sha256: sha256_hex(b"rejected-legacy"),
|
||||||
|
object_type: Some("roa".to_string()),
|
||||||
|
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||||
|
reject_reason: None,
|
||||||
|
});
|
||||||
|
let projection = PublicationPointCacheProjection::from_vcir_with_context(
|
||||||
|
&vcir,
|
||||||
|
"rsync://example.test/repo/issuer/".to_string(),
|
||||||
|
None,
|
||||||
|
[0x11; 32],
|
||||||
|
[0x22; 32],
|
||||||
|
[0x33; 32],
|
||||||
|
[0x44; 32],
|
||||||
|
[0x55; 32],
|
||||||
|
)
|
||||||
|
.expect("build publication point projection");
|
||||||
|
|
||||||
|
let ca = CaInstanceHandle {
|
||||||
|
depth: 0,
|
||||||
|
tal_id: "test-tal".to_string(),
|
||||||
|
parent_manifest_rsync_uri: None,
|
||||||
|
ca_certificate: CaCertificateRef::inline_der(Vec::new()),
|
||||||
|
ca_certificate_rsync_uri: None,
|
||||||
|
effective_ip_resources: None,
|
||||||
|
effective_as_resources: None,
|
||||||
|
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
|
||||||
|
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||||
|
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
|
||||||
|
rrdp_notification_uri: None,
|
||||||
|
};
|
||||||
|
let objects = crate::validation::objects::ObjectsOutput {
|
||||||
|
vrps: Vec::new(),
|
||||||
|
aspas: Vec::new(),
|
||||||
|
router_keys: Vec::new(),
|
||||||
|
local_outputs_cache: Vec::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
stats: crate::validation::objects::ObjectsStats::default(),
|
||||||
|
audit: Vec::new(),
|
||||||
|
roa_cache_stats: crate::validation::objects::RoaValidationCacheStats::default(),
|
||||||
|
roa_cache_object_meta: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let audit = build_publication_point_audit_from_publication_point_cache_projection(
|
||||||
|
&ca,
|
||||||
|
PublicationPointSource::PublicationPointCache,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&projection,
|
||||||
|
now,
|
||||||
|
&[],
|
||||||
|
&objects,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(audit.objects.iter().any(|entry| {
|
||||||
|
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-with-reason.roa"
|
||||||
|
&& matches!(entry.result, AuditObjectResult::Error)
|
||||||
|
&& entry.detail.as_deref() == Some("EE certificate path validation failed: test")
|
||||||
|
}));
|
||||||
|
assert!(audit.objects.iter().any(|entry| {
|
||||||
|
entry.rsync_uri == "rsync://example.test/repo/issuer/rejected-legacy.roa"
|
||||||
|
&& matches!(entry.result, AuditObjectResult::Error)
|
||||||
|
&& entry.detail.as_deref() == Some(CACHED_REJECT_REASON_NOT_RECORDED)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_publication_point_audit_from_vcir_failed_no_cache_keeps_current_reject_only() {
|
fn build_publication_point_audit_from_vcir_failed_no_cache_keeps_current_reject_only() {
|
||||||
let now = time::OffsetDateTime::now_utc();
|
let now = time::OffsetDateTime::now_utc();
|
||||||
@@ -4948,6 +5162,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
|
|||||||
sha256: sha256_hex(b"dup-roa-1"),
|
sha256: sha256_hex(b"dup-roa-1"),
|
||||||
object_type: Some("roa".to_string()),
|
object_type: Some("roa".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
vcir.related_artifacts.push(VcirRelatedArtifact {
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::SignedObject,
|
artifact_role: VcirArtifactRole::SignedObject,
|
||||||
@@ -4956,6 +5171,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
|
|||||||
sha256: sha256_hex(b"dup-roa-2"),
|
sha256: sha256_hex(b"dup-roa-2"),
|
||||||
object_type: Some("roa".to_string()),
|
object_type: Some("roa".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
vcir.related_artifacts.push(VcirRelatedArtifact {
|
vcir.related_artifacts.push(VcirRelatedArtifact {
|
||||||
artifact_role: VcirArtifactRole::IssuerCert,
|
artifact_role: VcirArtifactRole::IssuerCert,
|
||||||
@@ -4964,6 +5180,7 @@ fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes
|
|||||||
sha256: sha256_hex(b"issuer-cert"),
|
sha256: sha256_hex(b"issuer-cert"),
|
||||||
object_type: Some("cer".to_string()),
|
object_type: Some("cer".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let ca = CaInstanceHandle {
|
let ca = CaInstanceHandle {
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ use sha2::{Digest, Sha256};
|
|||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
use crate::parallel::types::TalInputSpec;
|
use crate::parallel::types::TalInputSpec;
|
||||||
use crate::policy::Policy;
|
use crate::policy::{CaFailedFetchPolicy, Policy};
|
||||||
use crate::storage::{RepositoryBlobVerificationSummary, RocksStore};
|
use crate::fetch::rsync_system::RsyncScopePolicy;
|
||||||
|
use crate::storage::{
|
||||||
|
RepositoryBlobVerificationSummary, RocksStore, ValidatedCaInstanceResult,
|
||||||
|
VcirFailedFetchReuseIdentity, VcirReuseRecordsCleared,
|
||||||
|
};
|
||||||
|
|
||||||
pub const VALIDATION_CONTRACT_SCHEMA_VERSION: u32 = 1;
|
pub const VALIDATION_CONTRACT_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const CURRENT_STATE_BINDING_SCHEMA_VERSION: u32 = 1;
|
pub const CURRENT_STATE_BINDING_SCHEMA_VERSION: u32 = 1;
|
||||||
@@ -21,11 +25,44 @@ pub struct ValidationCacheContract {
|
|||||||
pub roa: bool,
|
pub roa: bool,
|
||||||
pub child_certificate: bool,
|
pub child_certificate: bool,
|
||||||
pub transport_prefetch: bool,
|
pub transport_prefetch: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub crypto_signature: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normal-run fallback inputs which verification-only is permitted to
|
||||||
|
/// replay. The payload remains in the source work-db; the contract binds the
|
||||||
|
/// selected VCIR and its failed-fetch identity by canonical CBOR hashes.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FallbackReplaySelection {
|
||||||
|
pub manifest_rsync_uri: String,
|
||||||
|
pub vcir_sha256: String,
|
||||||
|
pub reuse_identity_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FallbackReplayMaterialization {
|
||||||
|
pub contract_selection_count: u64,
|
||||||
|
pub source_vcir_records_cleared: u64,
|
||||||
|
pub source_reuse_identity_records_cleared: u64,
|
||||||
|
pub imported_selection_count: u64,
|
||||||
|
pub scratch_vcir_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FallbackReplaySelectionComparison {
|
||||||
|
pub matches: bool,
|
||||||
|
pub expected: Vec<FallbackReplaySelection>,
|
||||||
|
pub actual: Vec<FallbackReplaySelection>,
|
||||||
|
pub missing: Vec<FallbackReplaySelection>,
|
||||||
|
pub unexpected: Vec<FallbackReplaySelection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ValidationCacheContract {
|
impl ValidationCacheContract {
|
||||||
pub fn any_validation_cache_enabled(&self) -> bool {
|
pub fn any_validation_cache_enabled(&self) -> bool {
|
||||||
self.publication_point || self.roa || self.child_certificate
|
self.publication_point || self.roa || self.child_certificate || self.crypto_signature
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +76,13 @@ pub struct ValidationContract {
|
|||||||
pub max_ca_depth: usize,
|
pub max_ca_depth: usize,
|
||||||
pub max_instances: Option<usize>,
|
pub max_instances: Option<usize>,
|
||||||
pub cache: ValidationCacheContract,
|
pub cache: ValidationCacheContract,
|
||||||
|
/// Source transport scope. With `transportPrefetch=true`, verification-only
|
||||||
|
/// replays this scope over the frozen repository view rather than deriving
|
||||||
|
/// a new publication-point scope.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rsync_scope_policy: RsyncScopePolicy,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub fallback_replay_selections: Vec<FallbackReplaySelection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -89,6 +133,7 @@ impl ValidationContract {
|
|||||||
max_ca_depth: usize,
|
max_ca_depth: usize,
|
||||||
max_instances: Option<usize>,
|
max_instances: Option<usize>,
|
||||||
cache: ValidationCacheContract,
|
cache: ValidationCacheContract,
|
||||||
|
rsync_scope_policy: RsyncScopePolicy,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
schema_version: VALIDATION_CONTRACT_SCHEMA_VERSION,
|
schema_version: VALIDATION_CONTRACT_SCHEMA_VERSION,
|
||||||
@@ -98,6 +143,8 @@ impl ValidationContract {
|
|||||||
max_ca_depth,
|
max_ca_depth,
|
||||||
max_instances,
|
max_instances,
|
||||||
cache,
|
cache,
|
||||||
|
rsync_scope_policy,
|
||||||
|
fallback_replay_selections: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +166,40 @@ impl ValidationContract {
|
|||||||
if self.max_ca_depth == 0 {
|
if self.max_ca_depth == 0 {
|
||||||
return Err("validation contract maxCaDepth must be greater than zero".to_string());
|
return Err("validation contract maxCaDepth must be greater than zero".to_string());
|
||||||
}
|
}
|
||||||
|
if !self.fallback_replay_selections.is_empty()
|
||||||
|
&& self.policy.ca_failed_fetch_policy != CaFailedFetchPolicy::ReuseCurrentInstanceVcir
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"validation contract fallbackReplaySelections require caFailedFetchPolicy=reuse_current_instance_vcir"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut previous: Option<&str> = None;
|
||||||
|
for selection in &self.fallback_replay_selections {
|
||||||
|
if !selection.manifest_rsync_uri.starts_with("rsync://") {
|
||||||
|
return Err(format!(
|
||||||
|
"validation contract fallbackReplaySelections manifestRsyncUri must be an rsync URI: {}",
|
||||||
|
selection.manifest_rsync_uri
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_sha256_hex(
|
||||||
|
"validation contract fallbackReplaySelections vcirSha256",
|
||||||
|
&selection.vcir_sha256,
|
||||||
|
)?;
|
||||||
|
validate_sha256_hex(
|
||||||
|
"validation contract fallbackReplaySelections reuseIdentitySha256",
|
||||||
|
&selection.reuse_identity_sha256,
|
||||||
|
)?;
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
if previous >= selection.manifest_rsync_uri.as_str() {
|
||||||
|
return Err(
|
||||||
|
"validation contract fallbackReplaySelections must be sorted and unique by manifestRsyncUri"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous = Some(&selection.manifest_rsync_uri);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +276,7 @@ pub struct PreparedVerification {
|
|||||||
pub cir: crate::cir::CanonicalInputRepresentation,
|
pub cir: crate::cir::CanonicalInputRepresentation,
|
||||||
pub contract: ValidationContract,
|
pub contract: ValidationContract,
|
||||||
pub blob_summary: RepositoryBlobVerificationSummary,
|
pub blob_summary: RepositoryBlobVerificationSummary,
|
||||||
|
pub fallback_replay_materialization: FallbackReplayMaterialization,
|
||||||
pub store: Option<Arc<RocksStore>>,
|
pub store: Option<Arc<RocksStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +314,8 @@ pub struct VerificationRunMeta {
|
|||||||
pub repository_objects_checked: u64,
|
pub repository_objects_checked: u64,
|
||||||
pub repository_bytes_checked: u64,
|
pub repository_bytes_checked: u64,
|
||||||
pub state_digest_match: bool,
|
pub state_digest_match: bool,
|
||||||
|
pub fallback_replay_selection_match: bool,
|
||||||
|
pub fallback_replay_materialization: FallbackReplayMaterialization,
|
||||||
pub scratch_retained: bool,
|
pub scratch_retained: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +387,174 @@ pub fn cir_tal_uris(cir: &crate::cir::CanonicalInputRepresentation) -> Vec<Strin
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive the exact fallback set used by a completed tree traversal and bind
|
||||||
|
/// every selected payload to its canonical storage representation.
|
||||||
|
pub fn collect_fallback_replay_selections(
|
||||||
|
store: &RocksStore,
|
||||||
|
publication_points: &[crate::audit::PublicationPointAudit],
|
||||||
|
) -> Result<Vec<FallbackReplaySelection>, String> {
|
||||||
|
let mut selections = Vec::new();
|
||||||
|
for publication_point in publication_points {
|
||||||
|
if publication_point.source != "vcir_current_instance" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selections.push(fallback_replay_selection_for_manifest(
|
||||||
|
store,
|
||||||
|
&publication_point.manifest_rsync_uri,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
selections.sort();
|
||||||
|
for pair in selections.windows(2) {
|
||||||
|
if pair[0].manifest_rsync_uri == pair[1].manifest_rsync_uri {
|
||||||
|
return Err(format!(
|
||||||
|
"multiple vcir_current_instance publication points use the same manifest URI: {}",
|
||||||
|
pair[0].manifest_rsync_uri
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fallback_replay_selection_for_manifest(
|
||||||
|
store: &RocksStore,
|
||||||
|
manifest_rsync_uri: &str,
|
||||||
|
) -> Result<FallbackReplaySelection, String> {
|
||||||
|
let vcir = store
|
||||||
|
.get_vcir(manifest_rsync_uri)
|
||||||
|
.map_err(|error| format!("load fallback VCIR failed for {manifest_rsync_uri}: {error}"))?
|
||||||
|
.ok_or_else(|| format!("fallback VCIR is missing for {manifest_rsync_uri}"))?;
|
||||||
|
let reuse_identity = store
|
||||||
|
.get_vcir_failed_fetch_reuse_identity(manifest_rsync_uri)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("load fallback VCIR reuse identity failed for {manifest_rsync_uri}: {error}")
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| format!("fallback VCIR reuse identity is missing for {manifest_rsync_uri}"))?;
|
||||||
|
fallback_replay_selection_from_values(&vcir, &reuse_identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_replay_selection_from_values(
|
||||||
|
vcir: &ValidatedCaInstanceResult,
|
||||||
|
reuse_identity: &VcirFailedFetchReuseIdentity,
|
||||||
|
) -> Result<FallbackReplaySelection, String> {
|
||||||
|
Ok(FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||||
|
vcir_sha256: canonical_cbor_sha256("fallback VCIR", vcir)?,
|
||||||
|
reuse_identity_sha256: canonical_cbor_sha256("fallback VCIR reuse identity", reuse_identity)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_cbor_sha256<T: Serialize>(label: &str, value: &T) -> Result<String, String> {
|
||||||
|
let bytes = serde_cbor::to_vec(value)
|
||||||
|
.map_err(|error| format!("serialize {label} for SHA-256 failed: {error}"))?;
|
||||||
|
Ok(hex::encode(Sha256::digest(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn materialize_fallback_replay_selections(
|
||||||
|
store: &RocksStore,
|
||||||
|
selections: &[FallbackReplaySelection],
|
||||||
|
) -> Result<FallbackReplayMaterialization, String> {
|
||||||
|
let mut extracted = Vec::with_capacity(selections.len());
|
||||||
|
for selection in selections {
|
||||||
|
let actual = fallback_replay_selection_for_manifest(store, &selection.manifest_rsync_uri)?;
|
||||||
|
if actual != *selection {
|
||||||
|
return Err(format!(
|
||||||
|
"fallback replay selection hash mismatch for {}: contract vcir={}, work-db vcir={}, contract identity={}, work-db identity={}",
|
||||||
|
selection.manifest_rsync_uri,
|
||||||
|
selection.vcir_sha256,
|
||||||
|
actual.vcir_sha256,
|
||||||
|
selection.reuse_identity_sha256,
|
||||||
|
actual.reuse_identity_sha256,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let vcir = store
|
||||||
|
.get_vcir(&selection.manifest_rsync_uri)
|
||||||
|
.map_err(|error| format!("reload selected fallback VCIR failed: {error}"))?
|
||||||
|
.expect("fallback selection was just verified as present");
|
||||||
|
let reuse_identity = store
|
||||||
|
.get_vcir_failed_fetch_reuse_identity(&selection.manifest_rsync_uri)
|
||||||
|
.map_err(|error| format!("reload selected fallback reuse identity failed: {error}"))?
|
||||||
|
.expect("fallback selection was just verified as present");
|
||||||
|
extracted.push((vcir, reuse_identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
let VcirReuseRecordsCleared {
|
||||||
|
vcir_records,
|
||||||
|
failed_fetch_identity_records,
|
||||||
|
} = store
|
||||||
|
.clear_vcir_reuse_records()
|
||||||
|
.map_err(|error| format!("clear scratch VCIR fallback records failed: {error}"))?;
|
||||||
|
for (vcir, reuse_identity) in &extracted {
|
||||||
|
store
|
||||||
|
.put_vcir_with_failed_fetch_reuse_identity(vcir, reuse_identity)
|
||||||
|
.map_err(|error| format!("import selected fallback VCIR failed: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actual = store
|
||||||
|
.list_vcirs()
|
||||||
|
.map_err(|error| format!("list scratch fallback VCIR records failed: {error}"))?
|
||||||
|
.iter()
|
||||||
|
.map(|vcir| {
|
||||||
|
let reuse_identity = store
|
||||||
|
.get_vcir_failed_fetch_reuse_identity(&vcir.manifest_rsync_uri)
|
||||||
|
.map_err(|error| format!("read materialized fallback reuse identity failed: {error}"))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"materialized fallback reuse identity is missing for {}",
|
||||||
|
vcir.manifest_rsync_uri
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
fallback_replay_selection_from_values(vcir, &reuse_identity)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
|
if actual != selections {
|
||||||
|
return Err(format!(
|
||||||
|
"scratch fallback replay allowlist differs from validation contract: expected={}, actual={}",
|
||||||
|
selections.len(),
|
||||||
|
actual.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FallbackReplayMaterialization {
|
||||||
|
contract_selection_count: selections.len() as u64,
|
||||||
|
source_vcir_records_cleared: vcir_records,
|
||||||
|
source_reuse_identity_records_cleared: failed_fetch_identity_records,
|
||||||
|
imported_selection_count: extracted.len() as u64,
|
||||||
|
scratch_vcir_count: actual.len() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare_fallback_replay_selections(
|
||||||
|
expected: &[FallbackReplaySelection],
|
||||||
|
actual: &[FallbackReplaySelection],
|
||||||
|
) -> FallbackReplaySelectionComparison {
|
||||||
|
let missing: Vec<FallbackReplaySelection> = expected
|
||||||
|
.iter()
|
||||||
|
.filter(|selection| !actual.contains(selection))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let unexpected: Vec<FallbackReplaySelection> = actual
|
||||||
|
.iter()
|
||||||
|
.filter(|selection| !expected.contains(selection))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
FallbackReplaySelectionComparison {
|
||||||
|
matches: missing.is_empty() && unexpected.is_empty(),
|
||||||
|
expected: expected.to_vec(),
|
||||||
|
actual: actual.to_vec(),
|
||||||
|
missing,
|
||||||
|
unexpected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_fallback_replay_selection_comparison(
|
||||||
|
path: &Path,
|
||||||
|
comparison: &FallbackReplaySelectionComparison,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let bytes = serde_json::to_vec_pretty(comparison)
|
||||||
|
.map_err(|error| format!("encode fallback replay selection comparison failed: {error}"))?;
|
||||||
|
atomic_write(path, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn prepare_verification(
|
pub fn prepare_verification(
|
||||||
source_run_dir: &Path,
|
source_run_dir: &Path,
|
||||||
source_state_root: &Path,
|
source_state_root: &Path,
|
||||||
@@ -358,7 +610,7 @@ pub fn prepare_verification(
|
|||||||
contract.require_current_binary()?;
|
contract.require_current_binary()?;
|
||||||
if !contract.cache.any_validation_cache_enabled() {
|
if !contract.cache.any_validation_cache_enabled() {
|
||||||
return Err(
|
return Err(
|
||||||
"source run did not enable PP, ROA, or child-certificate validation cache".to_string(),
|
"source run did not enable PP, ROA, child-certificate, or crypto signature validation cache".to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !artifacts.source_work_db.is_dir() {
|
if !artifacts.source_work_db.is_dir() {
|
||||||
@@ -389,6 +641,10 @@ pub fn prepare_verification(
|
|||||||
)
|
)
|
||||||
.map_err(|error| format!("open frozen verification store failed: {error}"))?,
|
.map_err(|error| format!("open frozen verification store failed: {error}"))?,
|
||||||
);
|
);
|
||||||
|
let fallback_replay_materialization = materialize_fallback_replay_selections(
|
||||||
|
store.as_ref(),
|
||||||
|
&contract.fallback_replay_selections,
|
||||||
|
)?;
|
||||||
let blob_summary = store
|
let blob_summary = store
|
||||||
.verify_current_repository_blobs(1024)
|
.verify_current_repository_blobs(1024)
|
||||||
.map_err(|error| format!("verify frozen repository blobs failed: {error}"))?;
|
.map_err(|error| format!("verify frozen repository blobs failed: {error}"))?;
|
||||||
@@ -399,6 +655,7 @@ pub fn prepare_verification(
|
|||||||
cir,
|
cir,
|
||||||
contract,
|
contract,
|
||||||
blob_summary,
|
blob_summary,
|
||||||
|
fallback_replay_materialization,
|
||||||
store: Some(store),
|
store: Some(store),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -645,7 +902,10 @@ mod tests {
|
|||||||
roa: true,
|
roa: true,
|
||||||
child_certificate: true,
|
child_certificate: true,
|
||||||
transport_prefetch: false,
|
transport_prefetch: false,
|
||||||
|
crypto_signature: false,
|
||||||
},
|
},
|
||||||
|
rsync_scope_policy: RsyncScopePolicy::default(),
|
||||||
|
fallback_replay_selections: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +1011,9 @@ mod tests {
|
|||||||
roa: false,
|
roa: false,
|
||||||
child_certificate: false,
|
child_certificate: false,
|
||||||
transport_prefetch: true,
|
transport_prefetch: true,
|
||||||
|
crypto_signature: false,
|
||||||
},
|
},
|
||||||
|
RsyncScopePolicy::default(),
|
||||||
)
|
)
|
||||||
.expect("contract");
|
.expect("contract");
|
||||||
write_validation_contract(&source_run.join("validation-contract.json"), &contract)
|
write_validation_contract(&source_run.join("validation-contract.json"), &contract)
|
||||||
@@ -795,6 +1057,60 @@ mod tests {
|
|||||||
assert!(contract.validate().is_err());
|
assert!(contract.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_replay_contract_requires_reuse_policy_and_sorted_unique_selections() {
|
||||||
|
let selection = FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||||
|
vcir_sha256: "11".repeat(32),
|
||||||
|
reuse_identity_sha256: "22".repeat(32),
|
||||||
|
};
|
||||||
|
let mut contract = sample_contract();
|
||||||
|
contract.policy.ca_failed_fetch_policy = CaFailedFetchPolicy::StopAllOutput;
|
||||||
|
contract.fallback_replay_selections = vec![selection.clone()];
|
||||||
|
assert!(contract
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("caFailedFetchPolicy"));
|
||||||
|
|
||||||
|
contract.policy.ca_failed_fetch_policy = CaFailedFetchPolicy::ReuseCurrentInstanceVcir;
|
||||||
|
contract.fallback_replay_selections = vec![
|
||||||
|
FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: "rsync://example.test/repo/z.mft".to_string(),
|
||||||
|
..selection.clone()
|
||||||
|
},
|
||||||
|
FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: "rsync://example.test/repo/a.mft".to_string(),
|
||||||
|
..selection.clone()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert!(contract.validate().unwrap_err().contains("sorted and unique"));
|
||||||
|
contract.fallback_replay_selections.sort();
|
||||||
|
contract.validate().expect("sorted selection contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_replay_selection_comparison_reports_exact_differences() {
|
||||||
|
let expected = vec![FallbackReplaySelection {
|
||||||
|
manifest_rsync_uri: "rsync://example.test/repo/a.mft".to_string(),
|
||||||
|
vcir_sha256: "11".repeat(32),
|
||||||
|
reuse_identity_sha256: "22".repeat(32),
|
||||||
|
}];
|
||||||
|
let mut actual = expected.clone();
|
||||||
|
actual[0].vcir_sha256 = "33".repeat(32);
|
||||||
|
let comparison = compare_fallback_replay_selections(&expected, &actual);
|
||||||
|
assert!(!comparison.matches);
|
||||||
|
assert_eq!(comparison.missing, expected);
|
||||||
|
assert_eq!(comparison.unexpected, actual);
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = temp.path().join("fallback-replay-selection.json");
|
||||||
|
write_fallback_replay_selection_comparison(&path, &comparison)
|
||||||
|
.expect("write fallback selection comparison");
|
||||||
|
assert!(fs::read_to_string(path)
|
||||||
|
.expect("read fallback selection comparison")
|
||||||
|
.contains("reuseIdentitySha256"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_contract_distinguishes_prefetch_from_validation_caches() {
|
fn cache_contract_distinguishes_prefetch_from_validation_caches() {
|
||||||
let cache = ValidationCacheContract {
|
let cache = ValidationCacheContract {
|
||||||
@@ -802,10 +1118,23 @@ mod tests {
|
|||||||
roa: false,
|
roa: false,
|
||||||
child_certificate: false,
|
child_certificate: false,
|
||||||
transport_prefetch: true,
|
transport_prefetch: true,
|
||||||
|
crypto_signature: false,
|
||||||
};
|
};
|
||||||
assert!(!cache.any_validation_cache_enabled());
|
assert!(!cache.any_validation_cache_enabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_contract_defaults_missing_rsync_scope_to_module_root() {
|
||||||
|
let mut value = serde_json::to_value(sample_contract()).expect("serialize contract");
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("contract object")
|
||||||
|
.remove("rsyncScopePolicy");
|
||||||
|
let decoded: ValidationContract =
|
||||||
|
serde_json::from_value(value).expect("decode older contract");
|
||||||
|
assert_eq!(decoded.rsync_scope_policy, RsyncScopePolicy::ModuleRoot);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validation_contract_and_binding_validation_cover_rejection_paths() {
|
fn validation_contract_and_binding_validation_cover_rejection_paths() {
|
||||||
let time = time::OffsetDateTime::parse(
|
let time = time::OffsetDateTime::parse(
|
||||||
@@ -819,6 +1148,7 @@ mod tests {
|
|||||||
32,
|
32,
|
||||||
Some(10),
|
Some(10),
|
||||||
sample_contract().cache,
|
sample_contract().cache,
|
||||||
|
RsyncScopePolicy::default(),
|
||||||
)
|
)
|
||||||
.expect("current contract");
|
.expect("current contract");
|
||||||
assert_eq!(contract.validation_time().expect("contract time"), time);
|
assert_eq!(contract.validation_time().expect("contract time"), time);
|
||||||
@@ -914,6 +1244,8 @@ mod tests {
|
|||||||
repository_objects_checked: 3,
|
repository_objects_checked: 3,
|
||||||
repository_bytes_checked: 4,
|
repository_bytes_checked: 4,
|
||||||
state_digest_match: true,
|
state_digest_match: true,
|
||||||
|
fallback_replay_selection_match: true,
|
||||||
|
fallback_replay_materialization: FallbackReplayMaterialization::default(),
|
||||||
scratch_retained: false,
|
scratch_retained: false,
|
||||||
};
|
};
|
||||||
let meta_path = temp.path().join("meta/result.json");
|
let meta_path = temp.path().join("meta/result.json");
|
||||||
@@ -1003,6 +1335,16 @@ mod tests {
|
|||||||
assert_eq!(prepared.blob_summary.current_objects, 0);
|
assert_eq!(prepared.blob_summary.current_objects, 0);
|
||||||
assert_eq!(prepared.blob_summary.bytes_verified, 0);
|
assert_eq!(prepared.blob_summary.bytes_verified, 0);
|
||||||
assert_eq!(prepared.contract.validation_time, "2026-07-16T00:00:00Z");
|
assert_eq!(prepared.contract.validation_time, "2026-07-16T00:00:00Z");
|
||||||
|
assert_eq!(
|
||||||
|
prepared
|
||||||
|
.fallback_replay_materialization
|
||||||
|
.contract_selection_count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prepared.fallback_replay_materialization.scratch_vcir_count,
|
||||||
|
0
|
||||||
|
);
|
||||||
assert_eq!(cir_tal_uris(&prepared.cir).len(), 1);
|
assert_eq!(cir_tal_uris(&prepared.cir).len(), 1);
|
||||||
assert!(prepared.artifacts.scratch_work_db.is_dir());
|
assert!(prepared.artifacts.scratch_work_db.is_dir());
|
||||||
assert!(prepared.artifacts.result_contract.is_file());
|
assert!(prepared.artifacts.result_contract.is_file());
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fn live_http_fetcher() -> BlockingHttpFetcher {
|
|||||||
BlockingHttpFetcher::new(HttpFetcherConfig {
|
BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||||
timeout: Duration::from_secs(timeout_secs),
|
timeout: Duration::from_secs(timeout_secs),
|
||||||
large_body_timeout: Duration::from_secs(timeout_secs),
|
large_body_timeout: Duration::from_secs(timeout_secs),
|
||||||
user_agent: "rpki-dev/0.1 (stage2 live rrdp delta test)".to_string(),
|
user_agent: "panda-rpki/0.2 (live rrdp delta test)".to_string(),
|
||||||
..HttpFetcherConfig::default()
|
..HttpFetcherConfig::default()
|
||||||
})
|
})
|
||||||
.expect("http fetcher")
|
.expect("http fetcher")
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ fn verify_against_vcir_store_matches_manifest_hashes() {
|
|||||||
sha256: manifest_hash.clone(),
|
sha256: manifest_hash.clone(),
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ fn store_validated_manifest_baseline(
|
|||||||
sha256: manifest_sha256,
|
sha256: manifest_sha256,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fn store_validated_manifest_baseline(
|
|||||||
sha256: manifest_sha256,
|
sha256: manifest_sha256,
|
||||||
object_type: Some("mft".to_string()),
|
object_type: Some("mft".to_string()),
|
||||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||||
|
reject_reason: None,
|
||||||
}],
|
}],
|
||||||
summary: VcirSummary {
|
summary: VcirSummary {
|
||||||
local_vrp_count: 0,
|
local_vrp_count: 0,
|
||||||
|
|||||||
@@ -51,11 +51,22 @@ async function parseErrorBody(res: Response): Promise<string> {
|
|||||||
return `HTTP ${res.status} ${res.statusText}`.trim();
|
return `HTTP ${res.status} ${res.statusText}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Default timeout for every API request — hung streams must not spin forever. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Combine the caller's abort signal (if any) with the default timeout. */
|
||||||
|
function requestSignal(signal?: AbortSignal | null): AbortSignal {
|
||||||
|
const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(
|
||||||
|
(s): s is AbortSignal => s != null,
|
||||||
|
);
|
||||||
|
return AbortSignal.any(signals);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
||||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(path, init);
|
res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
}
|
}
|
||||||
@@ -69,7 +80,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
|||||||
export async function apiFetchBlob(path: string): Promise<Blob> {
|
export async function apiFetchBlob(path: string): Promise<Blob> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(path);
|
res = await fetch(path, { signal: requestSignal() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,8 @@ export const manifestFileEntrySchema = z
|
|||||||
export const revokedCertEntrySchema = z
|
export const revokedCertEntrySchema = z
|
||||||
.object({
|
.object({
|
||||||
serialNumberHex: z.string().nullish(),
|
serialNumberHex: z.string().nullish(),
|
||||||
serialNumber: z.string().nullish(),
|
// Backend serializes CRL serials as a JSON number when they fit, else a string.
|
||||||
|
serialNumber: z.union([z.string(), z.number()]).nullish(),
|
||||||
revocationDate: z.string().nullish(),
|
revocationDate: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ async function getList<S extends z.ZodTypeAny>(
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function getServiceInfo(): Promise<ServiceInfo> {
|
export function getServiceInfo(): Promise<ServiceInfo> {
|
||||||
return getData(`${API}`, serviceInfoSchema);
|
// Trailing slash: the axum router serves the info document at `/api/v1/`.
|
||||||
|
return getData(`${API}/`, serviceInfoSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHealth(): Promise<HealthStatus> {
|
export function getHealth(): Promise<HealthStatus> {
|
||||||
@@ -116,9 +117,15 @@ export function getRunSummary(runId: string): Promise<RunSummary> {
|
|||||||
/* Repositories */
|
/* Repositories */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type RepoListParams = PageParams & {
|
||||||
|
/** Server-side sort; currently only "objects" is supported. */
|
||||||
|
sort?: "objects";
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
export function listRepos(
|
export function listRepos(
|
||||||
runId: string,
|
runId: string,
|
||||||
params: PageParams = {},
|
params: RepoListParams = {},
|
||||||
): Promise<ListResult<RepositoryRecord>> {
|
): Promise<ListResult<RepositoryRecord>> {
|
||||||
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
||||||
}
|
}
|
||||||
@@ -417,6 +424,10 @@ export function getStatsObjectTypes(runId: string): Promise<Record<string, numbe
|
|||||||
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStatsOverview(runId: string): Promise<Record<string, number>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/overview`, statsMapSchema);
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
||||||
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ export function DataTable<T>({
|
|||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
className={onRowClick ? "clickable" : undefined}
|
className={onRowClick ? "clickable" : undefined}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
// Clickable rows behave like links: focusable and Enter/Space-activated.
|
||||||
|
tabIndex={onRowClick ? 0 : undefined}
|
||||||
|
role={onRowClick ? "link" : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
onRowClick
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -14,16 +14,48 @@ import { StatusPill } from "./StatusPill";
|
|||||||
export interface ObjectFilterValues {
|
export interface ObjectFilterValues {
|
||||||
type: string;
|
type: string;
|
||||||
result: string;
|
result: string;
|
||||||
|
reason: string;
|
||||||
rejectedOnly: boolean;
|
rejectedOnly: boolean;
|
||||||
q: string;
|
q: string;
|
||||||
}
|
}
|
||||||
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
||||||
type: "",
|
type: "",
|
||||||
result: "",
|
result: "",
|
||||||
|
reason: "",
|
||||||
rejectedOnly: false,
|
rejectedOnly: false,
|
||||||
q: "",
|
q: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Read object filters from the page URL (standalone pages sync them there). */
|
||||||
|
export function objectFiltersFromParams(params: URLSearchParams): ObjectFilterValues {
|
||||||
|
return {
|
||||||
|
type: params.get("type") ?? "",
|
||||||
|
result: params.get("result") ?? "",
|
||||||
|
reason: params.get("reason") ?? "",
|
||||||
|
rejectedOnly: params.get("rejected") === "true",
|
||||||
|
q: params.get("q") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write object filters into a URLSearchParams copy, preserving other params. */
|
||||||
|
export function objectFiltersToParams(
|
||||||
|
next: ObjectFilterValues,
|
||||||
|
base: URLSearchParams,
|
||||||
|
): URLSearchParams {
|
||||||
|
const params = new URLSearchParams(base);
|
||||||
|
const write = (key: string, value: string) => {
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
else params.delete(key);
|
||||||
|
};
|
||||||
|
write("type", next.type);
|
||||||
|
write("result", next.result);
|
||||||
|
write("reason", next.reason);
|
||||||
|
write("q", next.q);
|
||||||
|
if (next.rejectedOnly) params.set("rejected", "true");
|
||||||
|
else params.delete("rejected");
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
|
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
|
||||||
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
||||||
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
||||||
@@ -55,7 +87,16 @@ export function ObjectsTable({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQDraft(filters.q);
|
setQDraft(filters.q);
|
||||||
}, [filters.q]);
|
}, [filters.q]);
|
||||||
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
|
|
||||||
|
// Auto-apply the URI draft after a short typing pause (Enter/blur still work).
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = qDraft.trim();
|
||||||
|
if (trimmed === filters.q) return;
|
||||||
|
const timer = setTimeout(() => onFiltersChange({ ...filters, q: trimmed }), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [qDraft, filters, onFiltersChange]);
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.reason, filters.rejectedOnly, filters.q]);
|
||||||
const pager = useCursorPager(filterKey);
|
const pager = useCursorPager(filterKey);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -67,6 +108,7 @@ export function ObjectsTable({
|
|||||||
type: filters.type || undefined,
|
type: filters.type || undefined,
|
||||||
result: filters.result || undefined,
|
result: filters.result || undefined,
|
||||||
rejected: filters.rejectedOnly || undefined,
|
rejected: filters.rejectedOnly || undefined,
|
||||||
|
reason: filters.reason || undefined,
|
||||||
q: filters.q || undefined,
|
q: filters.q || undefined,
|
||||||
}),
|
}),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
@@ -101,7 +143,9 @@ export function ObjectsTable({
|
|||||||
{
|
{
|
||||||
key: "result",
|
key: "result",
|
||||||
header: "Result",
|
header: "Result",
|
||||||
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
// Raw `result` value — the Result filter options must match what the
|
||||||
|
// column displays (rejection is visible via the reason column/filter).
|
||||||
|
render: (obj) => <StatusPill status={obj.result} />,
|
||||||
},
|
},
|
||||||
...(showReason
|
...(showReason
|
||||||
? [
|
? [
|
||||||
@@ -162,6 +206,17 @@ export function ObjectsTable({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor={`${queryKeyScope}-reason`}>Reason contains</label>
|
||||||
|
<input
|
||||||
|
id={`${queryKeyScope}-reason`}
|
||||||
|
type="search"
|
||||||
|
className="mono"
|
||||||
|
value={filters.reason}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, reason: e.target.value })}
|
||||||
|
placeholder="substring…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="filter-field">
|
<div className="filter-field">
|
||||||
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
||||||
<input
|
<input
|
||||||
@@ -174,7 +229,7 @@ export function ObjectsTable({
|
|||||||
if (e.key === "Enter") submitQ();
|
if (e.key === "Enter") submitQ();
|
||||||
}}
|
}}
|
||||||
onBlur={submitQ}
|
onBlur={submitQ}
|
||||||
placeholder="substring…"
|
placeholder="substring… (applies as you type)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="filter-check">
|
<label className="filter-check">
|
||||||
@@ -186,7 +241,7 @@ export function ObjectsTable({
|
|||||||
Rejected only
|
Rejected only
|
||||||
</label>
|
</label>
|
||||||
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
||||||
server-side filters
|
server-side filters · URI search applies as you type
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
||||||
import type { ProjectionRecord } from "../api/schemas";
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
import { CopyableValue } from "./CopyableValue";
|
import { CopyableValue } from "./CopyableValue";
|
||||||
@@ -19,6 +19,147 @@ function MetaRow({ label, value, mono = false }: { label: string; value: ReactNo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format an IP address from a projection byte array (v4 dotted / v6 compressed hextets). */
|
||||||
|
function formatIpAddr(addr: unknown, afi: unknown): string | null {
|
||||||
|
if (!Array.isArray(addr)) return null;
|
||||||
|
const bytes = addr.filter((b): b is number => typeof b === "number");
|
||||||
|
if (bytes.length === 0) return null;
|
||||||
|
if (asString(afi) === "Ipv6" || bytes.length > 4) {
|
||||||
|
const groups: number[] = [];
|
||||||
|
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
||||||
|
groups.push((bytes[i] << 8) | bytes[i + 1]);
|
||||||
|
}
|
||||||
|
// RFC 5952-style compression of the longest run of zero groups.
|
||||||
|
let bestStart = -1;
|
||||||
|
let bestLen = 0;
|
||||||
|
for (let i = 0; i < groups.length; ) {
|
||||||
|
if (groups[i] !== 0) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = i;
|
||||||
|
while (j < groups.length && groups[j] === 0) j += 1;
|
||||||
|
if (j - i > bestLen) {
|
||||||
|
bestStart = i;
|
||||||
|
bestLen = j - i;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
const hex = groups.map((g) => g.toString(16));
|
||||||
|
if (bestLen >= 2) {
|
||||||
|
const head = hex.slice(0, bestStart).join(":");
|
||||||
|
const tail = hex.slice(bestStart + bestLen).join(":");
|
||||||
|
return `${head}::${tail}`;
|
||||||
|
}
|
||||||
|
return hex.join(":");
|
||||||
|
}
|
||||||
|
return bytes.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten projection ipResources to display strings.
|
||||||
|
* Real shape (serde of the rpki types):
|
||||||
|
* { families: [ { afi, choice: { AddressesOrRanges: [ {Prefix:{addr,afi,prefix_len}} | {Range:{min,max}} ] } } ] }
|
||||||
|
*/
|
||||||
|
function formatIpResources(resources: unknown): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const famRaw of asArray(asRecord(resources)?.families)) {
|
||||||
|
const fam = asRecord(famRaw);
|
||||||
|
const choice = asRecord(fam?.choice);
|
||||||
|
if (!choice) continue;
|
||||||
|
if ("Inherit" in choice) {
|
||||||
|
out.push("inherit");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entryRaw of asArray(choice.AddressesOrRanges)) {
|
||||||
|
const entry = asRecord(entryRaw);
|
||||||
|
const prefix = asRecord(entry?.Prefix);
|
||||||
|
if (prefix) {
|
||||||
|
const base = formatIpAddr(prefix.addr, prefix.afi ?? fam?.afi);
|
||||||
|
out.push(base ? `${base}/${asNumber(prefix.prefix_len) ?? "?"}` : JSON.stringify(entryRaw));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const range = asRecord(entry?.Range);
|
||||||
|
if (range) {
|
||||||
|
const min = formatIpAddr(asRecord(range.min)?.addr ?? range.min, fam?.afi);
|
||||||
|
const max = formatIpAddr(asRecord(range.max)?.addr ?? range.max, fam?.afi);
|
||||||
|
out.push(min && max ? `${min} - ${max}` : JSON.stringify(entryRaw));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(JSON.stringify(entryRaw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten projection asResources to display strings.
|
||||||
|
* Real shape: { asnum: { AsIdsOrRanges: [ {Id: n} | {Range: {min, max}} ] } } or { "inherit"/"Inherit": … }.
|
||||||
|
*/
|
||||||
|
function formatAsResources(resources: unknown): string[] {
|
||||||
|
const rec = asRecord(resources);
|
||||||
|
if (!rec) return [];
|
||||||
|
if ("inherit" in rec || "Inherit" in rec) return ["inherit"];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entryRaw of asArray(asRecord(rec.asnum)?.AsIdsOrRanges)) {
|
||||||
|
const entry = asRecord(entryRaw);
|
||||||
|
const id = asNumber(entry?.Id);
|
||||||
|
if (id != null) {
|
||||||
|
out.push(`AS${id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const range = asRecord(entry?.Range);
|
||||||
|
if (range) {
|
||||||
|
out.push(`AS${asNumber(range.min) ?? "?"}-AS${asNumber(range.max) ?? "?"}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(JSON.stringify(entryRaw));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a list of URIs (one per line) or null when empty. */
|
||||||
|
function uriLines(uris: string[]): ReactNode {
|
||||||
|
if (!uris.length) return null;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{uris.map((u) => (
|
||||||
|
<span key={u} className="mono" style={{ display: "block", wordBreak: "break-all" }}>
|
||||||
|
{u}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chip list that collapses to `collapsedCount` entries with a clickable
|
||||||
|
* "+N more" toggle; expanding shows every item (plus a "Show less" toggle).
|
||||||
|
*/
|
||||||
|
export function ExpandableChipList({ items, collapsedCount = 12 }: { items: string[]; collapsedCount?: number }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
if (!items.length) return <>—</>;
|
||||||
|
const collapsible = items.length > collapsedCount;
|
||||||
|
const visible = expanded || !collapsible ? items : items.slice(0, collapsedCount);
|
||||||
|
return (
|
||||||
|
<span className="chip-list">
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<span className="chip" key={i}>{item}</span>
|
||||||
|
))}
|
||||||
|
{collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip chip-more"
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? "Show less" : `+${items.length - collapsedCount} more`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -69,12 +210,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan
|
|||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
|
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | number | null; revocationDate?: string | null }>[] = [
|
||||||
{
|
{
|
||||||
key: "serial",
|
key: "serial",
|
||||||
header: "Serial number",
|
header: "Serial number",
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
|
<CopyableValue
|
||||||
|
value={row.serialNumberHex ?? (row.serialNumber != null ? String(row.serialNumber) : null)}
|
||||||
|
max={44}
|
||||||
|
label="serial number"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
||||||
@@ -175,12 +320,43 @@ function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
export function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
|
// Real API shape: the certificate payload lives in `resourceCertificate`
|
||||||
|
// with extensions nested under `extensions`; keep the older
|
||||||
|
// certificate/cer + flat-field variants working as fallbacks.
|
||||||
|
const cert =
|
||||||
|
asRecord(projection.certificate) ??
|
||||||
|
asRecord(projection.cer) ??
|
||||||
|
asRecord(projection.resourceCertificate) ??
|
||||||
|
projection;
|
||||||
const validity = asRecord(cert.validity);
|
const validity = asRecord(cert.validity);
|
||||||
const resources = asRecord(cert.resources);
|
const ext = asRecord(cert.extensions);
|
||||||
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
|
const legacyResources = asRecord(cert.resources);
|
||||||
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
|
|
||||||
|
const siaDescriptions = asArray(asRecord(ext?.subjectInfoAccess)?.accessDescriptions);
|
||||||
|
const siaUris = siaDescriptions
|
||||||
|
.map((d) => asString(asRecord(d)?.accessLocation))
|
||||||
|
.filter((v): v is string => Boolean(v));
|
||||||
|
const aiaUris = asArray(ext?.caIssuersUris)
|
||||||
|
.map((u) => asString(u))
|
||||||
|
.filter((v): v is string => Boolean(v));
|
||||||
|
const crlDpUris = asArray(ext?.crlDistributionPointsUris)
|
||||||
|
.map((u) => asString(u))
|
||||||
|
.filter((v): v is string => Boolean(v));
|
||||||
|
|
||||||
|
const extIp = formatIpResources(ext?.ipResources);
|
||||||
|
const ipBlocks = extIp.length
|
||||||
|
? extIp
|
||||||
|
: asArray(legacyResources?.ipResources ?? legacyResources?.ipv4 ?? legacyResources?.addresses).map((b) =>
|
||||||
|
typeof b === "string" ? b : JSON.stringify(b),
|
||||||
|
);
|
||||||
|
const extAs = formatAsResources(ext?.asResources);
|
||||||
|
const asBlocks = extAs.length
|
||||||
|
? extAs
|
||||||
|
: asArray(legacyResources?.asResources ?? legacyResources?.asns).map((b) =>
|
||||||
|
typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className="meta-grid">
|
<dl className="meta-grid">
|
||||||
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
||||||
@@ -188,43 +364,42 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
|||||||
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="Validity"
|
label="Validity"
|
||||||
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
|
value={`${formatUtc(asString(validity?.notBefore)) ?? "—"} → ${formatUtc(asString(validity?.notAfter)) ?? "—"}`}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
|
|
||||||
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
|
|
||||||
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
|
|
||||||
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
|
|
||||||
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
|
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="IP resources"
|
label="SKI"
|
||||||
|
value={<CopyableValue value={asString(cert.ski) ?? asString(ext?.subjectKeyIdentifier)} max={56} label="SKI" />}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AKI"
|
||||||
|
value={<CopyableValue value={asString(cert.aki) ?? asString(ext?.authorityKeyIdentifier)} max={56} label="AKI" />}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="SIA"
|
||||||
|
value={asString(cert.sia) ? <CopyableValue value={asString(cert.sia)} max={72} label="SIA" /> : uriLines(siaUris)}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AIA"
|
||||||
|
value={asString(cert.aia) ? <CopyableValue value={asString(cert.aia)} max={72} label="AIA" /> : uriLines(aiaUris)}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="CRL DP"
|
||||||
value={
|
value={
|
||||||
ipBlocks.length ? (
|
asString(cert.crlDistributionPoint ?? cert.crldp) ? (
|
||||||
<span className="chip-list">
|
<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />
|
||||||
{ipBlocks.slice(0, 12).map((b, i) => (
|
|
||||||
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
|
|
||||||
))}
|
|
||||||
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
"—"
|
uriLines(crlDpUris)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="IP resources"
|
||||||
|
value={<ExpandableChipList items={ipBlocks} />}
|
||||||
|
/>
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="AS resources"
|
label="AS resources"
|
||||||
value={
|
value={<ExpandableChipList items={asBlocks} />}
|
||||||
asBlocks.length ? (
|
|
||||||
<span className="chip-list">
|
|
||||||
{asBlocks.slice(0, 12).map((b, i) => (
|
|
||||||
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
|
|
||||||
))}
|
|
||||||
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
||||||
</dl>
|
</dl>
|
||||||
@@ -257,12 +432,16 @@ export function ProjectionView({
|
|||||||
|
|
||||||
const projection = record.projection;
|
const projection = record.projection;
|
||||||
const type = (record.objectType ?? "").toLowerCase();
|
const type = (record.objectType ?? "").toLowerCase();
|
||||||
|
// The query service nests the typed payload one level down:
|
||||||
|
// projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool }
|
||||||
|
// Accept a flat payload too, so older fixtures / shape variants keep working.
|
||||||
|
const typed = asRecord(projection.object) ?? projection;
|
||||||
|
|
||||||
if (type === "roa") return <RoaView projection={projection} />;
|
if (type === "roa") return <RoaView projection={typed} />;
|
||||||
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
|
if (type === "asa" || type === "aspa") return <AspaView projection={typed} />;
|
||||||
|
|
||||||
if (type === "mft") {
|
if (type === "mft") {
|
||||||
const manifest = asRecord(projection.manifest);
|
const manifest = asRecord(typed.manifest);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
@@ -278,15 +457,37 @@ export function ProjectionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "crl") {
|
if (type === "crl") {
|
||||||
const crl = asRecord(projection.crl);
|
// Real API shape: CRL fields sit directly on the typed payload
|
||||||
|
// ({ issuer, thisUpdate, nextUpdate, extensions: { crlNumberHex, authorityKeyIdentifier }, … }),
|
||||||
|
// there is no `crl` wrapper; keep `typed.crl` as a legacy fallback.
|
||||||
|
const crl = asRecord(typed.crl) ?? typed;
|
||||||
|
const crlExt = asRecord(crl.extensions);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
|
<MetaRow label="Issuer" value={asString(crl.issuer)} mono />
|
||||||
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
|
<MetaRow label="thisUpdate" value={formatUtc(asString(crl.thisUpdate))} />
|
||||||
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
|
<MetaRow label="nextUpdate" value={formatUtc(asString(crl.nextUpdate))} />
|
||||||
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
|
<MetaRow
|
||||||
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
|
label="CRL number"
|
||||||
|
value={
|
||||||
|
asString(crl.crlNumberHex ?? crlExt?.crlNumberHex) ??
|
||||||
|
(asNumber(crl.crlNumber ?? crlExt?.crlNumber) != null
|
||||||
|
? String(asNumber(crl.crlNumber ?? crlExt?.crlNumber))
|
||||||
|
: null)
|
||||||
|
}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AKI"
|
||||||
|
value={
|
||||||
|
<CopyableValue
|
||||||
|
value={asString(crl.aki) ?? asString(crlExt?.authorityKeyIdentifier)}
|
||||||
|
max={56}
|
||||||
|
label="AKI"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
</>
|
</>
|
||||||
@@ -294,12 +495,12 @@ export function ProjectionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "cer" || type === "ee" || type === "router_cert") {
|
if (type === "cer" || type === "ee" || type === "router_cert") {
|
||||||
return <CertificateView projection={projection} />;
|
return <CertificateView projection={typed} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre className="json-block" data-testid="projection-json">
|
<pre className="json-block" data-testid="projection-json">
|
||||||
{JSON.stringify(projection, null, 2)}
|
{JSON.stringify(typed, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ function RunSelector() {
|
|||||||
queryKey: ["runs", "selector"],
|
queryKey: ["runs", "selector"],
|
||||||
queryFn: () => listRuns({ limit: 25 }),
|
queryFn: () => listRuns({ limit: 25 }),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
// Soak runs keep producing new runs; poll so the dropdown picks them up
|
||||||
|
// without a manual page refresh.
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||||
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
||||||
@@ -129,6 +132,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
<div className="shell-brand">
|
<div className="shell-brand">
|
||||||
<span className="brand-badge" aria-hidden="true">
|
<span className="brand-badge" aria-hidden="true">
|
||||||
<ShieldCheck size={15} />
|
<ShieldCheck size={15} />
|
||||||
@@ -170,7 +176,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
<div className="nav-footer">ours RP query layer</div>
|
<div className="nav-footer">ours RP query layer</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="shell-main">{children}</main>
|
<main className="shell-main" id="main-content" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export function Tabs({
|
|||||||
role="tab"
|
role="tab"
|
||||||
id={`tab-${tab.id}`}
|
id={`tab-${tab.id}`}
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-controls={`tabpanel-${tab.id}`}
|
// Only the active tab's panel is mounted — pointing aria-controls
|
||||||
|
// at an unmounted panel would leave a dangling reference.
|
||||||
|
aria-controls={selected ? `tabpanel-${tab.id}` : undefined}
|
||||||
tabIndex={selected ? 0 : -1}
|
tabIndex={selected ? 0 : -1}
|
||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
export interface CursorPager {
|
export interface CursorPager {
|
||||||
/** Cursor to pass to the backend for the current page (null = first page). */
|
/** Cursor to pass to the backend for the current page (null = first page). */
|
||||||
@@ -40,33 +41,72 @@ export function pageNumber(state: PagerState): number {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cursor pager that resets whenever `resetKey` changes (run id, filters…).
|
* Cursor pager that resets whenever `resetKey` changes (run id, filters…).
|
||||||
|
*
|
||||||
|
* The current cursor lives in the `?cursor=` URL param so a refresh or a
|
||||||
|
* shared link restores the current page; the previous-page stack stays in
|
||||||
|
* component state (Prev works within one mounted session). Only one pager
|
||||||
|
* is mounted per page, so a single shared param is unambiguous.
|
||||||
*/
|
*/
|
||||||
export function useCursorPager(resetKey: string): CursorPager {
|
export function useCursorPager(resetKey: string): CursorPager {
|
||||||
const [state, setState] = useState<PagerState>(INITIAL_PAGER_STATE);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const cursor = searchParams.get("cursor");
|
||||||
|
const [stack, setStack] = useState<(string | null)[]>([]);
|
||||||
|
|
||||||
|
const writeCursor = useCallback(
|
||||||
|
(next: string | null) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const params = new URLSearchParams(prev);
|
||||||
|
if (next === null) params.delete("cursor");
|
||||||
|
else params.set("cursor", next);
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ preventScrollReset: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset on resetKey change — but not on first mount, so a refreshed or
|
||||||
|
// deep-linked `?cursor=` survives.
|
||||||
|
const lastResetKey = useRef(resetKey);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState(INITIAL_PAGER_STATE);
|
if (lastResetKey.current === resetKey) return;
|
||||||
}, [resetKey]);
|
lastResetKey.current = resetKey;
|
||||||
|
setStack([]);
|
||||||
|
writeCursor(null);
|
||||||
|
}, [resetKey, writeCursor]);
|
||||||
|
|
||||||
const goNext = useCallback((nextCursor: string | null) => {
|
const goNext = useCallback(
|
||||||
setState((s) => advancePager(s, nextCursor));
|
(nextCursor: string | null) => {
|
||||||
}, []);
|
if (!nextCursor) return;
|
||||||
|
setStack((s) => [...s, cursor]);
|
||||||
|
writeCursor(nextCursor);
|
||||||
|
},
|
||||||
|
[cursor, writeCursor],
|
||||||
|
);
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
setState((s) => retreatPager(s));
|
if (stack.length === 0) return;
|
||||||
}, []);
|
const prev = stack[stack.length - 1];
|
||||||
|
setStack(stack.slice(0, -1));
|
||||||
|
writeCursor(prev);
|
||||||
|
}, [stack, writeCursor]);
|
||||||
|
|
||||||
const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []);
|
const reset = useCallback(() => {
|
||||||
|
setStack([]);
|
||||||
|
writeCursor(null);
|
||||||
|
}, [writeCursor]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cursor: state.cursor,
|
cursor,
|
||||||
page: pageNumber(state),
|
page: stack.length + 1,
|
||||||
canPrev: state.stack.length > 0,
|
canPrev: stack.length > 0,
|
||||||
goNext,
|
goNext,
|
||||||
goPrev,
|
goPrev,
|
||||||
reset,
|
reset,
|
||||||
}),
|
}),
|
||||||
[state, goNext, goPrev, reset],
|
[cursor, stack, goNext, goPrev, reset],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ describe("formatPercent", () => {
|
|||||||
it("guards zero total", () => {
|
it("guards zero total", () => {
|
||||||
expect(formatPercent(1, 0)).toBe("—");
|
expect(formatPercent(1, 0)).toBe("—");
|
||||||
});
|
});
|
||||||
|
it("marks tiny non-zero shares instead of rounding to 0.0%", () => {
|
||||||
|
expect(formatPercent(1, 5000)).toBe("<0.1%");
|
||||||
|
expect(formatPercent(0, 5000)).toBe("0.0%");
|
||||||
|
});
|
||||||
|
it("marks near-complete shares instead of rounding to 100.0%", () => {
|
||||||
|
expect(formatPercent(4999, 5000)).toBe(">99.9%");
|
||||||
|
expect(formatPercent(5000, 5000)).toBe("100.0%");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("truncateMiddle", () => {
|
describe("truncateMiddle", () => {
|
||||||
@@ -90,6 +98,10 @@ describe("objectTypeLabel", () => {
|
|||||||
expect(objectTypeLabel("mft")).toBe("Manifest");
|
expect(objectTypeLabel("mft")).toBe("Manifest");
|
||||||
expect(objectTypeLabel("asa")).toBe("ASPA");
|
expect(objectTypeLabel("asa")).toBe("ASPA");
|
||||||
});
|
});
|
||||||
|
it("maps router certificate spellings", () => {
|
||||||
|
expect(objectTypeLabel("router_certificate")).toBe("Router certificate");
|
||||||
|
expect(objectTypeLabel("router_cert")).toBe("Router certificate");
|
||||||
|
});
|
||||||
it("uppercases unknown types", () => {
|
it("uppercases unknown types", () => {
|
||||||
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ export function formatRelative(value: string | null | undefined): string {
|
|||||||
|
|
||||||
export function formatPercent(part: number, total: number): string {
|
export function formatPercent(part: number, total: number): string {
|
||||||
if (!total) return "—";
|
if (!total) return "—";
|
||||||
return `${((part / total) * 100).toFixed(1)}%`;
|
const pct = (part / total) * 100;
|
||||||
|
// One decimal is misleading at the extremes: a non-zero share rounding to
|
||||||
|
// "0.0%" looks empty, and 99.95% rounding to "100.0%" looks complete.
|
||||||
|
if (pct > 0 && pct < 0.1) return "<0.1%";
|
||||||
|
if (pct < 100 && pct > 99.9) return ">99.9%";
|
||||||
|
return `${pct.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate a long identifier for display, keeping head and tail. */
|
/** Truncate a long identifier for display, keeping head and tail. */
|
||||||
@@ -101,6 +106,9 @@ export function objectTypeLabel(type: string | null | undefined): string {
|
|||||||
return "Ghostbusters";
|
return "Ghostbusters";
|
||||||
case "ee":
|
case "ee":
|
||||||
return "EE Certificate";
|
return "EE Certificate";
|
||||||
|
case "router_certificate":
|
||||||
|
case "router_cert":
|
||||||
|
return "Router certificate";
|
||||||
case "other":
|
case "other":
|
||||||
return "Other";
|
return "Other";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { asArray, asNumber, asRecord, asString } from "./projection";
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
|
import { asArray, asNumber, asRecord, asString, eeCertificateProjection } from "./projection";
|
||||||
|
|
||||||
describe("projection guards", () => {
|
describe("projection guards", () => {
|
||||||
it("asRecord accepts plain objects only", () => {
|
it("asRecord accepts plain objects only", () => {
|
||||||
@@ -23,3 +24,48 @@ describe("projection guards", () => {
|
|||||||
expect(asArray({})).toEqual([]);
|
expect(asArray({})).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("eeCertificateProjection", () => {
|
||||||
|
const eeCert = { kind: "Ee", subject: "CN=ee" };
|
||||||
|
const recordWith = (object: unknown): ProjectionRecord =>
|
||||||
|
({ parseStatus: "ok", projection: { object } }) as unknown as ProjectionRecord;
|
||||||
|
|
||||||
|
it("extracts the Ee certificate from the CMS wrapper", () => {
|
||||||
|
const record = recordWith({
|
||||||
|
type: "roa",
|
||||||
|
signedObject: {
|
||||||
|
signedData: {
|
||||||
|
certificates: [
|
||||||
|
{ resourceCertificate: { kind: "Ca", subject: "CN=ca" } },
|
||||||
|
{ resourceCertificate: eeCert },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(eeCertificateProjection(record)).toEqual({ resourceCertificate: eeCert });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first certificate when no Ee kind is marked", () => {
|
||||||
|
const record = recordWith({
|
||||||
|
signedObject: { signedData: { certificates: [{ resourceCertificate: eeCert }] } },
|
||||||
|
});
|
||||||
|
expect(eeCertificateProjection(record)).toEqual({ resourceCertificate: eeCert });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-CMS or malformed projections", () => {
|
||||||
|
expect(eeCertificateProjection(null)).toBeNull();
|
||||||
|
expect(eeCertificateProjection(recordWith({ type: "crl" }))).toBeNull();
|
||||||
|
expect(eeCertificateProjection(recordWith(null))).toBeNull();
|
||||||
|
expect(
|
||||||
|
eeCertificateProjection(recordWith({ signedObject: { signedData: { certificates: [] } } })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the projection failed to parse", () => {
|
||||||
|
const record = {
|
||||||
|
parseStatus: "error",
|
||||||
|
projection: { object: { signedObject: { signedData: { certificates: [{ resourceCertificate: eeCert }] } } } },
|
||||||
|
} as unknown as ProjectionRecord;
|
||||||
|
expect(eeCertificateProjection(record)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/** Defensive accessors for loosely-typed projection payloads. */
|
/** Defensive accessors for loosely-typed projection payloads. */
|
||||||
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
|
|
||||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
@@ -16,3 +18,25 @@ export function asNumber(value: unknown): number | null {
|
|||||||
export function asArray(value: unknown): unknown[] {
|
export function asArray(value: unknown): unknown[] {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the CMS wrapper's EE certificate from a parsed projection, if any.
|
||||||
|
* Real shape: projection.object.signedObject.signedData.certificates[] — each
|
||||||
|
* entry carries a `resourceCertificate` with `kind: "Ee"` for the signer cert.
|
||||||
|
* Returns a `{ resourceCertificate }` projection for CertificateView, or null
|
||||||
|
* when the object is not CMS-signed or has no embedded certificate.
|
||||||
|
*/
|
||||||
|
export function eeCertificateProjection(
|
||||||
|
record: ProjectionRecord | null,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const projection = record?.projection;
|
||||||
|
if (!projection || record?.parseStatus === "error") return null;
|
||||||
|
const typed = asRecord(projection.object) ?? projection;
|
||||||
|
const certificates = asArray(asRecord(asRecord(typed.signedObject)?.signedData)?.certificates);
|
||||||
|
const certs = certificates
|
||||||
|
.map((entry) => asRecord(asRecord(entry)?.resourceCertificate))
|
||||||
|
.filter((cert): cert is Record<string, unknown> => Boolean(cert));
|
||||||
|
if (!certs.length) return null;
|
||||||
|
const ee = certs.find((cert) => asString(cert.kind) === "Ee") ?? certs[0];
|
||||||
|
return { resourceCertificate: ee };
|
||||||
|
}
|
||||||
|
|||||||
39
ui/rpki-explorer/src/lib/reasons.test.ts
Normal file
39
ui/rpki-explorer/src/lib/reasons.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clusterReasons, reasonClusterKey } from "./reasons";
|
||||||
|
|
||||||
|
describe("reasonClusterKey", () => {
|
||||||
|
it("takes the text before the first colon", () => {
|
||||||
|
expect(reasonClusterKey("manifest stale: thisUpdate in the past")).toBe("manifest stale");
|
||||||
|
expect(
|
||||||
|
reasonClusterKey("manifest is not valid at validation_time: this_update=2023-01-01"),
|
||||||
|
).toBe("manifest is not valid at validation_time");
|
||||||
|
});
|
||||||
|
it("keeps reasons without a colon intact", () => {
|
||||||
|
expect(reasonClusterKey("certificate expired")).toBe("certificate expired");
|
||||||
|
});
|
||||||
|
it("trims whitespace around the prefix", () => {
|
||||||
|
expect(reasonClusterKey("bad roa : detail")).toBe("bad roa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clusterReasons", () => {
|
||||||
|
it("merges reasons sharing a prefix and sums counts", () => {
|
||||||
|
const clusters = clusterReasons({
|
||||||
|
"manifest stale: thisUpdate in the past": 3,
|
||||||
|
"manifest stale: nextUpdate missing": 2,
|
||||||
|
"certificate expired: notAfter 2026-01-01": 5,
|
||||||
|
});
|
||||||
|
expect(clusters).toEqual([
|
||||||
|
{ key: "certificate expired", count: 5, variants: 1 },
|
||||||
|
{ key: "manifest stale", count: 5, variants: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("sorts by count desc, then key asc for determinism", () => {
|
||||||
|
const clusters = clusterReasons({ "b: 1": 4, "a: 2": 4, "c": 1 });
|
||||||
|
expect(clusters.map((c) => c.key)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
it("honours the limit", () => {
|
||||||
|
const clusters = clusterReasons({ "a: 1": 3, "b: 2": 2, "c: 3": 1 }, 2);
|
||||||
|
expect(clusters.map((c) => c.key)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
ui/rpki-explorer/src/lib/reasons.ts
Normal file
39
ui/rpki-explorer/src/lib/reasons.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Reject-reason clustering for the overview dashboard.
|
||||||
|
*
|
||||||
|
* Raw reject reasons embed object-specific details after a colon
|
||||||
|
* (e.g. "manifest is not valid at validation_time: this_update=2023-…"),
|
||||||
|
* which scatters one logical cause across many rows. Grouping by the text
|
||||||
|
* before the first colon collapses them back into a single cluster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cluster label for a raw reject reason: text before the first colon. */
|
||||||
|
export function reasonClusterKey(reason: string): string {
|
||||||
|
const idx = reason.indexOf(":");
|
||||||
|
return (idx === -1 ? reason : reason.slice(0, idx)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonCluster {
|
||||||
|
/** Cluster label (prefix before the first colon, or the whole reason). */
|
||||||
|
key: string;
|
||||||
|
/** Total rejected objects across all reasons in the cluster. */
|
||||||
|
count: number;
|
||||||
|
/** Distinct raw reasons merged into the cluster. */
|
||||||
|
variants: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate a reason→count map into clusters, sorted by count desc. */
|
||||||
|
export function clusterReasons(map: Record<string, number>, limit = 8): ReasonCluster[] {
|
||||||
|
const clusters = new Map<string, { count: number; variants: number }>();
|
||||||
|
for (const [reason, count] of Object.entries(map)) {
|
||||||
|
const key = reasonClusterKey(reason);
|
||||||
|
const entry = clusters.get(key) ?? { count: 0, variants: 0 };
|
||||||
|
entry.count += count;
|
||||||
|
entry.variants += 1;
|
||||||
|
clusters.set(key, entry);
|
||||||
|
}
|
||||||
|
return [...clusters.entries()]
|
||||||
|
.map(([key, { count, variants }]) => ({ key, count, variants }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Shared "start export job + poll until finished" flow used by the object,
|
||||||
|
* repository and publication point detail pages. The page renders the
|
||||||
|
* returned mutation/query state with `WorkflowStatus` and friends.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createExport, getExportJob, type ExportRequest } from "../api/service";
|
||||||
|
|
||||||
|
export function useExportJob(runId: string, request: ExportRequest) {
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: () => createExport(runId, request),
|
||||||
|
onSuccess: (job) => setJobId(job.jobId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobQuery = useQuery({
|
||||||
|
queryKey: ["export-job", runId, jobId],
|
||||||
|
queryFn: () => getExportJob(runId, jobId!),
|
||||||
|
enabled: jobId !== null,
|
||||||
|
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the trigger disabled until the job reaches a terminal state so the
|
||||||
|
// same export cannot be double-submitted while it is still running.
|
||||||
|
const running =
|
||||||
|
startMutation.isPending ||
|
||||||
|
(jobId !== null &&
|
||||||
|
jobQuery.data?.status !== "complete" &&
|
||||||
|
jobQuery.data?.status !== "failed");
|
||||||
|
|
||||||
|
return { startMutation, jobQuery, running };
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
/** Resolve the active run record for the current `?run=` context. */
|
/** Resolve the active run record for the current `?run=` context. */
|
||||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
import { getRun } from "../api/service";
|
import { getRun } from "../api/service";
|
||||||
import type { RunRecord } from "../api/schemas";
|
import type { RunRecord } from "../api/schemas";
|
||||||
import { useRunId } from "./run";
|
import { LATEST_RUN, useRunId } from "./run";
|
||||||
|
|
||||||
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
||||||
const runId = useRunId();
|
const runId = useRunId();
|
||||||
@@ -13,3 +14,16 @@ export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> }
|
|||||||
});
|
});
|
||||||
return { runId, runQuery };
|
return { runId, runQuery };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run-aware title for the run-scope error gate: a 404 means the requested
|
||||||
|
* run does not exist; anything else is a service/transport failure.
|
||||||
|
*/
|
||||||
|
export function runErrorTitle(runId: string, error: unknown): string {
|
||||||
|
if (error instanceof ApiError && error.status === 404) {
|
||||||
|
return runId === LATEST_RUN
|
||||||
|
? "No indexed run available yet"
|
||||||
|
: `Run ${runId} does not exist`;
|
||||||
|
}
|
||||||
|
return `Failed to load run ${runId}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,4 +94,34 @@ describe("classifyNetworkQuery", () => {
|
|||||||
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
||||||
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
|
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads two tokens as network + ASN in either order", () => {
|
||||||
|
expect(classifyNetworkQuery("1.0.0.1 AS13335")).toEqual({
|
||||||
|
kind: "ip",
|
||||||
|
ip: "1.0.0.1",
|
||||||
|
asn: 13335,
|
||||||
|
});
|
||||||
|
expect(classifyNetworkQuery("AS13335 1.0.0.1")).toEqual({
|
||||||
|
kind: "ip",
|
||||||
|
ip: "1.0.0.1",
|
||||||
|
asn: 13335,
|
||||||
|
});
|
||||||
|
expect(classifyNetworkQuery("192.0.2.0/24 13335")).toEqual({
|
||||||
|
kind: "prefix",
|
||||||
|
prefix: "192.0.2.0/24",
|
||||||
|
asn: 13335,
|
||||||
|
});
|
||||||
|
expect(classifyNetworkQuery(" 2001:db8::/32 as64496 ")).toEqual({
|
||||||
|
kind: "prefix",
|
||||||
|
prefix: "2001:db8::/32",
|
||||||
|
asn: 64496,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects multi-token queries without a network+ASN pair", () => {
|
||||||
|
expect(classifyNetworkQuery("13335 64496")).toEqual({ kind: "none" });
|
||||||
|
expect(classifyNetworkQuery("1.0.0.1 192.0.2.1")).toEqual({ kind: "none" });
|
||||||
|
expect(classifyNetworkQuery("1.0.0.1 AS13335 extra")).toEqual({ kind: "none" });
|
||||||
|
expect(classifyNetworkQuery("foo bar")).toEqual({ kind: "none" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type NetworkQuery =
|
export type NetworkQuery =
|
||||||
| { kind: "ip"; ip: string }
|
| { kind: "ip"; ip: string; asn?: number }
|
||||||
| { kind: "prefix"; prefix: string }
|
| { kind: "prefix"; prefix: string; asn?: number }
|
||||||
| { kind: "asn"; asn: number }
|
| { kind: "asn"; asn: number }
|
||||||
| { kind: "none" };
|
| { kind: "none" };
|
||||||
|
|
||||||
@@ -90,10 +90,30 @@ export function parsePrefix(input: string): string | null {
|
|||||||
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
||||||
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
|
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
|
||||||
* no overlap. A bare number is always treated as an ASN.)
|
* no overlap. A bare number is always treated as an ASN.)
|
||||||
|
*
|
||||||
|
* Two whitespace-separated tokens are read as a combined query — one network
|
||||||
|
* token (IP or prefix) plus one ASN token (`AS13335` or `13335`), in either
|
||||||
|
* order — yielding an ip/prefix query carrying an `asn` filter.
|
||||||
*/
|
*/
|
||||||
export function classifyNetworkQuery(input: string): NetworkQuery {
|
export function classifyNetworkQuery(input: string): NetworkQuery {
|
||||||
const s = input.trim();
|
const s = input.trim();
|
||||||
if (!s) return { kind: "none" };
|
if (!s) return { kind: "none" };
|
||||||
|
const tokens = s.split(/\s+/);
|
||||||
|
if (tokens.length === 2) {
|
||||||
|
for (const [netToken, asnToken] of [
|
||||||
|
[tokens[0], tokens[1]],
|
||||||
|
[tokens[1], tokens[0]],
|
||||||
|
] as const) {
|
||||||
|
const asn = parseAsn(asnToken);
|
||||||
|
if (asn === null) continue;
|
||||||
|
const prefix = parsePrefix(netToken);
|
||||||
|
if (prefix) return { kind: "prefix", prefix, asn };
|
||||||
|
const ip = parseIpAddress(netToken);
|
||||||
|
if (ip) return { kind: "ip", ip, asn };
|
||||||
|
}
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
if (tokens.length > 2) return { kind: "none" };
|
||||||
const prefix = parsePrefix(s);
|
const prefix = parsePrefix(s);
|
||||||
if (prefix) return { kind: "prefix", prefix };
|
if (prefix) return { kind: "prefix", prefix };
|
||||||
const ip = parseIpAddress(s);
|
const ip = parseIpAddress(s);
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export default function ApiStatusPage() {
|
|||||||
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
||||||
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
||||||
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
||||||
<li>VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { DataTable, type Column } from "../components/DataTable";
|
|||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { Notice } from "../components/StateBlock";
|
import { ErrorBlock, Notice } from "../components/StateBlock";
|
||||||
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
|
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export job history for the active run. Jobs are held in the query service
|
* Export job history for the active run. Jobs are held in the query service
|
||||||
* process memory, so the list resets when the service restarts.
|
* process memory, so the list resets when the service restarts.
|
||||||
*/
|
*/
|
||||||
export default function ExportsPage() {
|
export default function ExportsPage() {
|
||||||
const { runId } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
|
|
||||||
const exportsQuery = useQuery({
|
const exportsQuery = useQuery({
|
||||||
queryKey: ["exports", runId],
|
queryKey: ["exports", runId],
|
||||||
@@ -25,6 +25,19 @@ export default function ExportsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (runQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Exports" />
|
||||||
|
<ErrorBlock
|
||||||
|
error={runQuery.error}
|
||||||
|
onRetry={() => runQuery.refetch()}
|
||||||
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const columns: Column<ExportJobRecord>[] = [
|
const columns: Column<ExportJobRecord>[] = [
|
||||||
{
|
{
|
||||||
key: "job",
|
key: "job",
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
||||||
import { apiFetchBlob, saveBlob } from "../api/client";
|
import { apiFetchBlob, saveBlob } from "../api/client";
|
||||||
import {
|
import {
|
||||||
createExport,
|
|
||||||
explainObjectValidation,
|
explainObjectValidation,
|
||||||
getExportJob,
|
|
||||||
getObject,
|
getObject,
|
||||||
getObjectChain,
|
getObjectChain,
|
||||||
getObjectProjection,
|
getObjectProjection,
|
||||||
@@ -18,13 +16,15 @@ import { CopyableValue } from "../components/CopyableValue";
|
|||||||
import { DataTable, type Column } from "../components/DataTable";
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { ProjectionView } from "../components/ProjectionView";
|
import { CertificateView, ProjectionView } from "../components/ProjectionView";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||||
import { TabPanel, Tabs } from "../components/Tabs";
|
import { TabPanel, Tabs } from "../components/Tabs";
|
||||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||||
import { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
|
import { formatInt, objectTypeLabel } from "../lib/format";
|
||||||
|
import { eeCertificateProjection } from "../lib/projection";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useExportJob } from "../lib/useExportJob";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
||||||
@@ -207,17 +207,17 @@ export default function ObjectDetailPage() {
|
|||||||
const { runId } = useRun();
|
const { runId } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get("tab") ?? "parsed";
|
const tab = searchParams.get("tab") ?? "parsed";
|
||||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const objectQuery = useQuery({
|
const objectQuery = useQuery({
|
||||||
queryKey: ["object", runId, objectInstanceId],
|
queryKey: ["object", runId, objectInstanceId],
|
||||||
queryFn: () => getObject(runId, objectInstanceId),
|
queryFn: () => getObject(runId, objectInstanceId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always fetch the parsed projection: the Parsed tab needs it, and the
|
||||||
|
// conditional "EE Parsed" tab derives from it (CMS-signed objects only).
|
||||||
const projectionQuery = useQuery({
|
const projectionQuery = useQuery({
|
||||||
queryKey: ["object-parsed", runId, objectInstanceId],
|
queryKey: ["object-parsed", runId, objectInstanceId],
|
||||||
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
||||||
enabled: tab === "parsed",
|
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,17 +229,11 @@ export default function ObjectDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const {
|
||||||
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
|
startMutation: exportMutation,
|
||||||
onSuccess: (job) => setExportJobId(job.jobId),
|
jobQuery: exportJobQuery,
|
||||||
});
|
running: exportRunning,
|
||||||
|
} = useExportJob(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] });
|
||||||
const exportJobQuery = useQuery({
|
|
||||||
queryKey: ["export-job", runId, exportJobId],
|
|
||||||
queryFn: () => getExportJob(runId, exportJobId!),
|
|
||||||
enabled: exportJobId !== null,
|
|
||||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (objectQuery.isError) {
|
if (objectQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -259,6 +253,7 @@ export default function ObjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const object = objectQuery.data;
|
const object = objectQuery.data;
|
||||||
|
const eeProjection = eeCertificateProjection(projectionQuery.data ?? null);
|
||||||
|
|
||||||
const setTab = (next: string) => {
|
const setTab = (next: string) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
@@ -298,7 +293,7 @@ export default function ObjectDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => exportMutation.mutate()}
|
onClick={() => exportMutation.mutate()}
|
||||||
disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"}
|
disabled={exportRunning}
|
||||||
>
|
>
|
||||||
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
||||||
Export object set
|
Export object set
|
||||||
@@ -317,6 +312,12 @@ export default function ObjectDetailPage() {
|
|||||||
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
||||||
|
{exportJobQuery.data?.status === "complete" ? (
|
||||||
|
<Notice kind="info">
|
||||||
|
Export complete — {formatInt(exportJobQuery.data.objectCount)} objects. Find it any
|
||||||
|
time on the <Link to={withRunParam("/exports", runId)}>Exports page</Link>.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Panel className="object-header-card">
|
<Panel className="object-header-card">
|
||||||
<dl className="meta-grid">
|
<dl className="meta-grid">
|
||||||
@@ -334,8 +335,6 @@ export default function ObjectDetailPage() {
|
|||||||
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
||||||
<dt>Detail</dt>
|
<dt>Detail</dt>
|
||||||
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
||||||
<dt>Size</dt>
|
|
||||||
<dd>{formatBytes(object.sizeBytes)}</dd>
|
|
||||||
<dt>Repository</dt>
|
<dt>Repository</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
||||||
@@ -355,6 +354,7 @@ export default function ObjectDetailPage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: "parsed", label: "Parsed" },
|
{ id: "parsed", label: "Parsed" },
|
||||||
|
...(eeProjection ? [{ id: "ee", label: "EE Parsed" }] : []),
|
||||||
{ id: "validation", label: "Validation" },
|
{ id: "validation", label: "Validation" },
|
||||||
{ id: "chain", label: "Chain" },
|
{ id: "chain", label: "Chain" },
|
||||||
]}
|
]}
|
||||||
@@ -381,6 +381,17 @@ export default function ObjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
{eeProjection ? (
|
||||||
|
<TabPanel id="ee" active={tab}>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<CertificateView projection={eeProjection} />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
) : tab === "ee" ? (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Notice kind="info">This object has no embedded EE certificate in its CMS wrapper.</Notice>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<TabPanel id="validation" active={tab}>
|
<TabPanel id="validation" active={tab}>
|
||||||
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { listObjects } from "../api/service";
|
|||||||
import { Notice } from "../components/StateBlock";
|
import { Notice } from "../components/StateBlock";
|
||||||
import {
|
import {
|
||||||
EMPTY_OBJECT_FILTERS,
|
EMPTY_OBJECT_FILTERS,
|
||||||
|
objectFiltersFromParams,
|
||||||
|
objectFiltersToParams,
|
||||||
ObjectsTable,
|
ObjectsTable,
|
||||||
type ObjectFilterValues,
|
type ObjectFilterValues,
|
||||||
} from "../components/ObjectsTable";
|
} from "../components/ObjectsTable";
|
||||||
@@ -11,37 +13,19 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
function filtersFromParams(params: URLSearchParams): ObjectFilterValues {
|
|
||||||
return {
|
|
||||||
type: params.get("type") ?? "",
|
|
||||||
result: params.get("result") ?? "",
|
|
||||||
rejectedOnly: params.get("rejected") === "true",
|
|
||||||
q: params.get("q") ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ObjectsPage() {
|
export default function ObjectsPage() {
|
||||||
const { runId } = useRun();
|
const { runId } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
|
const filters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
|
||||||
|
|
||||||
const setFilters = (next: ObjectFilterValues) => {
|
const setFilters = (next: ObjectFilterValues) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
|
||||||
const write = (key: string, value: string) => {
|
|
||||||
if (value) params.set(key, value);
|
|
||||||
else params.delete(key);
|
|
||||||
};
|
|
||||||
write("type", next.type);
|
|
||||||
write("result", next.result);
|
|
||||||
write("q", next.q);
|
|
||||||
if (next.rejectedOnly) params.set("rejected", "true");
|
|
||||||
else params.delete("rejected");
|
|
||||||
setSearchParams(params, { preventScrollReset: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
||||||
filters.result !== "" ||
|
filters.result !== "" ||
|
||||||
|
filters.reason !== "" ||
|
||||||
filters.rejectedOnly ||
|
filters.rejectedOnly ||
|
||||||
filters.q !== "";
|
filters.q !== "";
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Clock, GitBranch, Timer } from "lucide-react";
|
import { Clock, GitBranch, Timer } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getStatsObjectTypes,
|
getStatsObjectTypes,
|
||||||
|
getStatsOverview,
|
||||||
getStatsReasons,
|
getStatsReasons,
|
||||||
getStatsValidation,
|
getStatsValidation,
|
||||||
listRepos,
|
listRepos,
|
||||||
@@ -35,8 +36,9 @@ import {
|
|||||||
objectTypeLabel,
|
objectTypeLabel,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
|
import { clusterReasons } from "../lib/reasons";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
|
|
||||||
/** Semantic colors keyed by validation result — never assigned by index. */
|
/** Semantic colors keyed by validation result — never assigned by index. */
|
||||||
const RESULT_COLORS: Record<string, string> = {
|
const RESULT_COLORS: Record<string, string> = {
|
||||||
@@ -61,7 +63,7 @@ export default function OverviewPage() {
|
|||||||
const { runId, runQuery } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({
|
const [validationQuery, typesQuery, reasonsQuery, reposQuery, overviewQuery] = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
queryKey: ["stats", runId, "validation"],
|
queryKey: ["stats", runId, "validation"],
|
||||||
@@ -80,7 +82,12 @@ export default function OverviewPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
queryKey: ["repos", runId, "top"],
|
queryKey: ["repos", runId, "top"],
|
||||||
queryFn: () => listRepos(runId, { limit: 8 }),
|
queryFn: () => listRepos(runId, { limit: 8, sort: "objects", order: "desc" }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "overview"],
|
||||||
|
queryFn: () => getStatsOverview(runId),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -109,13 +116,10 @@ export default function OverviewPage() {
|
|||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [typesQuery.data]);
|
}, [typesQuery.data]);
|
||||||
|
|
||||||
const reasonRows = useMemo(() => {
|
const reasonRows = useMemo(
|
||||||
const map = reasonsQuery.data ?? {};
|
() => clusterReasons(reasonsQuery.data ?? {}),
|
||||||
return Object.entries(map)
|
[reasonsQuery.data],
|
||||||
.map(([reason, count]) => ({ reason, count }))
|
);
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 8);
|
|
||||||
}, [reasonsQuery.data]);
|
|
||||||
|
|
||||||
const maxReason = reasonRows[0]?.count ?? 0;
|
const maxReason = reasonRows[0]?.count ?? 0;
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ export default function OverviewPage() {
|
|||||||
<ErrorBlock
|
<ErrorBlock
|
||||||
error={runQuery.error}
|
error={runQuery.error}
|
||||||
onRetry={() => runQuery.refetch()}
|
onRetry={() => runQuery.refetch()}
|
||||||
title="Failed to load the latest run"
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -170,6 +174,17 @@ export default function OverviewPage() {
|
|||||||
numeric: true,
|
numeric: true,
|
||||||
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "terminal",
|
||||||
|
header: "Terminal states",
|
||||||
|
render: (repo) => (
|
||||||
|
<span className="chip-list">
|
||||||
|
{Object.entries(repo.terminalStates ?? {}).map(([state, count]) => (
|
||||||
|
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -213,6 +228,11 @@ export default function OverviewPage() {
|
|||||||
value={formatInt(counts?.publicationPoints)}
|
value={formatInt(counts?.publicationPoints)}
|
||||||
to={withRunParam("/publication-points", runId)}
|
to={withRunParam("/publication-points", runId)}
|
||||||
/>
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Repositories"
|
||||||
|
value={formatInt(overviewQuery.data?.repos)}
|
||||||
|
to={withRunParam("/repositories", runId)}
|
||||||
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Rejected"
|
label="Rejected"
|
||||||
value={formatInt(counts?.rejectedObjects)}
|
value={formatInt(counts?.rejectedObjects)}
|
||||||
@@ -224,6 +244,7 @@ export default function OverviewPage() {
|
|||||||
value={formatInt(counts?.warnings)}
|
value={formatInt(counts?.warnings)}
|
||||||
tone="amber"
|
tone="amber"
|
||||||
sub="objects with warnings"
|
sub="objects with warnings"
|
||||||
|
to={withRunParam("/validation", runId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,7 +259,7 @@ export default function OverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="chart-box">
|
<div className="chart-box">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={validationData}
|
data={validationData}
|
||||||
@@ -286,13 +307,13 @@ export default function OverviewPage() {
|
|||||||
<p className="text-muted">No object type stats recorded.</p>
|
<p className="text-muted">No object type stats recorded.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="chart-box">
|
<div className="chart-box">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||||
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
width={104}
|
width={150}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
||||||
@@ -313,7 +334,7 @@ export default function OverviewPage() {
|
|||||||
<div className="overview-bottom">
|
<div className="overview-bottom">
|
||||||
<Panel
|
<Panel
|
||||||
title="Top repositories"
|
title="Top repositories"
|
||||||
subtitle="First repositories by index order"
|
subtitle="Repositories with the most objects"
|
||||||
tools={
|
tools={
|
||||||
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
||||||
View all
|
View all
|
||||||
@@ -341,7 +362,7 @@ export default function OverviewPage() {
|
|||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
title="Top reject reasons"
|
title="Top reject reasons"
|
||||||
subtitle="Click a reason to inspect matching objects"
|
subtitle="Grouped by reason prefix — click to inspect matching objects"
|
||||||
tools={
|
tools={
|
||||||
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
||||||
Validation
|
Validation
|
||||||
@@ -358,12 +379,17 @@ export default function OverviewPage() {
|
|||||||
<div className="reason-list">
|
<div className="reason-list">
|
||||||
{reasonRows.map((row) => (
|
{reasonRows.map((row) => (
|
||||||
<Link
|
<Link
|
||||||
key={row.reason}
|
key={row.key}
|
||||||
className="reason-row"
|
className="reason-row"
|
||||||
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
|
to={withRunParam(`/validation?reason=${encodeURIComponent(row.key)}`, runId)}
|
||||||
title={row.reason}
|
title={row.key}
|
||||||
>
|
>
|
||||||
<span className="reason-text">{row.reason}</span>
|
<span className="reason-text">
|
||||||
|
{row.key}
|
||||||
|
{row.variants > 1 ? (
|
||||||
|
<span className="text-faint text-small"> ×{row.variants}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span className="reason-count">{formatInt(row.count)}</span>
|
<span className="reason-count">{formatInt(row.count)}</span>
|
||||||
<span className="reason-bar" aria-hidden="true">
|
<span className="reason-bar" aria-hidden="true">
|
||||||
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user