Compare commits
31 Commits
feature/14
...
74cbebbd33
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cbebbd33 | |||
| b59adc65a8 | |||
| ea3f0d5dde | |||
| 46fb5e5b99 | |||
| fafe96ceb3 | |||
| 49172b57d3 | |||
| e98eb6d68f | |||
| f37fe8c4cb | |||
| 3d7e18a238 | |||
| f6a2d3a593 | |||
| f7f80fe684 | |||
| 0d6bbec9f3 | |||
| 7d5454a729 | |||
| cf68abf642 | |||
| 001f13209f | |||
| b5c033426e | |||
| 3d772f748c | |||
| f86fbd4939 | |||
| 4074323302 | |||
| b00a5f920c | |||
| 654b6d1c56 | |||
| daf0764cd0 | |||
| 06f4b086b4 | |||
| 6d9eedd680 | |||
| e80fba44c3 | |||
| c48dc04909 | |||
| a5902c0a5e | |||
| a77982deaa | |||
| 4865d3698d | |||
| 59623b449b | |||
| 7aac4855bd |
@@ -1,6 +1,7 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
docker/
|
||||
perf.*
|
||||
**/* copy.excalidraw
|
||||
ui/rpki-explorer/node_modules/
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# `build_docker_installer_package.sh` rewrites package-specific placeholders
|
||||
# when producing amd64/arm64 release packages.
|
||||
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md
|
||||
# Every assignment is a required non-empty configuration key with a package
|
||||
# default. During upgrade: .env.example < reused old .env < command environment.
|
||||
|
||||
# Package metadata and architecture guardrails.
|
||||
PACKAGE_ARCH=__PACKAGE_ARCH__
|
||||
@@ -37,14 +39,36 @@ RETAIN_RUNS=100
|
||||
# TAL/TA input mode:
|
||||
# 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.
|
||||
# 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.
|
||||
TAL_INPUT_MODE=file-live-ta
|
||||
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
|
||||
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
|
||||
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.
|
||||
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
|
||||
RUN_ROOT=/var/lib/ours-rp
|
||||
DB_DIR=/var/lib/ours-rp/state/db
|
||||
@@ -86,6 +110,12 @@ RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
|
||||
METRICS_INSTANCE=__PACKAGE_ARCH__-installer
|
||||
METRICS_PORT=9556
|
||||
METRICS_POLL_SECS=10
|
||||
# CCR rpki-client format check: the artifact-metrics container runs
|
||||
# `rpki-client -f result.ccr` once for every newly completed run and exports
|
||||
# ours_rp_ccr_format_check_* metrics. The binary is packaged inside the
|
||||
# metrics image; adjust only when deliberately testing another binary.
|
||||
CCR_CHECK_BIN=/opt/ours-rp/bin/rpki-client
|
||||
CCR_CHECK_TIMEOUT_SECS=120
|
||||
# Optional external RTR report directory produced by a separately deployed RTR service.
|
||||
# Default points to an installer-managed empty fallback directory. To enable real RTR
|
||||
# metrics, set this to the host-side report directory, for example:
|
||||
|
||||
@@ -11,8 +11,10 @@ services:
|
||||
RUN_ROOT: /var/lib/ours-rp
|
||||
BIN_DIR: /opt/ours-rp/bin
|
||||
FIXTURE_DIR: /opt/ours-rp/fixtures
|
||||
CUSTOM_FIXTURE_DIR: /opt/ours-rp/custom-fixtures
|
||||
volumes:
|
||||
- ../.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}/runs:/var/lib/ours-rp/runs
|
||||
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs
|
||||
@@ -39,6 +41,10 @@ services:
|
||||
- ${METRICS_POLL_SECS:-10}
|
||||
- --instance
|
||||
- ${METRICS_INSTANCE:-package-installer}
|
||||
- --ccr-check-bin
|
||||
- ${CCR_CHECK_BIN:-/opt/ours-rp/bin/rpki-client}
|
||||
- --ccr-check-timeout-secs
|
||||
- ${CCR_CHECK_TIMEOUT_SECS:-120}
|
||||
ports:
|
||||
- "${METRICS_PORT:-9556}:9556"
|
||||
volumes:
|
||||
|
||||
@@ -850,6 +850,343 @@
|
||||
],
|
||||
"title": "Fresh PP / Object Counts by Run",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"decimals": 0,
|
||||
"unit": "none",
|
||||
"mappings": [
|
||||
{
|
||||
"type": "value",
|
||||
"options": {
|
||||
"1": {
|
||||
"text": "PASS",
|
||||
"color": "green"
|
||||
},
|
||||
"0": {
|
||||
"text": "FAIL",
|
||||
"color": "red"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 56,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"id": 19,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "max(ours_rp_ccr_format_check_last_success and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "CCR check",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Latest CCR Format Check",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"decimals": 0,
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 56,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"id": 20,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "max(ours_rp_ccr_format_check_last_run_sequence and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "run seq",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CCR Checked Run Sequence",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"decimals": 1,
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 56,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"id": 21,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "max(ours_rp_ccr_format_check_last_duration_seconds and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "duration",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CCR Check Duration",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"decimals": 0,
|
||||
"unit": "none",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 18,
|
||||
"y": 56,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"id": 22,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "max(ours_rp_ccr_format_check_consecutive_failures and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "failures",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CCR Check Consecutive Failures",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"decimals": 0,
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"w": 24,
|
||||
"h": 8
|
||||
},
|
||||
"id": 23,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (result) (ours_rp_ccr_format_check_total)",
|
||||
"legendFormat": "{{result}} checks",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "max(ours_rp_ccr_format_check_last_success and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "latest check ok",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "CCR Format Check Results",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"decimals": 0,
|
||||
"min": 0,
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "ber_compatible_cms_encoding"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "orange",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "unclassified"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "gray",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 68,
|
||||
"w": 24,
|
||||
"h": 8
|
||||
},
|
||||
"id": 24,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (warning_category) (ours_rp_warnings_by_category)",
|
||||
"legendFormat": "{{warning_category}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Warnings by Category",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "5s",
|
||||
@@ -870,6 +1207,6 @@
|
||||
"timezone": "browser",
|
||||
"title": "Ours RP Soak Overview",
|
||||
"uid": "ours-rp-soak-overview",
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"weekStart": ""
|
||||
}
|
||||
|
||||
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`
|
||||
- `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
|
||||
|
||||
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.
|
||||
@@ -80,7 +86,7 @@ Supported values:
|
||||
- `rfc6487`: switch back to the original RFC 6487 resource containment behavior.
|
||||
|
||||
The installer / soak runner passes `--resource-validation-mode` to every `rpki` child start.
|
||||
When an upgraded deployment reuses an older `.env` that does not contain this key, the installer appends the new default `validation-update-03` automatically.
|
||||
When an upgraded deployment reuses an older `.env` that does not contain this key, the new package `.env.example` default `validation-update-03` remains in the final `.env`; an empty final value fails before Docker operations.
|
||||
|
||||
## Architecture Guardrails
|
||||
|
||||
@@ -125,6 +131,12 @@ If a separately deployed RTR service on the same host continuously writes `rtr-s
|
||||
|
||||
The command accepts only an absolute path on the same host, validates the candidate Compose mount, and atomically updates `.env`. It recreates only `artifact-metrics`, Prometheus, and Grafana; it does not restart or recreate `ours-rp-soak`. The `artifact-metrics` container mounts the directory read-only and reads it through `RPKI_METRICS_RTR_REPORT_DIR`, producing `ours_rp_rtr_*` metrics. By default, `RTR_REPORT_DIR` points at an empty fallback directory under the installer data root, so the metrics container also starts cleanly without an RTR service.
|
||||
|
||||
## CCR artifact format check metrics
|
||||
|
||||
The metrics image ships the official `rpki-client 9.8`. For every newly completed run, `artifact-metrics` runs `rpki-client -f result.ccr` once and exports `ours_rp_ccr_format_check_*` metrics (cumulative pass/ccr_parse_error/tool_error counters, latest check result, check duration, consecutive failures). The Grafana `Ours RP Soak Overview` dashboard shows them in a dedicated panel row. On service start only the latest existing run is checked; older history is not backfilled.
|
||||
|
||||
Related environment variables (normally no need to change): `CCR_CHECK_BIN` (defaults to the in-image `/opt/ours-rp/bin/rpki-client`) and `CCR_CHECK_TIMEOUT_SECS` (default 120 seconds).
|
||||
|
||||
## Data Directory
|
||||
|
||||
Default host directory:
|
||||
|
||||
@@ -54,6 +54,11 @@ vim .env
|
||||
- `ALLOW_CROSS_ARCH=0`
|
||||
- `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。
|
||||
@@ -80,7 +85,7 @@ RESOURCE_VALIDATION_MODE=validation-update-03
|
||||
- `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。
|
||||
|
||||
installer / soak runner 每次启动 `rpki` 子进程时都会显式传入 `--resource-validation-mode`。
|
||||
如果升级时复用旧 `.env` 且缺少该变量,installer 会自动补入默认值 `validation-update-03`,不会破坏旧 `.env` 复用流程。
|
||||
如果升级时复用旧 `.env` 且缺少该变量,新包 `.env.example` 中的默认值 `validation-update-03` 会保留在最终 `.env` 中;最终值为空时升级会在 Docker 操作前失败。
|
||||
|
||||
## 架构检查
|
||||
|
||||
@@ -125,6 +130,12 @@ GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
该命令只接受本机绝对路径,会先校验候选 Compose 挂载,再原子更新 `.env`。随后只重建 `artifact-metrics`、Prometheus 和 Grafana,不重启或重建 `ours-rp-soak`。`artifact-metrics` 以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。
|
||||
|
||||
## CCR 产物格式检查监控
|
||||
|
||||
metrics 镜像内置官方 `rpki-client 9.8`。`artifact-metrics` 会对每个新完成 run 的 `result.ccr` 执行一次 `rpki-client -f` 格式检查,并输出 `ours_rp_ccr_format_check_*` 指标(累计 pass/ccr_parse_error/tool_error、最近检查结果、检查耗时、连续失败数)。Grafana `Ours RP Soak Overview` 底部有对应面板。服务启动时只补查最新的一个历史 run,不回扫全部历史。
|
||||
|
||||
相关环境变量(通常无需修改):`CCR_CHECK_BIN`(默认为镜像内 `/opt/ours-rp/bin/rpki-client`)、`CCR_CHECK_TIMEOUT_SECS`(默认 120 秒)。
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认宿主机目录:
|
||||
|
||||
@@ -89,9 +89,26 @@ Extract the new package into a new directory and explicitly reuse the existing `
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
If the new package directory already has a `.env`, the upgrade script keeps it.
|
||||
Configuration is merged in this order, with a key on the right overriding the same key on the left:
|
||||
|
||||
If an older `.env` does not contain `METRICS_IMAGE` or `METRICS_PLATFORM`, the install/upgrade flow backfills package-matched defaults automatically.
|
||||
```text
|
||||
new .env.example < old .env < environment variables on this upgrade command
|
||||
```
|
||||
|
||||
For example, override the next-run interval and Grafana password for this upgrade:
|
||||
|
||||
```bash
|
||||
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
Before any image is loaded or container is started, upgrade verifies that every key declared by the new template is present and non-empty in the final `.env`. An explicit empty assignment fails, for example `INTERVAL_SECS= ./scripts/upgrade.sh ...`. Removed keys from an old `.env` are not copied, and the script reports key names only, never password-like values.
|
||||
|
||||
The installer reads `.env` as Compose-compatible dotenv assignments. Do not put shell command substitutions or environment references such as `$HOME` in the file; use concrete values or command-level overrides instead.
|
||||
|
||||
If the new package directory already has a `.env`, the upgrade script keeps it. In that case `--reuse-env-from` is not imported again, but environment variables from the current command still override the existing `.env`.
|
||||
|
||||
When an older `.env` lacks a key introduced by the new package, the new `.env.example` default remains in the final `.env`. Upgrade fails before Docker operations if any final declared value is empty.
|
||||
|
||||
Upgrade does not delete:
|
||||
|
||||
|
||||
@@ -89,9 +89,26 @@ curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_'
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。
|
||||
配置按以下优先级合并,右侧同名键覆盖左侧:
|
||||
|
||||
如果旧 `.env` 缺失 `METRICS_IMAGE` 或 `METRICS_PLATFORM`,升级/安装链路会按当前包架构自动补齐默认值。
|
||||
```text
|
||||
新包 .env.example < 旧 .env < 本次 upgrade 命令的环境变量
|
||||
```
|
||||
|
||||
例如,临时调整下轮触发间隔和 Grafana 密码:
|
||||
|
||||
```bash
|
||||
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
升级会在加载镜像或启动容器前检查:新模板声明的所有变量在最终 `.env` 中都必须存在且非空。显式传入空值会失败,例如 `INTERVAL_SECS= ./scripts/upgrade.sh ...`。旧 `.env` 中已不在新模板内的键不会复制,脚本只输出相关键名而不会输出密码等值。
|
||||
|
||||
installer 按与 Compose 一致的 dotenv 赋值格式读取 `.env`。不要在文件中使用 Shell 命令替换或 `$HOME` 一类环境变量引用;请使用确定的值,或在执行 upgrade 时通过命令环境变量覆盖。
|
||||
|
||||
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖;此时 `--reuse-env-from` 不会再次导入旧配置,但本次命令的环境变量仍会覆盖已存在的 `.env`。
|
||||
|
||||
旧 `.env` 缺少新版本新增的键时,新包 `.env.example` 的默认值会保留在最终 `.env` 中;任一最终值为空则升级会在 Docker 操作前失败。
|
||||
|
||||
升级不会删除以下数据:
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
|
||||
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
|
||||
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
|
||||
MANIFEST_FILE="${MANIFEST_FILE:-$INSTALLER_ROOT/PACKAGE-MANIFEST.env}"
|
||||
declare -a ENV_TEMPLATE_KEYS=()
|
||||
declare -A ENV_TEMPLATE_KEY_SET=()
|
||||
declare -A INVOCATION_ENV_OVERRIDES=()
|
||||
declare -A REUSED_ENV_KEYS=()
|
||||
declare -A ENV_PARSED_VALUES=()
|
||||
|
||||
log() {
|
||||
printf '[ours-rp-installer] %s\n' "$*"
|
||||
@@ -48,20 +53,246 @@ env_file_has_key() {
|
||||
[[ -f "$env_path" ]] && grep -Eq "^${key}=" "$env_path"
|
||||
}
|
||||
|
||||
persist_env_default() {
|
||||
load_env_template_keys() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
local key
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment template: $env_path"
|
||||
ENV_TEMPLATE_KEYS=()
|
||||
ENV_TEMPLATE_KEY_SET=()
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || die "invalid environment assignment in $env_path: $line"
|
||||
key="${BASH_REMATCH[1]}"
|
||||
[[ -z "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]] || die "duplicate environment key in $env_path: $key"
|
||||
ENV_TEMPLATE_KEYS+=("$key")
|
||||
ENV_TEMPLATE_KEY_SET["$key"]=1
|
||||
done < "$env_path"
|
||||
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "no environment assignments found in $env_path"
|
||||
}
|
||||
|
||||
template_has_env_key() {
|
||||
local key="$1"
|
||||
[[ -n "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]]
|
||||
}
|
||||
|
||||
env_file_assignment_keys() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || continue
|
||||
printf '%s\n' "${BASH_REMATCH[1]}"
|
||||
done < "$env_path"
|
||||
}
|
||||
|
||||
dotenv_decode_value() {
|
||||
local raw_value="$1"
|
||||
local quote
|
||||
local body
|
||||
local decoded=""
|
||||
local character
|
||||
local next_character
|
||||
local index=0
|
||||
|
||||
[[ -n "$raw_value" ]] || {
|
||||
printf '\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
quote="${raw_value:0:1}"
|
||||
case "$quote" in
|
||||
"'")
|
||||
[[ "${raw_value: -1}" == "'" && ${#raw_value} -ge 2 ]] || return 1
|
||||
body="${raw_value:1:${#raw_value}-2}"
|
||||
while (( index < ${#body} )); do
|
||||
character="${body:index:1}"
|
||||
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
|
||||
next_character="${body:index + 1:1}"
|
||||
case "$next_character" in
|
||||
"'")
|
||||
decoded+="$next_character"
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
decoded+="$character"
|
||||
((index += 1))
|
||||
done
|
||||
;;
|
||||
'"')
|
||||
[[ "${raw_value: -1}" == '"' && ${#raw_value} -ge 2 ]] || return 1
|
||||
body="${raw_value:1:${#raw_value}-2}"
|
||||
while (( index < ${#body} )); do
|
||||
character="${body:index:1}"
|
||||
if [[ "$character" == '$' && $((index + 1)) -lt ${#body} && "${body:index + 1:1}" == '$' ]]; then
|
||||
decoded+='$'
|
||||
((index += 2))
|
||||
continue
|
||||
fi
|
||||
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
|
||||
next_character="${body:index + 1:1}"
|
||||
case "$next_character" in
|
||||
'"'|\\|'$'|'`')
|
||||
decoded+="$next_character"
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
n)
|
||||
decoded+=$'\n'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
r)
|
||||
decoded+=$'\r'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
t)
|
||||
decoded+=$'\t'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
decoded+="$character"
|
||||
((index += 1))
|
||||
done
|
||||
;;
|
||||
*)
|
||||
decoded="$raw_value"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s' "$decoded"
|
||||
}
|
||||
|
||||
parse_env_file() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
local parsed_key
|
||||
local raw_value
|
||||
local parsed_value
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
ENV_PARSED_VALUES=()
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || return 2
|
||||
parsed_key="${BASH_REMATCH[1]}"
|
||||
raw_value="${BASH_REMATCH[2]}"
|
||||
parsed_value="$(dotenv_decode_value "$raw_value")" || return 2
|
||||
ENV_PARSED_VALUES["$parsed_key"]="$parsed_value"
|
||||
done < "$env_path"
|
||||
}
|
||||
|
||||
env_file_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
|
||||
parse_env_file "$env_path"
|
||||
|
||||
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || return 3
|
||||
printf '%s' "${ENV_PARSED_VALUES[$key]}"
|
||||
}
|
||||
|
||||
env_file_set_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp_env
|
||||
if env_file_has_key "$env_path" "$key"; then
|
||||
local encoded_value
|
||||
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value for $key contains an unsupported newline"
|
||||
encoded_value="$(env_file_encode_value "$value")"
|
||||
if ! env_file_has_key "$env_path" "$key"; then
|
||||
printf '%s=%s\n' "$key" "$encoded_value" >> "$env_path"
|
||||
return 0
|
||||
fi
|
||||
tmp_env="$(mktemp)"
|
||||
cat "$env_path" > "$tmp_env"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp_env"
|
||||
ENV_FILE_REPLACEMENT="$key=$encoded_value" awk -v key="$key" '
|
||||
BEGIN { done=0 }
|
||||
$0 ~ "^" key "=" { print ENVIRON["ENV_FILE_REPLACEMENT"]; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print ENVIRON["ENV_FILE_REPLACEMENT"] }
|
||||
' "$env_path" > "$tmp_env"
|
||||
mv "$tmp_env" "$env_path"
|
||||
}
|
||||
|
||||
env_file_encode_value() {
|
||||
local value="$1"
|
||||
local escaped="$value"
|
||||
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value contains an unsupported newline"
|
||||
escaped="${escaped//\\/\\\\}"
|
||||
escaped="${escaped//\"/\\\"}"
|
||||
escaped="${escaped//\$/\$\$}"
|
||||
printf '"%s"' "$escaped"
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
local env_path="$1"
|
||||
local key
|
||||
|
||||
parse_env_file "$env_path" || die "unable to parse environment file: $env_path"
|
||||
for key in "${!ENV_PARSED_VALUES[@]}"; do
|
||||
printf -v "$key" '%s' "${ENV_PARSED_VALUES[$key]}"
|
||||
export "$key"
|
||||
done
|
||||
}
|
||||
|
||||
validate_env_file_contract() {
|
||||
local env_path="$1"
|
||||
local invalid_keys=""
|
||||
local key
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
|
||||
|
||||
if ! parse_env_file "$env_path"; then
|
||||
die "unable to parse environment file: $env_path"
|
||||
fi
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
if [[ -z "${ENV_PARSED_VALUES[$key]+x}" || -z "${ENV_PARSED_VALUES[$key]}" ]]; then
|
||||
invalid_keys+="$key"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -z "$invalid_keys" ]] || die "environment contract requires non-empty values in $env_path: ${invalid_keys//$'\n'/,}"
|
||||
}
|
||||
|
||||
capture_invocation_env_overrides() {
|
||||
local key
|
||||
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
|
||||
INVOCATION_ENV_OVERRIDES=()
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
if [[ -v "$key" ]]; then
|
||||
INVOCATION_ENV_OVERRIDES["$key"]="${!key}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
log_env_key_group() {
|
||||
local group="$1"
|
||||
shift
|
||||
local rendered="none"
|
||||
if (( $# > 0 )); then
|
||||
local IFS=,
|
||||
rendered="$*"
|
||||
fi
|
||||
log "$group=$rendered"
|
||||
}
|
||||
|
||||
env_flag_enabled() {
|
||||
case "${1:-0}" in
|
||||
1|true|TRUE|yes|YES|on|ON)
|
||||
@@ -156,15 +387,15 @@ assert_arch_compatibility() {
|
||||
|
||||
load_env() {
|
||||
load_manifest
|
||||
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
|
||||
load_env_template_keys "$ENV_EXAMPLE"
|
||||
validate_env_file_contract "$ENV_EXAMPLE"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log "created .env from .env.example"
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
validate_env_file_contract "$ENV_FILE"
|
||||
load_env_file "$ENV_FILE"
|
||||
|
||||
if [[ -n "${MANIFEST_PACKAGE_ARCH_RAW:-}" ]]; then
|
||||
manifest_package_arch="$(normalize_arch "$MANIFEST_PACKAGE_ARCH_RAW")" || die "unsupported manifest package arch: $MANIFEST_PACKAGE_ARCH_RAW"
|
||||
@@ -190,27 +421,6 @@ load_env() {
|
||||
PACKAGE_PLATFORM="${PACKAGE_PLATFORM:-$(platform_for_arch "$PACKAGE_ARCH")}"
|
||||
fi
|
||||
|
||||
HOST_DATA_DIR="${HOST_DATA_DIR:-/var/lib/ours-rp-${PACKAGE_ARCH}-installer}"
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ours-rp-${PACKAGE_ARCH}-installer}"
|
||||
RPKI_IMAGE="${RPKI_IMAGE:-ours-rp-runtime-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
|
||||
RPKI_PLATFORM="${RPKI_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
METRICS_IMAGE="${METRICS_IMAGE:-ours-rp-metrics-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
|
||||
METRICS_PLATFORM="${METRICS_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
RTR_REPORT_DIR="${RTR_REPORT_DIR:-$HOST_DATA_DIR/empty-rtr-report}"
|
||||
RTR_REPORT_CONTAINER_DIR="${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}"
|
||||
MONITOR_PLATFORM="${MONITOR_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
|
||||
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
|
||||
METRICS_INSTANCE="${METRICS_INSTANCE:-${PACKAGE_ARCH}-installer}"
|
||||
ALLOW_CROSS_ARCH="${ALLOW_CROSS_ARCH:-0}"
|
||||
RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}"
|
||||
FIRST_RUN_WAIT_TIMEOUT_SECS="${FIRST_RUN_WAIT_TIMEOUT_SECS:-7200}"
|
||||
|
||||
persist_env_default "$ENV_FILE" "METRICS_IMAGE" "$METRICS_IMAGE"
|
||||
persist_env_default "$ENV_FILE" "METRICS_PLATFORM" "$METRICS_PLATFORM"
|
||||
persist_env_default "$ENV_FILE" "RTR_REPORT_DIR" "$RTR_REPORT_DIR"
|
||||
persist_env_default "$ENV_FILE" "RTR_REPORT_CONTAINER_DIR" "$RTR_REPORT_CONTAINER_DIR"
|
||||
persist_env_default "$ENV_FILE" "RESOURCE_VALIDATION_MODE" "$RESOURCE_VALIDATION_MODE"
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
@@ -546,6 +756,18 @@ verify_metrics_image() {
|
||||
log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM"
|
||||
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics"
|
||||
verify_image_usage_output "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics" /opt/ours-rp/bin/rpki_artifact_metrics --help
|
||||
verify_metrics_image_rpki_client
|
||||
}
|
||||
|
||||
verify_metrics_image_rpki_client() {
|
||||
local output
|
||||
local status
|
||||
set +e
|
||||
output="$(docker run --rm --platform "$METRICS_PLATFORM" "$METRICS_IMAGE" /opt/ours-rp/bin/rpki-client -V 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" == "0" ]] || die "metrics image rpki-client -V failed: image=$METRICS_IMAGE exit=$status output=$output"
|
||||
grep -q "rpki-client" <<<"$output" || die "metrics image rpki-client -V unexpected output: $output"
|
||||
}
|
||||
|
||||
verify_image_platform() {
|
||||
|
||||
@@ -47,7 +47,21 @@ if has_success_run; then
|
||||
fi
|
||||
|
||||
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
|
||||
log "no previous successful run found; waiting for first run timeout=${timeout_secs}s"
|
||||
|
||||
@@ -13,6 +13,8 @@ Usage: ./scripts/upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-imag
|
||||
|
||||
By default, --reuse-env-from creates the new .env from this package's
|
||||
.env.example first, then overlays existing user settings from the old .env.
|
||||
For declared configuration keys, explicit invocation variables have highest
|
||||
priority, for example: INTERVAL_SECS=300 ./scripts/upgrade.sh ...
|
||||
Image tags are intentionally kept from the new package so the upgraded service
|
||||
actually runs the new packaged runtime and monitor images. Use
|
||||
--keep-reused-image only when you intentionally want to keep previous image tags.
|
||||
@@ -39,56 +41,80 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
env_get_key() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
awk -F= -v key="$key" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' "$env_path"
|
||||
}
|
||||
|
||||
env_set_key() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp_env
|
||||
if grep -q "^${key}=" "$env_path"; then
|
||||
tmp_env="$(mktemp)"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { done=0 }
|
||||
$0 ~ "^" key "=" { print key "=" value; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print key "=" value }
|
||||
' "$env_path" > "$tmp_env"
|
||||
mv "$tmp_env" "$env_path"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$env_path"
|
||||
fi
|
||||
}
|
||||
|
||||
overlay_reused_env() {
|
||||
local source_env="$1"
|
||||
local target_env="$2"
|
||||
local key
|
||||
local value
|
||||
while IFS='=' read -r key _; do
|
||||
[[ -n "$key" ]] || continue
|
||||
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
local -a reused_keys=()
|
||||
local -a ignored_removed_keys=()
|
||||
local -a preserved_package_keys=()
|
||||
|
||||
REUSED_ENV_KEYS=()
|
||||
parse_env_file "$source_env" || die "unable to parse reused environment: $source_env"
|
||||
while IFS= read -r key; do
|
||||
if ! template_has_env_key "$key"; then
|
||||
ignored_removed_keys+=("$key")
|
||||
continue
|
||||
fi
|
||||
case "$key" in
|
||||
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
|
||||
preserved_package_keys+=("$key")
|
||||
continue
|
||||
;;
|
||||
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE)
|
||||
[[ "$UPDATE_PACKAGE_IMAGE" == "0" ]] || continue
|
||||
if [[ "$UPDATE_PACKAGE_IMAGE" != "0" ]]; then
|
||||
preserved_package_keys+=("$key")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
value="$(env_get_key "$source_env" "$key")"
|
||||
env_set_key "$target_env" "$key" "$value"
|
||||
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$source_env" || true)
|
||||
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || die "unable to read $key from reused environment: $source_env"
|
||||
value="${ENV_PARSED_VALUES[$key]}"
|
||||
env_file_set_value "$target_env" "$key" "$value"
|
||||
REUSED_ENV_KEYS["$key"]=1
|
||||
reused_keys+=("$key")
|
||||
done < <(env_file_assignment_keys "$source_env")
|
||||
|
||||
log_env_key_group "reused_keys" "${reused_keys[@]}"
|
||||
log_env_key_group "ignored_removed_old_keys" "${ignored_removed_keys[@]}"
|
||||
log_env_key_group "preserved_package_keys" "${preserved_package_keys[@]}"
|
||||
}
|
||||
|
||||
apply_invocation_env_overrides() {
|
||||
local target_env="$1"
|
||||
local key
|
||||
local -a overridden_keys=()
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] || continue
|
||||
env_file_set_value "$target_env" "$key" "${INVOCATION_ENV_OVERRIDES[$key]}"
|
||||
overridden_keys+=("$key")
|
||||
done
|
||||
|
||||
log_env_key_group "command_override_keys" "${overridden_keys[@]}"
|
||||
}
|
||||
|
||||
log_template_only_keys() {
|
||||
local key
|
||||
local -a template_only_keys=()
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
[[ -n "${REUSED_ENV_KEYS[$key]+x}" ]] && continue
|
||||
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] && continue
|
||||
template_only_keys+=("$key")
|
||||
done
|
||||
log_env_key_group "template_only_keys" "${template_only_keys[@]}"
|
||||
}
|
||||
|
||||
load_env_template_keys "$ENV_EXAMPLE"
|
||||
validate_env_file_contract "$ENV_EXAMPLE"
|
||||
capture_invocation_env_overrides
|
||||
REUSED_ENV_KEYS=()
|
||||
|
||||
if [[ -n "$REUSE_ENV_FROM" ]]; then
|
||||
[[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
overlay_reused_env "$REUSE_ENV_FROM" "$ENV_FILE"
|
||||
log "created new package env from .env.example and overlaid user settings from $REUSE_ENV_FROM"
|
||||
@@ -97,6 +123,13 @@ if [[ -n "$REUSE_ENV_FROM" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log "created new package env from .env.example"
|
||||
fi
|
||||
apply_invocation_env_overrides "$ENV_FILE"
|
||||
log_template_only_keys
|
||||
validate_env_file_contract "$ENV_FILE"
|
||||
load_env
|
||||
assert_arch_compatibility
|
||||
create_data_dirs
|
||||
|
||||
206
deploy/docker-installer/tests/upgrade_env_contract_test.sh
Normal file
206
deploy/docker-installer/tests/upgrade_env_contract_test.sh
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALLER_SOURCE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ours-rp-installer-env-contract.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEST_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_equals() {
|
||||
local expected="$1"
|
||||
local actual="$2"
|
||||
local message="$3"
|
||||
[[ "$actual" == "$expected" ]] || fail "$message (expected=$expected actual=$actual)"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local needle="$1"
|
||||
local path="$2"
|
||||
! grep -Fq "$needle" "$path" || fail "unexpected value in $path: $needle"
|
||||
}
|
||||
|
||||
expect_exit() {
|
||||
local expected_status="$1"
|
||||
local output_path="$2"
|
||||
shift 2
|
||||
local actual_status
|
||||
|
||||
set +e
|
||||
"$@" >"$output_path" 2>&1
|
||||
actual_status=$?
|
||||
set -e
|
||||
[[ "$actual_status" == "$expected_status" ]] || {
|
||||
cat "$output_path" >&2 || true
|
||||
fail "unexpected exit status (expected=$expected_status actual=$actual_status)"
|
||||
}
|
||||
}
|
||||
|
||||
host_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) printf 'amd64\n' ;;
|
||||
aarch64) printf 'arm64\n' ;;
|
||||
*) fail "unsupported test host architecture: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
(
|
||||
# shellcheck disable=SC1090
|
||||
source "$INSTALLER_SOURCE/scripts/common.sh"
|
||||
env_file_value "$env_path" "$key"
|
||||
)
|
||||
}
|
||||
|
||||
create_fixture() {
|
||||
local name="$1"
|
||||
local fixture="$TEST_ROOT/$name"
|
||||
local arch
|
||||
arch="$(host_arch)"
|
||||
|
||||
mkdir -p "$fixture"
|
||||
cp -a "$INSTALLER_SOURCE"/. "$fixture"/
|
||||
rm -rf "$fixture/tests"
|
||||
mkdir -p "$fixture/images" "$fixture/fake-bin"
|
||||
sed -i \
|
||||
-e "s|__PACKAGE_ARCH__|$arch|g" \
|
||||
-e "s|__PACKAGE_PLATFORM__|linux/$arch|g" \
|
||||
-e "s|__RUNTIME_IMAGE__|ours-rp-runtime-$arch:new|g" \
|
||||
-e "s|__METRICS_IMAGE__|ours-rp-metrics-$arch:new|g" \
|
||||
-e "s|__HOST_DATA_DIR__|$fixture/data|g" \
|
||||
"$fixture/.env.example"
|
||||
cat > "$fixture/PACKAGE-MANIFEST.env" <<EOF
|
||||
PACKAGE_ARCH=$arch
|
||||
PACKAGE_PLATFORM=linux/$arch
|
||||
EOF
|
||||
cat > "$fixture/fake-bin/docker" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
|
||||
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 99
|
||||
EOF
|
||||
chmod +x "$fixture/fake-bin/docker"
|
||||
for command in jq rsync curl gzip tar; do
|
||||
cat > "$fixture/fake-bin/$command" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$fixture/fake-bin/$command"
|
||||
done
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
run_upgrade() {
|
||||
local fixture="$1"
|
||||
local old_env="$2"
|
||||
shift 2
|
||||
(
|
||||
cd "$fixture"
|
||||
export PATH="$fixture/fake-bin:$PATH"
|
||||
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
|
||||
"$@" ./scripts/upgrade.sh --reuse-env-from "$old_env"
|
||||
)
|
||||
}
|
||||
|
||||
run_upgrade_without_reuse() {
|
||||
local fixture="$1"
|
||||
(
|
||||
cd "$fixture"
|
||||
export PATH="$fixture/fake-bin:$PATH"
|
||||
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
|
||||
./scripts/upgrade.sh
|
||||
)
|
||||
}
|
||||
|
||||
new_fixture_with_old_env() {
|
||||
local name="$1"
|
||||
local fixture
|
||||
fixture="$(create_fixture "$name")"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
RPKI_IMAGE=ours-rp-runtime-old:old
|
||||
REMOVED_LEGACY_KEY=legacy-value
|
||||
EOF
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
fixture="$(create_fixture template-defaults)"
|
||||
expect_exit 99 "$fixture/template.log" run_upgrade_without_reuse "$fixture"
|
||||
assert_equals "600" "$(env_value "$fixture/.env" INTERVAL_SECS)" "template default must be retained without reuse"
|
||||
|
||||
fixture="$(new_fixture_with_old_env default-old)"
|
||||
expect_exit 99 "$fixture/default.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
assert_equals "120" "$(env_value "$fixture/.env" INTERVAL_SECS)" "old value must override template default"
|
||||
assert_equals "ours-rp-runtime-$(host_arch):new" "$(env_value "$fixture/.env" RPKI_IMAGE)" "standard upgrade must retain package image"
|
||||
assert_equals "10" "$(env_value "$fixture/.env" METRICS_POLL_SECS)" "template default must fill a key absent from old environment"
|
||||
! grep -q '^REMOVED_LEGACY_KEY=' "$fixture/.env" || fail "removed old key was copied into target environment"
|
||||
grep -q 'reused_keys=INTERVAL_SECS' "$fixture/default.log" || fail "reused key summary missing"
|
||||
grep -q 'ignored_removed_old_keys=REMOVED_LEGACY_KEY' "$fixture/default.log" || fail "removed key summary missing"
|
||||
grep -q 'template_only_keys=' "$fixture/default.log" || fail "template key summary missing"
|
||||
|
||||
fixture="$(new_fixture_with_old_env invocation-overrides)"
|
||||
expect_exit 99 "$fixture/override.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='secret with space'
|
||||
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override old value"
|
||||
assert_equals "secret with space" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value must preserve shell-sensitive content"
|
||||
grep -q 'command_override_keys=INTERVAL_SECS,GRAFANA_ADMIN_PASSWORD' "$fixture/override.log" || fail "override key summary missing"
|
||||
assert_not_contains 'secret with space' "$fixture/override.log"
|
||||
|
||||
fixture="$(new_fixture_with_old_env invocation-special-characters)"
|
||||
special_password="O'Reilly"
|
||||
special_password+=' $literal `tick` slash\'
|
||||
expect_exit 99 "$fixture/special.log" run_upgrade "$fixture" "$fixture/old.env" env GRAFANA_ADMIN_PASSWORD="$special_password"
|
||||
assert_equals "$special_password" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value with dotenv-sensitive characters must round-trip"
|
||||
assert_not_contains "$special_password" "$fixture/special.log"
|
||||
|
||||
fixture="$(create_fixture existing-target-env)"
|
||||
cp "$fixture/.env.example" "$fixture/.env"
|
||||
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=180/' "$fixture/.env"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
EOF
|
||||
expect_exit 99 "$fixture/existing.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300
|
||||
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override an existing target environment"
|
||||
|
||||
fixture="$(new_fixture_with_old_env explicit-empty)"
|
||||
expect_exit 1 "$fixture/empty.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "explicit empty value reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/empty.log" || fail "empty-value error did not identify key"
|
||||
|
||||
fixture="$(create_fixture empty-old-value)"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=
|
||||
EOF
|
||||
expect_exit 1 "$fixture/old-empty.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty reused value reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/old-empty.log" || fail "empty reused value error did not identify key"
|
||||
|
||||
fixture="$(create_fixture empty-template)"
|
||||
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=/' "$fixture/.env.example"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
EOF
|
||||
expect_exit 1 "$fixture/template-empty.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty template default reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/template-empty.log" || fail "empty template error did not identify key"
|
||||
|
||||
fixture="$(new_fixture_with_old_env package-guard)"
|
||||
arch="$(host_arch)"
|
||||
other_arch="amd64"
|
||||
[[ "$arch" == "amd64" ]] && other_arch="arm64"
|
||||
expect_exit 1 "$fixture/arch.log" run_upgrade "$fixture" "$fixture/old.env" env PACKAGE_ARCH="$other_arch"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "package architecture mismatch reached Docker"
|
||||
grep -q 'mismatches manifest PACKAGE_ARCH' "$fixture/arch.log" || fail "package architecture guard did not reject override"
|
||||
|
||||
printf 'PASS: Docker installer upgrade environment contract\n'
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -85,27 +85,66 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
mkdir -p /build-out/bin; \
|
||||
cp "target/$target_triple/release/rpki_artifact_metrics" /build-out/bin/
|
||||
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS rpki-client-builder
|
||||
|
||||
ARG RPKI_CLIENT_VERSION=9.8
|
||||
ARG RPKI_CLIENT_SHA256=42920aac5afd0996173fb9f7848691a2c49dd234e4f10478808c1aa475620861
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libexpat1-dev \
|
||||
libssl-dev \
|
||||
libtls-dev \
|
||||
pkg-config \
|
||||
rsync \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client
|
||||
|
||||
RUN set -eux; \
|
||||
curl -fsSL "https://ftp.openbsd.org/pub/OpenBSD/rpki-client/rpki-client-${RPKI_CLIENT_VERSION}.tar.gz" -o /tmp/rpki-client.tar.gz; \
|
||||
echo "${RPKI_CLIENT_SHA256} /tmp/rpki-client.tar.gz" | sha256sum -c -; \
|
||||
tar -xzf /tmp/rpki-client.tar.gz -C /tmp; \
|
||||
cd "/tmp/rpki-client-${RPKI_CLIENT_VERSION}"; \
|
||||
./configure --prefix=/opt/rpki-client; \
|
||||
make -j"$(nproc)"; \
|
||||
make install; \
|
||||
/opt/rpki-client/sbin/rpki-client -V
|
||||
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libexpat1 \
|
||||
libssl3 \
|
||||
libtls26 \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client \
|
||||
&& mkdir -p /opt/rpki-client/var/cache/rpki-client
|
||||
|
||||
WORKDIR /opt/ours-rp
|
||||
|
||||
COPY --from=builder /build-out/bin/ /opt/ours-rp/bin/
|
||||
COPY --from=rpki-client-builder /opt/rpki-client/ /opt/rpki-client/
|
||||
COPY --from=rpki-client-builder /opt/rpki-client/sbin/rpki-client /opt/ours-rp/bin/rpki-client
|
||||
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
ARG SOURCE_DIRTY=unknown
|
||||
ARG BUILD_TIMESTAMP_UTC=unknown
|
||||
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.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
||||
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
|
||||
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}" \
|
||||
org.opencontainers.image.rpki-client.version="${RPKI_CLIENT_VERSION}"
|
||||
|
||||
RUN chmod +x /opt/ours-rp/bin/* \
|
||||
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs
|
||||
|
||||
@@ -81,15 +81,24 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
;; \
|
||||
esac; \
|
||||
fi; \
|
||||
# The public crate CDN occasionally rejects a burst of parallel fetches
|
||||
# from a BuildKit worker. Keep the release build resilient without
|
||||
# changing the produced binaries or their dependency graph.
|
||||
export \
|
||||
CARGO_NET_RETRY=10 \
|
||||
CARGO_HTTP_TIMEOUT=120 \
|
||||
CARGO_HTTP_MULTIPLEXING=false; \
|
||||
cargo build --release --target "$target_triple" \
|
||||
--bin rpki \
|
||||
--bin rpki_daemon \
|
||||
--bin db_stats; \
|
||||
--bin db_stats \
|
||||
--bin rpki_der_ber_audit; \
|
||||
mkdir -p /build-out/bin; \
|
||||
cp \
|
||||
"target/$target_triple/release/rpki" \
|
||||
"target/$target_triple/release/rpki_daemon" \
|
||||
"target/$target_triple/release/db_stats" \
|
||||
"target/$target_triple/release/rpki_der_ber_audit" \
|
||||
/build-out/bin/
|
||||
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
|
||||
@@ -124,7 +133,7 @@ ARG SOURCE_COMMIT=unknown
|
||||
ARG SOURCE_DIRTY=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.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
||||
|
||||
667
scripts/benchmark/run_sync_worker_ablation.py
Normal file
667
scripts/benchmark/run_sync_worker_ablation.py
Normal file
@@ -0,0 +1,667 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run ours RP sync-worker-count ablation on a remote host.
|
||||
|
||||
The experiment intentionally mirrors the Feature #100 ours-pp-object-cache
|
||||
profile and changes only --parallel-max-repo-sync-workers-global.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRODUCT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_REMOTE = "root@47.77.204.233"
|
||||
DEFAULT_WORKERS = "1,2,4,8"
|
||||
DEFAULT_INTERVAL_SECS = 0
|
||||
DEFAULT_RUNS_PER_WORKER = 5
|
||||
|
||||
RIRS = ["afrinic", "apnic", "arin", "lacnic", "ripe"]
|
||||
TAL_URLS = {
|
||||
"afrinic": "https://rpki.afrinic.net/tal/afrinic.tal",
|
||||
"apnic": "https://tal.apnic.net/apnic.tal",
|
||||
"arin": "https://www.arin.net/resources/manage/rpki/arin.tal",
|
||||
"lacnic": "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal",
|
||||
"ripe": "https://tal.rpki.ripe.net/ripe-ncc.tal",
|
||||
}
|
||||
|
||||
|
||||
REMOTE_RUNNER = r"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
mode="$1"
|
||||
state_dir="$2"
|
||||
run_dir="$3"
|
||||
shift 3
|
||||
mkdir -p "$run_dir"
|
||||
if [[ "$mode" == "snapshot" ]]; then
|
||||
rm -rf "$state_dir"
|
||||
fi
|
||||
mkdir -p "$state_dir" "$(dirname "$run_dir")"
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$run_dir/start-time.txt"
|
||||
printf '%q ' "$@" > "$run_dir/command.txt"
|
||||
printf '\n' >> "$run_dir/command.txt"
|
||||
set +e
|
||||
/usr/bin/time -v -o "$run_dir/process-time.txt" "$@" > "$run_dir/stdout.log" 2> "$run_dir/stderr.log"
|
||||
status=$?
|
||||
set -e
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$run_dir/end-time.txt"
|
||||
printf '%s\n' "$status" > "$run_dir/exit-code.txt"
|
||||
exit "$status"
|
||||
"""
|
||||
|
||||
|
||||
REMOTE_METRICS = r"""import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
run_dir = Path(os.environ["RUN_DIR"])
|
||||
state_dir = Path(os.environ["STATE_DIR"])
|
||||
workers = int(os.environ["WORKERS"])
|
||||
mode = os.environ["MODE"]
|
||||
run_index = int(os.environ["RUN_INDEX"])
|
||||
scheduled_epoch = float(os.environ["SCHEDULED_EPOCH"])
|
||||
started_epoch = float(os.environ["STARTED_EPOCH"])
|
||||
|
||||
|
||||
def read_text(path):
|
||||
try:
|
||||
return Path(path).read_text(errors="replace")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
with Path(path).open() as fh:
|
||||
return json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"_json_error": str(exc)}
|
||||
|
||||
|
||||
def parse_time(path):
|
||||
text = read_text(path)
|
||||
out = {}
|
||||
match = re.search(r"Elapsed \(wall clock\) time.*: (?:(\d+):)?(\d+):(\d+(?:\.\d+)?)", text)
|
||||
if match:
|
||||
hours = int(match.group(1) or 0)
|
||||
minutes = int(match.group(2))
|
||||
seconds = float(match.group(3))
|
||||
out["wall_ms_timev"] = int(round(((hours * 60 + minutes) * 60 + seconds) * 1000))
|
||||
for name, key in [
|
||||
("Maximum resident set size \\(kbytes\\)", "max_rss_kb"),
|
||||
("Percent of CPU this job got", "cpu_percent_text"),
|
||||
("Major \\(requiring I/O\\) page faults", "major_faults"),
|
||||
("Minor \\(reclaiming a frame\\) page faults", "minor_faults"),
|
||||
]:
|
||||
found = re.search(name + r":\s+(.+)", text)
|
||||
if found:
|
||||
value = found.group(1).strip()
|
||||
if key.endswith("_faults") or key == "max_rss_kb":
|
||||
try:
|
||||
out[key] = int(value)
|
||||
except ValueError:
|
||||
out[key] = value
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def count_csv(path):
|
||||
rows = 0
|
||||
unique = set()
|
||||
try:
|
||||
with Path(path).open(newline="") as fh:
|
||||
reader = csv.reader(fh)
|
||||
header = next(reader, None)
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
rows += 1
|
||||
unique.add(tuple(row))
|
||||
except FileNotFoundError:
|
||||
return {"rows": None, "unique_rows": None}
|
||||
return {"rows": rows, "unique_rows": len(unique)}
|
||||
|
||||
|
||||
def dir_bytes(path):
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return 0
|
||||
total = 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def file_bytes(path):
|
||||
try:
|
||||
return Path(path).stat().st_size
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
stage = read_json(run_dir / "stage-timing.json") or {}
|
||||
process_time = parse_time(run_dir / "process-time.txt")
|
||||
exit_text = read_text(run_dir / "exit-code.txt").strip()
|
||||
start_text = read_text(run_dir / "start-time.txt").strip()
|
||||
end_text = read_text(run_dir / "end-time.txt").strip()
|
||||
|
||||
df = subprocess.run(["df", "-P", "/"], text=True, capture_output=True)
|
||||
df_line = df.stdout.strip().splitlines()[-1].split() if df.returncode == 0 and len(df.stdout.strip().splitlines()) >= 2 else []
|
||||
df_root = {}
|
||||
if len(df_line) >= 5:
|
||||
df_root = {
|
||||
"filesystem": df_line[0],
|
||||
"blocks_1k": int(df_line[1]),
|
||||
"used_1k": int(df_line[2]),
|
||||
"available_1k": int(df_line[3]),
|
||||
"use_percent": int(df_line[4].rstrip("%")),
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"workers": workers,
|
||||
"mode": mode,
|
||||
"run_index": run_index,
|
||||
"scheduled_epoch": scheduled_epoch,
|
||||
"started_epoch": started_epoch,
|
||||
"schedule_lag_ms": int(round((started_epoch - scheduled_epoch) * 1000)),
|
||||
"start_time_utc": start_text,
|
||||
"end_time_utc": end_text,
|
||||
"exit_code": int(exit_text) if exit_text.isdigit() else None,
|
||||
"run_dir": str(run_dir),
|
||||
"state_dir": str(state_dir),
|
||||
"stage": stage,
|
||||
"process_time": process_time,
|
||||
"vrps": count_csv(run_dir / "vrps.csv"),
|
||||
"vaps": count_csv(run_dir / "vaps.csv"),
|
||||
"artifact_bytes": {
|
||||
"report_json": file_bytes(run_dir / "report.json"),
|
||||
"result_ccr": file_bytes(run_dir / "result.ccr"),
|
||||
"input_cir": file_bytes(run_dir / "input.cir"),
|
||||
"vrps_csv": file_bytes(run_dir / "vrps.csv"),
|
||||
"vaps_csv": file_bytes(run_dir / "vaps.csv"),
|
||||
},
|
||||
"state_bytes": dir_bytes(state_dir),
|
||||
"run_bytes": dir_bytes(run_dir),
|
||||
"df_root": df_root,
|
||||
}
|
||||
print(json.dumps(metrics, ensure_ascii=False, sort_keys=True))
|
||||
"""
|
||||
|
||||
|
||||
def utc_stamp() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def run_cmd(cmd: list[str], *, cwd: Path | None = None, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(cmd, cwd=cwd, input=input_text, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if check and result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
raise SystemExit(result.returncode)
|
||||
return result
|
||||
|
||||
|
||||
def ssh(remote: str, command: str, *, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return run_cmd(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", remote, command], check=check, input_text=input_text)
|
||||
|
||||
|
||||
def scp_to(remote: str, local: Path, remote_path: str) -> None:
|
||||
ssh(remote, "mkdir -p " + shlex.quote(str(Path(remote_path).parent)))
|
||||
run_cmd(["scp", "-q", str(local), f"{remote}:{remote_path}"])
|
||||
|
||||
|
||||
def parse_workers(value: str) -> list[int]:
|
||||
workers = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
worker = int(part)
|
||||
if worker <= 0:
|
||||
raise argparse.ArgumentTypeError("worker count must be positive")
|
||||
workers.append(worker)
|
||||
if len(set(workers)) != len(workers):
|
||||
raise argparse.ArgumentTypeError("worker counts must be unique")
|
||||
return workers
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run ours RP sync worker ablation.")
|
||||
parser.add_argument("--remote", default=DEFAULT_REMOTE)
|
||||
parser.add_argument("--workers", type=parse_workers, default=parse_workers(DEFAULT_WORKERS))
|
||||
parser.add_argument("--interval-secs", type=int, default=DEFAULT_INTERVAL_SECS)
|
||||
parser.add_argument("--runs-per-worker", type=int, default=DEFAULT_RUNS_PER_WORKER)
|
||||
parser.add_argument("--remote-root")
|
||||
parser.add_argument("--out-root", type=Path, default=Path("specs/develop/20260714/feature115_sync_worker_ablation_runs"))
|
||||
parser.add_argument("--binary", type=Path)
|
||||
parser.add_argument("--skip-build", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def ensure_binary(args: argparse.Namespace) -> Path:
|
||||
if args.binary:
|
||||
binary = args.binary.resolve()
|
||||
if not binary.exists():
|
||||
raise SystemExit(f"binary does not exist: {binary}")
|
||||
return binary
|
||||
binary = PRODUCT_ROOT / "target" / "release" / "rpki"
|
||||
if not args.skip_build:
|
||||
print("building release rpki binary", flush=True)
|
||||
run_cmd(["cargo", "build", "--release", "--bin", "rpki"], cwd=PRODUCT_ROOT)
|
||||
if not binary.exists():
|
||||
raise SystemExit(f"release binary not found: {binary}")
|
||||
return binary
|
||||
|
||||
|
||||
def fixed_args(remote_bin: str, state_dir: str, run_dir: str, workers: int) -> list[str]:
|
||||
args = [
|
||||
remote_bin,
|
||||
"--db", f"{state_dir}/work-db",
|
||||
"--repo-bytes-db", f"{state_dir}/repo-bytes.db",
|
||||
"--rsync-mirror-root", f"{state_dir}/rsync-mirror",
|
||||
"--rsync-scope", "module-root",
|
||||
"--report-json", f"{run_dir}/report.json",
|
||||
"--report-json-compact",
|
||||
"--ccr-out", f"{run_dir}/result.ccr",
|
||||
"--cir-enable",
|
||||
"--cir-out", f"{run_dir}/input.cir",
|
||||
"--vrps-csv-out", f"{run_dir}/vrps.csv",
|
||||
"--vaps-csv-out", f"{run_dir}/vaps.csv",
|
||||
"--compare-view-trust-anchor", "all5",
|
||||
"--parallel-phase2-ready-batch-size", "256",
|
||||
"--parallel-phase2-ready-batch-wall-time-budget-ms", "100",
|
||||
"--parallel-phase2-result-drain-batch-size", "2048",
|
||||
"--parallel-phase2-finalize-batch-size", "256",
|
||||
"--parallel-phase2-finalize-batch-wall-time-budget-ms", "100",
|
||||
"--parallel-max-repo-sync-workers-global", str(workers),
|
||||
"--enable-publication-point-validation-cache",
|
||||
"--enable-roa-validation-cache",
|
||||
"--enable-child-certificate-validation-cache",
|
||||
]
|
||||
for rir in RIRS:
|
||||
url = TAL_URLS[rir]
|
||||
args.extend(["--tal-url", url, "--cir-tal-uri", url])
|
||||
return args
|
||||
|
||||
|
||||
def plan_runs(workers: list[int], runs_per_worker: int, interval_secs: int, remote_root: str, remote_bin: str) -> list[dict[str, Any]]:
|
||||
if runs_per_worker < 2:
|
||||
raise SystemExit("--runs-per-worker must be at least 2")
|
||||
planned = []
|
||||
ordinal = 0
|
||||
for worker in workers:
|
||||
case_root = f"{remote_root}/cases/workers-{worker:02d}"
|
||||
state_dir = f"{case_root}/state"
|
||||
for run_index in range(1, runs_per_worker + 1):
|
||||
mode = "snapshot" if run_index == 1 else "delta"
|
||||
run_dir = f"{case_root}/runs/run_{run_index:04d}_{mode}"
|
||||
planned.append({
|
||||
"ordinal": ordinal,
|
||||
"workers": worker,
|
||||
"mode": mode,
|
||||
"run_index": run_index,
|
||||
"state_dir": state_dir,
|
||||
"run_dir": run_dir,
|
||||
"interval_secs": interval_secs,
|
||||
"command": fixed_args(remote_bin, state_dir, run_dir, worker),
|
||||
})
|
||||
ordinal += 1
|
||||
return planned
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def prepare_remote(remote: str, remote_root: str, binary: Path) -> str:
|
||||
remote_bin = f"{remote_root}/bin/rpki"
|
||||
preflight = f"""
|
||||
set -euo pipefail
|
||||
mkdir -p {shlex.quote(remote_root)}/bin {shlex.quote(remote_root)}/env
|
||||
systemctl disable --now rpki-client.timer >/dev/null 2>&1 || true
|
||||
systemctl stop rpki-client.service >/dev/null 2>&1 || true
|
||||
pkill -x rpki-client >/dev/null 2>&1 || true
|
||||
pkill -x routinator >/dev/null 2>&1 || true
|
||||
pkill -x fort >/dev/null 2>&1 || true
|
||||
pkill -x rpki >/dev/null 2>&1 || true
|
||||
{{
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
hostname
|
||||
uname -a
|
||||
nproc
|
||||
df -h /
|
||||
free -h || true
|
||||
ps -eo pid,ppid,comm,%cpu,%mem,args --sort=-%cpu | head -30
|
||||
systemctl is-active rpki-client.timer 2>/dev/null || true
|
||||
}} > {shlex.quote(remote_root)}/env/preflight.txt
|
||||
"""
|
||||
ssh(remote, "bash -s", input_text=preflight)
|
||||
scp_to(remote, binary, remote_bin)
|
||||
ssh(remote, "chmod +x " + shlex.quote(remote_bin))
|
||||
return remote_bin
|
||||
|
||||
|
||||
def run_remote_plan(remote: str, plan: dict[str, Any], scheduled_epoch: float) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if now < scheduled_epoch:
|
||||
wait_secs = int(round(scheduled_epoch - now))
|
||||
print(f"waiting {wait_secs}s before workers={plan['workers']} {plan['mode']}", flush=True)
|
||||
time.sleep(scheduled_epoch - now)
|
||||
started_epoch = time.time()
|
||||
command = shlex.join(["bash", "-s", "--", plan["mode"], plan["state_dir"], plan["run_dir"], *plan["command"]])
|
||||
print(f"start workers={plan['workers']} {plan['mode']} run={plan['run_index']} at {datetime.now(timezone.utc).isoformat()}", flush=True)
|
||||
result = ssh(remote, command, input_text=REMOTE_RUNNER, check=False)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
metrics_script = "\n".join([
|
||||
f"export RUN_DIR={shlex.quote(plan['run_dir'])}",
|
||||
f"export STATE_DIR={shlex.quote(plan['state_dir'])}",
|
||||
f"export WORKERS={plan['workers']}",
|
||||
f"export MODE={shlex.quote(plan['mode'])}",
|
||||
f"export RUN_INDEX={plan['run_index']}",
|
||||
f"export SCHEDULED_EPOCH={scheduled_epoch}",
|
||||
f"export STARTED_EPOCH={started_epoch}",
|
||||
"python3 - <<'PY'",
|
||||
REMOTE_METRICS,
|
||||
"PY",
|
||||
])
|
||||
metrics_result = ssh(remote, "bash -s", input_text=metrics_script, check=True)
|
||||
metrics = json.loads(metrics_result.stdout)
|
||||
metrics["ssh_return_code"] = result.returncode
|
||||
metrics["command_digest"] = hashlib.sha256("\0".join(plan["command"]).encode()).hexdigest()
|
||||
ssh(
|
||||
remote,
|
||||
"cat > " + shlex.quote(plan["run_dir"] + "/metrics.json"),
|
||||
input_text=json.dumps(metrics, ensure_ascii=False, sort_keys=True) + "\n",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"remote run failed: workers={plan['workers']} mode={plan['mode']} rc={result.returncode}")
|
||||
validate_metrics(metrics)
|
||||
print(f"done workers={plan['workers']} {plan['mode']} wall={metrics.get('wall_ms')}ms vrps={metrics['vrps'].get('rows')} vaps={metrics['vaps'].get('rows')}", flush=True)
|
||||
return metrics
|
||||
|
||||
|
||||
def validate_metrics(metrics: dict[str, Any]) -> None:
|
||||
stage = metrics.get("stage") or {}
|
||||
expected = {
|
||||
"enable_transport_request_prefetch": False,
|
||||
"enable_publication_point_validation_cache": True,
|
||||
"enable_roa_validation_cache": True,
|
||||
"enable_child_certificate_validation_cache": True,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if stage.get(key) is not value:
|
||||
raise RuntimeError(f"unexpected {key}: {stage.get(key)!r}, expected {value!r}")
|
||||
if metrics["vrps"].get("rows") in (None, 0):
|
||||
raise RuntimeError("VRP CSV is missing or empty")
|
||||
if metrics["vaps"].get("rows") is None:
|
||||
raise RuntimeError("VAP CSV is missing")
|
||||
df_root = metrics.get("df_root") or {}
|
||||
if df_root.get("use_percent", 0) >= 90:
|
||||
raise RuntimeError(f"remote disk is above threshold: {df_root}")
|
||||
stage_total = stage.get("total_ms")
|
||||
timev_total = metrics.get("process_time", {}).get("wall_ms_timev")
|
||||
metrics["wall_ms"] = stage_total if isinstance(stage_total, int) else timev_total
|
||||
|
||||
|
||||
def append_metric(out_root: Path, metrics: dict[str, Any]) -> None:
|
||||
path = out_root / "per_run_metrics.jsonl"
|
||||
with path.open("a") as fh:
|
||||
fh.write(json.dumps(metrics, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def load_metrics(out_root: Path) -> list[dict[str, Any]]:
|
||||
path = out_root / "per_run_metrics.jsonl"
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def metric_row(metric: dict[str, Any]) -> dict[str, Any]:
|
||||
stage = metric.get("stage") or {}
|
||||
roa_cache = stage.get("roa_validation_cache") or {}
|
||||
return {
|
||||
"workers": metric["workers"],
|
||||
"mode": metric["mode"],
|
||||
"run_index": metric["run_index"],
|
||||
"start_time_utc": metric.get("start_time_utc"),
|
||||
"wall_ms": metric.get("wall_ms"),
|
||||
"timev_wall_ms": metric.get("process_time", {}).get("wall_ms_timev"),
|
||||
"max_rss_kb": metric.get("process_time", {}).get("max_rss_kb"),
|
||||
"cpu_percent": metric.get("process_time", {}).get("cpu_percent_text"),
|
||||
"validation_ms": stage.get("validation_ms"),
|
||||
"repo_sync_ms_total": stage.get("repo_sync_ms_total"),
|
||||
"rrdp_download_ms_total": stage.get("rrdp_download_ms_total"),
|
||||
"rsync_download_ms_total": stage.get("rsync_download_ms_total"),
|
||||
"download_bytes_total": stage.get("download_bytes_total"),
|
||||
"publication_points": stage.get("publication_points"),
|
||||
"pp_cache_hit_ratio": stage.get("publication_point_cache_hit_ratio"),
|
||||
"roa_hit_roas": roa_cache.get("hit_roas"),
|
||||
"roa_miss_roas": roa_cache.get("miss_roas"),
|
||||
"vrps_rows": metric.get("vrps", {}).get("rows"),
|
||||
"vrps_unique_rows": metric.get("vrps", {}).get("unique_rows"),
|
||||
"vaps_rows": metric.get("vaps", {}).get("rows"),
|
||||
"vaps_unique_rows": metric.get("vaps", {}).get("unique_rows"),
|
||||
"state_bytes": metric.get("state_bytes"),
|
||||
"run_bytes": metric.get("run_bytes"),
|
||||
"remote_run_dir": metric.get("run_dir"),
|
||||
}
|
||||
|
||||
|
||||
def write_csv_report(out_root: Path, metrics: list[dict[str, Any]]) -> None:
|
||||
rows = [metric_row(item) for item in metrics]
|
||||
if not rows:
|
||||
return
|
||||
with (out_root / "per_run_metrics.csv").open("w", newline="") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def fmt_ms(value: Any) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
try:
|
||||
return f"{float(value) / 1000:.2f}s"
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def fmt_bytes(value: Any) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
for unit in ["B", "KiB", "MiB", "GiB"]:
|
||||
if value < 1024 or unit == "GiB":
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return str(value)
|
||||
|
||||
|
||||
def write_markdown_report(out_root: Path, metrics: list[dict[str, Any]], provenance: dict[str, Any]) -> None:
|
||||
rows = [metric_row(item) for item in metrics]
|
||||
lines = [
|
||||
"# Feature #115 Sync Worker Ablation Summary",
|
||||
"",
|
||||
"## Experiment",
|
||||
"",
|
||||
f"- Remote: {provenance['remote']}",
|
||||
f"- Remote root: {provenance['remote_root']}",
|
||||
f"- Local commit: {provenance['git_commit']}",
|
||||
f"- Worker counts: {', '.join(str(w) for w in provenance['workers'])}",
|
||||
f"- Runs per worker: {provenance['runs_per_worker']}",
|
||||
f"- Interval seconds: {provenance['interval_secs']}",
|
||||
"- Fixed profile: all5 live TAL URL, module-root rsync scope, PP cache enabled, ROA cache enabled, child-certificate cache enabled, transport prefetch disabled.",
|
||||
"",
|
||||
"## Per Run",
|
||||
"",
|
||||
"| workers | mode | wall | rss | validation | repo sync | RRDP | rsync | download | PP | VRPs | VAPs |",
|
||||
"|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
for row in rows:
|
||||
lines.append(
|
||||
f"| {row['workers']} | {row['mode']} | {fmt_ms(row['wall_ms'])} | "
|
||||
f"{fmt_bytes((row['max_rss_kb'] or 0) * 1024 if row['max_rss_kb'] else None)} | "
|
||||
f"{fmt_ms(row['validation_ms'])} | {fmt_ms(row['repo_sync_ms_total'])} | "
|
||||
f"{fmt_ms(row['rrdp_download_ms_total'])} | {fmt_ms(row['rsync_download_ms_total'])} | "
|
||||
f"{fmt_bytes(row['download_bytes_total'])} | {row['publication_points']} | "
|
||||
f"{row['vrps_rows']} | {row['vaps_rows']} |"
|
||||
)
|
||||
lines.extend(["", "## Artifacts", "", f"- Per-run metrics JSONL: {out_root / 'per_run_metrics.jsonl'}", f"- Per-run metrics CSV: {out_root / 'per_run_metrics.csv'}"])
|
||||
write_text(out_root / "summary.md", "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def write_html_report(out_root: Path, metrics: list[dict[str, Any]], provenance: dict[str, Any]) -> None:
|
||||
rows = [metric_row(item) for item in metrics]
|
||||
if not rows:
|
||||
return
|
||||
max_wall = max(float(row["wall_ms"] or 0) for row in rows) or 1
|
||||
bars = []
|
||||
for row in rows:
|
||||
width = max(1, int((float(row["wall_ms"] or 0) / max_wall) * 100))
|
||||
bars.append(
|
||||
f"<tr><td>{row['workers']}</td><td>{html.escape(str(row['mode']))}</td>"
|
||||
f"<td>{fmt_ms(row['wall_ms'])}</td><td><div class='bar'><span style='width:{width}%'></span></div></td>"
|
||||
f"<td>{fmt_bytes((row['max_rss_kb'] or 0) * 1024 if row['max_rss_kb'] else None)}</td>"
|
||||
f"<td>{fmt_ms(row['validation_ms'])}</td><td>{fmt_ms(row['repo_sync_ms_total'])}</td>"
|
||||
f"<td>{row['publication_points']}</td><td>{row['vrps_rows']}</td><td>{row['vaps_rows']}</td></tr>"
|
||||
)
|
||||
html_doc = f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Feature #115 Sync Worker Ablation</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, -apple-system, Segoe UI, sans-serif; margin: 32px; color: #1f2933; }}
|
||||
h1, h2 {{ margin-bottom: 0.35rem; }}
|
||||
.meta {{ display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 12px; margin: 20px 0; }}
|
||||
.card {{ border: 1px solid #d8dee9; border-radius: 8px; padding: 14px; background: #f8fafc; }}
|
||||
.card b {{ display: block; font-size: 12px; color: #52606d; text-transform: uppercase; letter-spacing: .04em; }}
|
||||
.card span {{ font-size: 18px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; font-size: 13px; }}
|
||||
th, td {{ border: 1px solid #d8dee9; padding: 8px; text-align: left; vertical-align: middle; }}
|
||||
th {{ background: #eef2f7; }}
|
||||
.bar {{ width: 180px; height: 12px; background: #edf2f7; border-radius: 999px; overflow: hidden; }}
|
||||
.bar span {{ display: block; height: 100%; background: #2563eb; }}
|
||||
code {{ background: #edf2f7; padding: 2px 5px; border-radius: 4px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Feature #115 Sync Worker Ablation</h1>
|
||||
<p>This report compares only the global repo-sync worker count while holding the Feature #100 ours-cache profile fixed.</p>
|
||||
<section class="meta">
|
||||
<div class="card"><b>Remote</b><span>{html.escape(provenance['remote'])}</span></div>
|
||||
<div class="card"><b>Remote Root</b><span>{html.escape(provenance['remote_root'])}</span></div>
|
||||
<div class="card"><b>Workers</b><span>{html.escape(', '.join(str(w) for w in provenance['workers']))}</span></div>
|
||||
<div class="card"><b>Runs</b><span>{provenance['runs_per_worker']} per worker, {provenance['interval_secs']}s interval</span></div>
|
||||
</section>
|
||||
<h2>Per Run Metrics</h2>
|
||||
<table>
|
||||
<thead><tr><th>Workers</th><th>Mode</th><th>Wall</th><th>Wall Bar</th><th>Max RSS</th><th>Validation</th><th>Repo Sync</th><th>PP</th><th>VRPs</th><th>VAPs</th></tr></thead>
|
||||
<tbody>
|
||||
{''.join(bars)}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Configuration</h2>
|
||||
<p>all5 live TAL URL, module-root rsync scope, PP cache enabled, ROA cache enabled, child-certificate cache enabled, transport prefetch disabled.</p>
|
||||
<p>Local commit: <code>{html.escape(provenance['git_commit'])}</code>. Full JSONL and CSV evidence are stored beside this report.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
write_text(out_root / "summary.html", html_doc)
|
||||
|
||||
|
||||
def write_reports(out_root: Path, provenance: dict[str, Any]) -> None:
|
||||
metrics = load_metrics(out_root)
|
||||
write_csv_report(out_root, metrics)
|
||||
write_markdown_report(out_root, metrics, provenance)
|
||||
write_html_report(out_root, metrics, provenance)
|
||||
|
||||
|
||||
def git_metadata() -> dict[str, str]:
|
||||
commit = run_cmd(["git", "rev-parse", "--short", "HEAD"], cwd=PRODUCT_ROOT).stdout.strip()
|
||||
status = run_cmd(["git", "status", "--short"], cwd=PRODUCT_ROOT).stdout
|
||||
return {"git_commit": commit, "git_status_short": status}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
stamp = utc_stamp()
|
||||
out_root = (args.out_root / stamp).resolve()
|
||||
remote_root = args.remote_root or f"/root/rpki_feature115_sync_worker_ablation_{stamp}"
|
||||
remote_bin = f"{remote_root}/bin/rpki"
|
||||
plans = plan_runs(args.workers, args.runs_per_worker, args.interval_secs, remote_root, remote_bin)
|
||||
|
||||
provenance = {
|
||||
"created_at_utc": stamp,
|
||||
"remote": args.remote,
|
||||
"remote_root": remote_root,
|
||||
"workers": args.workers,
|
||||
"runs_per_worker": args.runs_per_worker,
|
||||
"interval_secs": args.interval_secs,
|
||||
"fixed_profile": "feature100 ours-pp-object-cache",
|
||||
**git_metadata(),
|
||||
}
|
||||
write_json(out_root / "provenance.json", provenance)
|
||||
write_text(out_root / "remote_root.txt", remote_root + "\n")
|
||||
write_json(out_root / "execution_plan.json", plans)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"dry-run plan written to {out_root}")
|
||||
return 0
|
||||
|
||||
binary = ensure_binary(args)
|
||||
remote_bin = prepare_remote(args.remote, remote_root, binary)
|
||||
plans = plan_runs(args.workers, args.runs_per_worker, args.interval_secs, remote_root, remote_bin)
|
||||
write_json(out_root / "execution_plan.json", plans)
|
||||
start_epoch = time.time()
|
||||
for plan in plans:
|
||||
write_json(
|
||||
out_root / "run_configs" / f"workers-{plan['workers']:02d}-run-{plan['run_index']:04d}-{plan['mode']}.json",
|
||||
plan,
|
||||
)
|
||||
scheduled_epoch = start_epoch + plan["ordinal"] * args.interval_secs
|
||||
metrics = run_remote_plan(args.remote, plan, scheduled_epoch)
|
||||
append_metric(out_root, metrics)
|
||||
write_reports(out_root, provenance)
|
||||
print(f"reports written under {out_root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
107
scripts/ccr/check_rpki_client_ccr.sh
Executable file
107
scripts/ccr/check_rpki_client_ccr.sh
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
check_rpki_client_ccr.sh \
|
||||
--rpki-client <path> \
|
||||
--ccr-root <directory-or-ccr-file> \
|
||||
--out <result.jsonl> \
|
||||
[--limit <count>]
|
||||
|
||||
Runs the supplied rpki-client importer against CCR files. A non-zero result
|
||||
means that rpki-client reported a CCR-file parsing error or could not start.
|
||||
Per-file command output is retained next to the JSONL result file.
|
||||
EOF
|
||||
}
|
||||
|
||||
RPKI_CLIENT=""
|
||||
CCR_ROOT=""
|
||||
OUT=""
|
||||
LIMIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--rpki-client) RPKI_CLIENT=${2:?missing value for --rpki-client}; shift 2 ;;
|
||||
--ccr-root) CCR_ROOT=${2:?missing value for --ccr-root}; shift 2 ;;
|
||||
--out) OUT=${2:?missing value for --out}; shift 2 ;;
|
||||
--limit) LIMIT=${2:?missing value for --limit}; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$RPKI_CLIENT" && -n "$CCR_ROOT" && -n "$OUT" ]] || {
|
||||
echo "error: --rpki-client, --ccr-root, and --out are required" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -x "$RPKI_CLIENT" ]] || { echo "error: rpki-client is not executable: $RPKI_CLIENT" >&2; exit 2; }
|
||||
[[ "$LIMIT" =~ ^[0-9]+$ ]] || { echo "error: --limit must be a non-negative integer" >&2; exit 2; }
|
||||
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
LOG_DIR="${OUT%.jsonl}.logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
: > "$OUT"
|
||||
|
||||
declare -a CCR_FILES=()
|
||||
if [[ -f "$CCR_ROOT" ]]; then
|
||||
CCR_FILES=("$(readlink -f "$CCR_ROOT")")
|
||||
elif [[ -d "$CCR_ROOT" ]]; then
|
||||
while IFS= read -r -d '' file; do
|
||||
CCR_FILES+=("$file")
|
||||
done < <(find "$CCR_ROOT" -type f -name '*.ccr' -print0 | sort -z)
|
||||
else
|
||||
echo "error: CCR root does not exist: $CCR_ROOT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if (( LIMIT > 0 && ${#CCR_FILES[@]} > LIMIT )); then
|
||||
CCR_FILES=("${CCR_FILES[@]:0:LIMIT}")
|
||||
fi
|
||||
if (( ${#CCR_FILES[@]} == 0 )); then
|
||||
echo "error: no .ccr files found under: $CCR_ROOT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
for ccr in "${CCR_FILES[@]}"; do
|
||||
digest=$(sha256sum "$ccr" | awk '{print $1}')
|
||||
log="$LOG_DIR/${digest}.log"
|
||||
set +e
|
||||
"$RPKI_CLIENT" -f "$ccr" >"$log" 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
classification=pass
|
||||
if (( exit_code != 0 )); then
|
||||
classification=tool_error
|
||||
elif grep -Fq "rpki-client: ${ccr}:" "$log"; then
|
||||
classification=ccr_parse_error
|
||||
fi
|
||||
if [[ "$classification" == pass ]]; then
|
||||
((passed += 1))
|
||||
else
|
||||
((failed += 1))
|
||||
fi
|
||||
|
||||
python3 - "$OUT" "$ccr" "$digest" "$exit_code" "$classification" "$log" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
out, ccr, sha256, exit_code, classification, log = sys.argv[1:]
|
||||
with open(out, "a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps({
|
||||
"ccr": ccr,
|
||||
"sha256": sha256,
|
||||
"rpkiClientExitCode": int(exit_code),
|
||||
"classification": classification,
|
||||
"log": log,
|
||||
}, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
PY
|
||||
done
|
||||
|
||||
echo "checked=${#CCR_FILES[@]} passed=${passed} failed=${failed} jsonl=${OUT} logs=${LOG_DIR}"
|
||||
(( failed == 0 ))
|
||||
@@ -5,6 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
TARGET_ARCH="${TARGET_ARCH:-}"
|
||||
VERSION="${VERSION:-}"
|
||||
IMAGE_TAG=""
|
||||
IMAGE_TAR=""
|
||||
METRICS_IMAGE=""
|
||||
@@ -25,6 +26,10 @@ Usage:
|
||||
|
||||
Options:
|
||||
--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 to record and package.
|
||||
--prometheus-image-tar <path>
|
||||
@@ -42,7 +47,7 @@ Options:
|
||||
-h, --help Show help.
|
||||
|
||||
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.
|
||||
USAGE
|
||||
}
|
||||
@@ -72,17 +77,17 @@ safe_tag_name() {
|
||||
runtime_image_tag() {
|
||||
local arch="$1"
|
||||
local revision="$2"
|
||||
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$revision"
|
||||
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$revision"
|
||||
}
|
||||
|
||||
metrics_image_tag() {
|
||||
local arch="$1"
|
||||
local revision="$2"
|
||||
printf 'ours-rp-metrics-%s:%s\n' "$arch" "$revision"
|
||||
printf 'panda-rpki-metrics-%s:%s\n' "$arch" "$revision"
|
||||
}
|
||||
|
||||
default_package_prefix() {
|
||||
printf 'ours-rp-installer-%s\n' "$1"
|
||||
printf 'panda-rpki-rp-installer-%s\n' "$1"
|
||||
}
|
||||
|
||||
default_host_data_dir() {
|
||||
@@ -180,6 +185,10 @@ while [[ $# -gt 0 ]]; do
|
||||
}
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--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
|
||||
exit 2
|
||||
@@ -254,8 +263,36 @@ will be explicitly marked dirty.
|
||||
EOF
|
||||
exit 2
|
||||
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)"
|
||||
REVISION_TOKEN="$SOURCE_COMMIT_SHORT"
|
||||
REVISION_TOKEN="$VERSION"
|
||||
if [[ "$SOURCE_DIRTY" == "true" ]]; then
|
||||
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
|
||||
fi
|
||||
@@ -367,6 +404,8 @@ package_schema_version=2
|
||||
package_name=$package_name
|
||||
created_at_utc=$TIMESTAMP
|
||||
build_timestamp_utc=$TIMESTAMP
|
||||
version=$VERSION
|
||||
git_tag=${GIT_TAG_AT_HEAD:-}
|
||||
source_commit=$SOURCE_COMMIT_FULL
|
||||
source_commit_short=$SOURCE_COMMIT_SHORT
|
||||
source_dirty=$SOURCE_DIRTY
|
||||
@@ -419,6 +458,8 @@ EOF
|
||||
|
||||
cat > "$stage/PACKAGE-SUMMARY.txt" <<EOF
|
||||
package_name: $package_name
|
||||
version: $VERSION
|
||||
git_tag: ${GIT_TAG_AT_HEAD:-}
|
||||
source_commit: $SOURCE_COMMIT_FULL
|
||||
source_commit_short: $SOURCE_COMMIT_SHORT
|
||||
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
|
||||
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
|
||||
fi
|
||||
IMAGE_TAG="ours-rp-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
|
||||
IMAGE_TAG="panda-rpki-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
|
||||
fi
|
||||
|
||||
if [[ "$dockerfile_explicit" != "1" ]]; then
|
||||
|
||||
@@ -14,6 +14,10 @@ BUILDER_NAME="${BUILDER_NAME:-default}"
|
||||
INSTALL_BINFMT="${INSTALL_BINFMT:-1}"
|
||||
SAVE_IMAGE="${SAVE_IMAGE:-1}"
|
||||
LOAD_IMAGE="${LOAD_IMAGE:-1}"
|
||||
# BuildKit's default bridge network can differ from the Docker daemon proxy
|
||||
# configuration. Set BUILD_NETWORK=host when the build container needs the
|
||||
# host's known-good registry connectivity.
|
||||
BUILD_NETWORK="${BUILD_NETWORK:-default}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
@@ -22,7 +26,7 @@ Usage:
|
||||
|
||||
Options:
|
||||
--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)
|
||||
--dockerfile <path> Dockerfile path
|
||||
--builder <name> buildx builder name
|
||||
@@ -69,7 +73,7 @@ platform_for_arch() {
|
||||
default_image_tag() {
|
||||
local arch="$1"
|
||||
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() {
|
||||
@@ -216,6 +220,7 @@ echo "builder_source_image: $builder_source_image"
|
||||
echo "runtime_source_image: $runtime_source_image"
|
||||
echo "builder_local_image: $BUILDER_IMAGE"
|
||||
echo "runtime_local_image: $RUNTIME_IMAGE"
|
||||
echo "build_network: $BUILD_NETWORK"
|
||||
echo "source_commit: $SOURCE_COMMIT_FULL"
|
||||
echo "source_dirty: $SOURCE_DIRTY"
|
||||
echo "build_timestamp_utc: $BUILD_TIMESTAMP_UTC"
|
||||
@@ -225,6 +230,7 @@ if [[ "$LOAD_IMAGE" == "1" ]]; then
|
||||
docker buildx build \
|
||||
--platform "$target_platform" \
|
||||
--builder "$BUILDER_NAME" \
|
||||
--network "$BUILD_NETWORK" \
|
||||
--pull=false \
|
||||
--load \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
@@ -242,6 +248,7 @@ else
|
||||
docker buildx build \
|
||||
--platform "$target_platform" \
|
||||
--builder "$BUILDER_NAME" \
|
||||
--network "$BUILD_NETWORK" \
|
||||
--pull=false \
|
||||
--output "type=docker,dest=$raw_tar_path" \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
# rpctl
|
||||
|
||||
`rpctl` 是四款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次 delta 子 run。
|
||||
`rpctl` 是五款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次复用 state 的子 run。
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
mkdir -p /root/rpki_4rp_ctl
|
||||
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.77.237.41:/root/rpki_4rp_ctl/
|
||||
rsync -a specs/develop/20260707_3/feature100_four_rp_interval_archive/runtime_bundle/ root@47.77.237.41:/root/rpki_4rp_ctl_runtime_bundle/
|
||||
ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle'
|
||||
mkdir -p /data/rpki_4rp_ctl
|
||||
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.251.127.231:/data/rpki_4rp_ctl/
|
||||
rsync -a artifacts/rpki-prover/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz root@47.251.127.231:/data/
|
||||
ssh root@47.251.127.231 'cd /data/rpki_4rp_ctl && ./rpctl init \
|
||||
--bundle /data/rpki_4rp_ctl_runtime_bundle \
|
||||
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz'
|
||||
```
|
||||
|
||||
`--bundle` 目录需要包含 #100 runtime bundle 的 `bin/`、`lib/`、`fixtures/`、`scripts/` 和 `versions.json`。不要使用早期 precheck 目录作为标准 bundle,因为它可能缺少 FORT 运行所需动态库。
|
||||
|
||||
`--rpki-prover-image` 是可选项;不配置时原有四款 RP 仍可运行,但启动 rpki-prover 会明确报错。配置后 `init` 会复制并加载离线镜像,校验宿主机和镜像均为 `amd64`,确认 tag 为 `rpki-prover:6857d4bf`、版本为 `rpki-prover-0.10.1`,并将 image ID 与 archive SHA-256 写入 `config.json`。运行时不会在线拉取镜像。
|
||||
|
||||
## ours RP 默认同步并发
|
||||
|
||||
`ours` 和 `ours-no-cache` 两个 profile 都会显式传入 `--parallel-max-repo-sync-workers-global 8`,即默认使用 8 个全局仓库同步 worker。此设置只作用于 ours RP;Routinator、rpki-client 与 FORT 的运行参数保持不变。`show` 与 `view --command` 会在参数摘要中显示 `sync-workers=8`。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
@@ -20,6 +28,8 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
|
||||
./rpctl status
|
||||
./rpctl start --rp ours-pp-object-cache --interval 10m --runs 6 --rirs all5
|
||||
./rpctl start --rp routinator --interval 0 --runs 1 --rirs apnic
|
||||
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
|
||||
./rpctl start --rp rpki-prover-full --interval 0 --runs 1 --rirs all5
|
||||
./rpctl continue --exp-id <successful-exp-id> --runs 3 --interval 10m
|
||||
./rpctl tail
|
||||
./rpctl show
|
||||
@@ -40,6 +50,10 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
|
||||
| `routinator` | `routinator-latest-release` |
|
||||
| `rpki-client` | `rpki-client-latest-release` |
|
||||
| `fort` | `fort-latest-release` |
|
||||
| `rpki-prover`, `rpki-prover-incremental` | `rpki-prover-incremental` |
|
||||
| `rpki-prover-full`, `rpki-prover-no-incremental` | `rpki-prover-full` |
|
||||
|
||||
`rpki-prover-incremental` 使用上游默认增量算法;`rpki-prover-full` 显式传入 `--no-incremental-validation`。两者的 state 位于各自实验/profile 目录,互不复用。首轮显示为 `snapshot`;后续和 `continue` 追加轮显示为 `warm`,表示保留了上轮 state,实际 RRDP snapshot/delta 决策仍由 rpki-prover 自身完成。
|
||||
|
||||
## 数据目录
|
||||
|
||||
@@ -53,7 +67,9 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
|
||||
runs/<profile>/<exp-id>/profiles/<profile>/runs/run_0001/
|
||||
```
|
||||
|
||||
每个子 run 目录包含 `stdout.log`、`stderr.log`、`process-time.txt`、`exit-code.txt`、`run-meta.json` 以及各 RP 产物。
|
||||
每个子 run 目录包含 `stdout.log`、`stderr.log`、`exit-code.txt`、`run-meta.json` 以及各 RP 产物。传统四 RP 使用 `process-time.txt`;rpki-prover 额外保存 `container-inspect.json`、`rss-samples.tsv` 和 `vrps.csv`。
|
||||
|
||||
rpki-prover 的 `run-meta.json` 和 `view` 同时记录 VRP 原始行数与按 `(ASN, prefix, maxLength)` 去重数。其当前 CLI 不输出 VAP,所以统一显示 `n/a`,不是 `0`。max RSS 是容器全部相关 PID 的 RSS 峰值汇总,不是短暂 Docker client 进程的 RSS。
|
||||
|
||||
## 历史查看与删除
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import csv
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
@@ -37,6 +39,8 @@ PROFILES = {
|
||||
"routinator-latest-release": "routinator",
|
||||
"rpki-client-latest-release": "rpki-client",
|
||||
"fort-latest-release": "fort",
|
||||
"rpki-prover-incremental": "rpki-prover",
|
||||
"rpki-prover-full": "rpki-prover",
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
@@ -52,8 +56,15 @@ ALIASES = {
|
||||
"rpki-client-latest-release": "rpki-client-latest-release",
|
||||
"fort": "fort-latest-release",
|
||||
"fort-latest-release": "fort-latest-release",
|
||||
"rpki-prover": "rpki-prover-incremental",
|
||||
"rpki-prover-incremental": "rpki-prover-incremental",
|
||||
"rpki-prover-full": "rpki-prover-full",
|
||||
"rpki-prover-no-incremental": "rpki-prover-full",
|
||||
}
|
||||
|
||||
RPKI_PROVER_IMAGE_TAG = "rpki-prover:6857d4bf"
|
||||
RPKI_PROVER_VERSION = "rpki-prover-0.10.1"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"root": "/root/rpki_4rp_ctl",
|
||||
"bundle": "",
|
||||
@@ -207,6 +218,14 @@ def command_string(argv: list[str]) -> str:
|
||||
return " ".join(shlex.quote(x) for x in argv)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def check_bundle(root: Path) -> None:
|
||||
required = ["bin/rpki", "bin/routinator", "bin/rpki-client", "bin/fort", "fixtures/tal", "scripts/normalize_and_collect.py", "scripts/write_group_report.py", "versions.json"]
|
||||
missing = [item for item in required if not (root / item).exists()]
|
||||
@@ -232,6 +251,102 @@ def install_from_bundle(bundle: Path) -> None:
|
||||
item.chmod(item.stat().st_mode | 0o111)
|
||||
|
||||
|
||||
def validate_rpki_prover_host() -> None:
|
||||
arch = platform.machine().lower()
|
||||
if arch not in {"x86_64", "amd64"}:
|
||||
raise SystemExit(f"rpki-prover image requires amd64 host, found: {arch}")
|
||||
if shutil.which("docker") is None:
|
||||
raise SystemExit("docker is required for rpki-prover profiles")
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"docker is not available: {result.stderr.strip()}")
|
||||
|
||||
|
||||
def inspect_rpki_prover_image() -> dict[str, str]:
|
||||
inspect = subprocess.run(
|
||||
["docker", "image", "inspect", RPKI_PROVER_IMAGE_TAG],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if inspect.returncode != 0:
|
||||
raise SystemExit(f"loaded image is missing tag {RPKI_PROVER_IMAGE_TAG}: {inspect.stderr.strip()}")
|
||||
data = json.loads(inspect.stdout)[0]
|
||||
architecture = str(data.get("Architecture") or "")
|
||||
if architecture != "amd64":
|
||||
raise SystemExit(f"rpki-prover image architecture mismatch: expected amd64, found {architecture or '<unknown>'}")
|
||||
version = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "/opt/app/rpki-prover", RPKI_PROVER_IMAGE_TAG, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
version_text = version.stdout.strip()
|
||||
if version.returncode != 0 or version_text != RPKI_PROVER_VERSION:
|
||||
raise SystemExit(
|
||||
f"rpki-prover version check failed: expected {RPKI_PROVER_VERSION}, "
|
||||
f"exit={version.returncode} output={version_text or version.stderr.strip()}"
|
||||
)
|
||||
return {
|
||||
"tag": RPKI_PROVER_IMAGE_TAG,
|
||||
"imageId": str(data.get("Id") or ""),
|
||||
"architecture": architecture,
|
||||
"version": version_text,
|
||||
}
|
||||
|
||||
|
||||
def install_rpki_prover_image(archive: Path) -> dict[str, str]:
|
||||
if not archive.is_file():
|
||||
raise SystemExit(f"rpki-prover image archive does not exist: {archive}")
|
||||
validate_rpki_prover_host()
|
||||
image_dir = ctl_root() / "images"
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = image_dir / archive.name
|
||||
if archive != destination:
|
||||
shutil.copy2(archive, destination)
|
||||
load = subprocess.run(
|
||||
["docker", "load", "--input", str(destination)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if load.returncode != 0:
|
||||
raise SystemExit(f"failed to load rpki-prover image archive: {load.stderr.strip()}")
|
||||
metadata = inspect_rpki_prover_image()
|
||||
metadata.update({
|
||||
"archive": str(destination),
|
||||
"archiveSha256": sha256_file(destination),
|
||||
"loadedAtUtc": utc_now(),
|
||||
})
|
||||
return metadata
|
||||
|
||||
|
||||
def require_rpki_prover_image() -> dict[str, Any]:
|
||||
metadata = load_config().get("rpkiProverImage")
|
||||
if not isinstance(metadata, dict) or metadata.get("tag") != RPKI_PROVER_IMAGE_TAG:
|
||||
raise SystemExit(
|
||||
"rpki-prover image is not initialized; run ./rpctl init --bundle <bundle> "
|
||||
"--rpki-prover-image <docker.tar.gz>"
|
||||
)
|
||||
validate_rpki_prover_host()
|
||||
inspected = inspect_rpki_prover_image()
|
||||
if metadata.get("imageId") and metadata.get("imageId") != inspected.get("imageId"):
|
||||
raise SystemExit(
|
||||
f"rpki-prover image id changed: configured={metadata.get('imageId')} "
|
||||
f"loaded={inspected.get('imageId')}"
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
ensure_dirs()
|
||||
bundle = Path(args.bundle).resolve()
|
||||
@@ -239,9 +354,16 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
cfg = load_config()
|
||||
cfg["bundle"] = str(bundle)
|
||||
cfg["initializedAtUtc"] = utc_now()
|
||||
if args.rpki_prover_image:
|
||||
cfg["rpkiProverImage"] = install_rpki_prover_image(Path(args.rpki_prover_image).resolve())
|
||||
save_config(cfg)
|
||||
print(f"initialized root={ctl_root()}")
|
||||
print(f"bundle={bundle}")
|
||||
if cfg.get("rpkiProverImage"):
|
||||
image = cfg["rpkiProverImage"]
|
||||
print(f"rpki_prover_image={image.get('tag')}")
|
||||
print(f"rpki_prover_image_id={image.get('imageId')}")
|
||||
print(f"rpki_prover_archive_sha256={image.get('archiveSha256')}")
|
||||
|
||||
|
||||
def timed_run(run_dir: Path, argv: list[str], env: dict[str, str] | None = None) -> int:
|
||||
@@ -293,6 +415,7 @@ def build_ours_args(profile: str, run_root: Path, run_dir: Path, state: Path, ri
|
||||
"--vrps-csv-out", str(run_dir / "vrps.csv"),
|
||||
"--vaps-csv-out", str(run_dir / "vaps.csv"),
|
||||
"--compare-view-trust-anchor", "all5" if len(rirs) > 1 else rirs[0],
|
||||
"--parallel-max-repo-sync-workers-global", "8",
|
||||
"--parallel-phase2-ready-batch-size", "256",
|
||||
"--parallel-phase2-ready-batch-wall-time-budget-ms", "100",
|
||||
"--parallel-phase2-result-drain-batch-size", "2048",
|
||||
@@ -396,6 +519,240 @@ def build_fort_args(run_dir: Path, state: Path, rirs: list[str]) -> tuple[list[s
|
||||
return argv, env
|
||||
|
||||
|
||||
def prepare_rpki_prover_state(state: Path, rirs: list[str]) -> None:
|
||||
tal_dir = state / "tals"
|
||||
tal_dir.mkdir(parents=True, exist_ok=True)
|
||||
for old_tal in tal_dir.glob("*.tal"):
|
||||
old_tal.unlink()
|
||||
for rir in rirs:
|
||||
shutil.copy2(ctl_root() / "fixtures" / "tal" / RIR_TAL[rir], tal_dir / RIR_TAL[rir])
|
||||
|
||||
|
||||
def rpki_prover_arguments(profile: str) -> list[str]:
|
||||
argv = [
|
||||
"--once",
|
||||
"--vrp-output", "/outputs/vrps.csv",
|
||||
"--no-rir-tals",
|
||||
"--rpki-root-directory", "/rpki-data",
|
||||
"--cpu-count", "4",
|
||||
"--fetcher-count", "8",
|
||||
"--log-level", "info",
|
||||
]
|
||||
if profile == "rpki-prover-full":
|
||||
argv.append("--no-incremental-validation")
|
||||
return argv
|
||||
|
||||
|
||||
def build_rpki_prover_command(profile: str, run_dir: Path, state: Path, container_name: str) -> list[str]:
|
||||
return [
|
||||
"docker", "run", "--name", container_name,
|
||||
"-v", f"{state.resolve()}:/rpki-data",
|
||||
"-v", f"{run_dir.resolve()}:/outputs",
|
||||
RPKI_PROVER_IMAGE_TAG,
|
||||
*rpki_prover_arguments(profile),
|
||||
]
|
||||
|
||||
|
||||
def container_rss_kb(container_name: str) -> tuple[int, int]:
|
||||
top = subprocess.run(
|
||||
["docker", "top", container_name, "-eo", "pid"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
total = 0
|
||||
maximum = 0
|
||||
for line in top.stdout.splitlines():
|
||||
pid = line.strip()
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
status = Path(f"/proc/{pid}/status").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
match = re.search(r"^VmRSS:\s+(\d+)\s+kB$", status, re.MULTILINE)
|
||||
if not match:
|
||||
continue
|
||||
rss = int(match.group(1))
|
||||
total += rss
|
||||
maximum = max(maximum, rss)
|
||||
return total, maximum
|
||||
|
||||
|
||||
def timed_rpki_prover_run(
|
||||
profile: str,
|
||||
run_dir: Path,
|
||||
state: Path,
|
||||
container_name: str,
|
||||
) -> tuple[int, int, int, int, dict[str, Any]]:
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
start_utc = utc_now()
|
||||
start_ns = time.monotonic_ns()
|
||||
(run_dir / "start-utc.txt").write_text(start_utc + "\n", encoding="utf-8")
|
||||
create_argv = [
|
||||
"docker", "create", "--name", container_name,
|
||||
"-v", f"{state.resolve()}:/rpki-data",
|
||||
"-v", f"{run_dir.resolve()}:/outputs",
|
||||
RPKI_PROVER_IMAGE_TAG,
|
||||
*rpki_prover_arguments(profile),
|
||||
]
|
||||
create = subprocess.run(create_argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
|
||||
if create.returncode != 0:
|
||||
(run_dir / "stderr.log").write_text(create.stderr, encoding="utf-8")
|
||||
(run_dir / "stdout.log").write_text(create.stdout, encoding="utf-8")
|
||||
(run_dir / "end-utc.txt").write_text(utc_now() + "\n", encoding="utf-8")
|
||||
(run_dir / "exit-code.txt").write_text(str(create.returncode) + "\n", encoding="utf-8")
|
||||
return create.returncode, 0, 0, (time.monotonic_ns() - start_ns) // 1_000_000, {}
|
||||
|
||||
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
|
||||
def terminate_container(signum: int, _frame: Any) -> None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "--force", container_name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
signal.signal(signal.SIGTERM, terminate_container)
|
||||
|
||||
max_total_rss_kb = 0
|
||||
max_process_rss_kb = 0
|
||||
inspect_data: dict[str, Any] = {}
|
||||
samples_path = run_dir / "rss-samples.tsv"
|
||||
with (run_dir / "stdout.log").open("wb") as stdout, (run_dir / "stderr.log").open("wb") as stderr, samples_path.open("w", encoding="utf-8") as samples:
|
||||
samples.write("epoch_ms\ttotal_rss_kb\tmax_process_rss_kb\n")
|
||||
start = subprocess.Popen(["docker", "start", "--attach", container_name], stdout=stdout, stderr=stderr)
|
||||
while start.poll() is None:
|
||||
total_rss_kb, process_rss_kb = container_rss_kb(container_name)
|
||||
max_total_rss_kb = max(max_total_rss_kb, total_rss_kb)
|
||||
max_process_rss_kb = max(max_process_rss_kb, process_rss_kb)
|
||||
samples.write(f"{int(time.time() * 1000)}\t{total_rss_kb}\t{process_rss_kb}\n")
|
||||
samples.flush()
|
||||
time.sleep(0.25)
|
||||
start.wait()
|
||||
|
||||
try:
|
||||
inspect = subprocess.run(
|
||||
["docker", "inspect", container_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if inspect.returncode == 0:
|
||||
parsed = json.loads(inspect.stdout)
|
||||
if parsed:
|
||||
inspect_data = parsed[0]
|
||||
write_json(run_dir / "container-inspect.json", inspect_data)
|
||||
state_data = inspect_data.get("State", {}) if inspect_data else {}
|
||||
exit_code = int(state_data.get("ExitCode", start.returncode if start.returncode is not None else 1))
|
||||
finally:
|
||||
subprocess.run(["docker", "rm", "--force", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
signal.signal(signal.SIGTERM, previous_sigterm)
|
||||
wall_ms = (time.monotonic_ns() - start_ns) // 1_000_000
|
||||
(run_dir / "end-utc.txt").write_text(utc_now() + "\n", encoding="utf-8")
|
||||
(run_dir / "exit-code.txt").write_text(str(exit_code) + "\n", encoding="utf-8")
|
||||
return exit_code, max_total_rss_kb, max_process_rss_kb, wall_ms, inspect_data
|
||||
|
||||
|
||||
def collect_rpki_prover_vrps(path: Path) -> tuple[int, set[str]]:
|
||||
unique: set[str] = set()
|
||||
raw = 0
|
||||
if not path.is_file():
|
||||
return raw, unique
|
||||
with path.open("r", encoding="utf-8", errors="replace", newline="") as handle:
|
||||
for row in csv.reader(handle):
|
||||
if not row or row[0].strip().lower() == "asn":
|
||||
continue
|
||||
if len(row) < 3:
|
||||
continue
|
||||
raw += 1
|
||||
unique.add(",".join(value.strip() for value in row[:3]))
|
||||
return raw, unique
|
||||
|
||||
|
||||
def write_rpki_prover_run_metadata(
|
||||
run_root: Path,
|
||||
profile: str,
|
||||
run_seq: int,
|
||||
mode: str,
|
||||
wall_ms: int,
|
||||
max_rss_kb: int,
|
||||
max_process_rss_kb: int,
|
||||
exit_code: int,
|
||||
) -> dict[str, Any]:
|
||||
run_dir = run_root / "profiles" / profile / "runs" / f"run_{run_seq:04d}"
|
||||
raw_vrps, unique_vrps = collect_rpki_prover_vrps(run_dir / "vrps.csv")
|
||||
with gzip.open(run_dir / "normalized-vrps.txt.gz", "wt", encoding="utf-8") as handle:
|
||||
for row in sorted(unique_vrps):
|
||||
handle.write(row + "\n")
|
||||
image = load_config().get("rpkiProverImage", {})
|
||||
meta = {
|
||||
"profile": profile,
|
||||
"rp": "rpki-prover",
|
||||
"runSeq": run_seq,
|
||||
"syncMode": mode,
|
||||
"startUtc": read_text_strip(run_dir / "start-utc.txt"),
|
||||
"endUtc": read_text_strip(run_dir / "end-utc.txt"),
|
||||
"exitCode": exit_code,
|
||||
"timing": {
|
||||
"wallMs": wall_ms,
|
||||
"maxRssKb": max_rss_kb,
|
||||
"maxProcessRssKb": max_process_rss_kb,
|
||||
},
|
||||
"counts": {
|
||||
"vrps": raw_vrps,
|
||||
"vrpsRaw": raw_vrps,
|
||||
"vrpsUnique": len(unique_vrps),
|
||||
"vaps": "n/a",
|
||||
},
|
||||
"vapsAvailable": False,
|
||||
"validationAlgorithm": "full-every-iteration" if profile == "rpki-prover-full" else "incremental",
|
||||
"image": image,
|
||||
"remoteRunDir": str(run_dir),
|
||||
}
|
||||
write_json(run_dir / "run-meta.json", meta)
|
||||
return meta
|
||||
|
||||
|
||||
def write_rpki_prover_group_report(run_root: Path, profile: str) -> None:
|
||||
rows: list[dict[str, Any]] = []
|
||||
run_dirs = sorted((run_root / "profiles" / profile / "runs").glob("run_*"))
|
||||
for run_dir in run_dirs:
|
||||
meta = read_json(run_dir / "run-meta.json", {})
|
||||
if not meta:
|
||||
continue
|
||||
rows.append({
|
||||
"profile": profile,
|
||||
"rp": "rpki-prover",
|
||||
"run_seq": meta.get("runSeq", ""),
|
||||
"sync_mode": meta.get("syncMode", ""),
|
||||
"start_utc": meta.get("startUtc", ""),
|
||||
"end_utc": meta.get("endUtc", ""),
|
||||
"exit_code": meta.get("exitCode", ""),
|
||||
"wall_ms": meta.get("timing", {}).get("wallMs", ""),
|
||||
"max_rss_kb": meta.get("timing", {}).get("maxRssKb", ""),
|
||||
"max_process_rss_kb": meta.get("timing", {}).get("maxProcessRssKb", ""),
|
||||
"vrps": meta.get("counts", {}).get("vrpsRaw", ""),
|
||||
"vrps_unique": meta.get("counts", {}).get("vrpsUnique", ""),
|
||||
"vaps": "n/a",
|
||||
"vaps_available": False,
|
||||
"validation_algorithm": meta.get("validationAlgorithm", ""),
|
||||
"remote_run_dir": str(run_dir),
|
||||
})
|
||||
reports = run_root / "reports"
|
||||
reports.mkdir(parents=True, exist_ok=True)
|
||||
if rows:
|
||||
with (reports / "per_run_metrics.csv").open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
write_json(reports / "per_run_metrics.json", {"generatedAtUtc": utc_now(), "rows": rows})
|
||||
|
||||
|
||||
def normalize_one(run_root: Path, profile: str, run_seq: int) -> None:
|
||||
script = ctl_root() / "scripts" / "normalize_and_collect.py"
|
||||
if not script.exists():
|
||||
@@ -460,6 +817,18 @@ def parse_entry_rirs(entry: dict[str, Any]) -> list[str]:
|
||||
return parse_rirs(str(value))
|
||||
|
||||
|
||||
def write_experiment_versions(run_root: Path) -> None:
|
||||
versions = read_json(ctl_root() / "versions.json", {})
|
||||
image = load_config().get("rpkiProverImage")
|
||||
if image:
|
||||
versions["rpki-prover"] = image
|
||||
profiles = versions.setdefault("profiles", [])
|
||||
for profile in ["rpki-prover-incremental", "rpki-prover-full"]:
|
||||
if profile not in profiles:
|
||||
profiles.append(profile)
|
||||
write_json(run_root / "versions.json", versions)
|
||||
|
||||
|
||||
def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs: list[str], append: bool = False) -> int:
|
||||
ensure_dirs()
|
||||
rp = PROFILES[profile]
|
||||
@@ -467,7 +836,7 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
profile_root = run_root / "profiles" / profile
|
||||
(run_root / "logs").mkdir(parents=True, exist_ok=True)
|
||||
(run_root / "reports").mkdir(parents=True, exist_ok=True)
|
||||
(run_root / "versions.json").write_text((ctl_root() / "versions.json").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
write_experiment_versions(run_root)
|
||||
now = utc_now()
|
||||
if append:
|
||||
run_info = normalize_experiment_metadata(read_json(run_root / "run-info.json", {}))
|
||||
@@ -504,6 +873,9 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
"experimentRoot": str(run_root),
|
||||
"stateRoot": str(profile_root / "state"),
|
||||
}
|
||||
if rp == "rpki-prover":
|
||||
run_info["validationAlgorithm"] = cache_profile_name(profile)
|
||||
run_info["rpkiProverImage"] = load_config().get("rpkiProverImage", {})
|
||||
write_json(run_root / "run-info.json", run_info)
|
||||
status = {
|
||||
"state": "running",
|
||||
@@ -535,7 +907,7 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
time.sleep(sleep_seconds)
|
||||
last_start = time.time()
|
||||
snapshot = run_seq == 1
|
||||
mode = "snapshot" if snapshot else "delta"
|
||||
mode = "snapshot" if snapshot else ("warm" if rp == "rpki-prover" else "delta")
|
||||
run_dir = profile_root / "runs" / f"run_{run_seq:04d}"
|
||||
if run_dir.exists() and any(run_dir.iterdir()):
|
||||
raise RuntimeError(f"refusing to overwrite existing run directory: {run_dir}")
|
||||
@@ -550,13 +922,34 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
argv, env = build_rpki_client_args(run_dir, state, rirs)
|
||||
elif rp == "fort":
|
||||
argv, env = build_fort_args(run_dir, state, rirs)
|
||||
elif rp == "rpki-prover":
|
||||
prepare_rpki_prover_state(state, rirs)
|
||||
container_suffix = hashlib.sha1(f"{run_root}:{run_seq}".encode("utf-8")).hexdigest()[:12]
|
||||
container_name = f"rpctl-prover-{container_suffix}"
|
||||
argv = build_rpki_prover_command(profile, run_dir, state, container_name)
|
||||
else:
|
||||
raise RuntimeError(rp)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(run_dir / "command.txt").write_text(command_string(argv) + "\n", encoding="utf-8")
|
||||
code = timed_run(run_dir, argv, env=env)
|
||||
normalize_one(run_root, profile, run_seq)
|
||||
meta = parse_latest_meta(run_root, profile, run_seq)
|
||||
if rp == "rpki-prover":
|
||||
code, max_rss_kb, max_process_rss_kb, wall_ms, _ = timed_rpki_prover_run(
|
||||
profile, run_dir, state, container_name
|
||||
)
|
||||
meta = write_rpki_prover_run_metadata(
|
||||
run_root,
|
||||
profile,
|
||||
run_seq,
|
||||
mode,
|
||||
wall_ms,
|
||||
max_rss_kb,
|
||||
max_process_rss_kb,
|
||||
code,
|
||||
)
|
||||
write_rpki_prover_group_report(run_root, profile)
|
||||
else:
|
||||
code = timed_run(run_dir, argv, env=env)
|
||||
normalize_one(run_root, profile, run_seq)
|
||||
meta = parse_latest_meta(run_root, profile, run_seq)
|
||||
update_current(
|
||||
state="running",
|
||||
lastRunSeq=run_seq,
|
||||
@@ -571,7 +964,10 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
if code != 0:
|
||||
exit_code = code
|
||||
break
|
||||
write_group_report(run_root, interval_secs, completed_seq)
|
||||
if rp == "rpki-prover":
|
||||
write_rpki_prover_group_report(run_root, profile)
|
||||
else:
|
||||
write_group_report(run_root, interval_secs, completed_seq)
|
||||
final_state = "success" if exit_code == 0 else "failed"
|
||||
update_current(state=final_state, finishedAtUtc=utc_now(), exitCode=exit_code)
|
||||
run_info = normalize_experiment_metadata(read_json(run_root / "run-info.json", {}))
|
||||
@@ -645,6 +1041,10 @@ def cmd_paths(args: argparse.Namespace) -> None:
|
||||
print(f"runs={runs_dir()}")
|
||||
print(f"logs={logs_dir()}")
|
||||
print(f"bundle={cfg.get('bundle','')}")
|
||||
image = cfg.get("rpkiProverImage", {})
|
||||
print(f"rpki_prover_image={image.get('tag', '')}")
|
||||
print(f"rpki_prover_image_id={image.get('imageId', '')}")
|
||||
print(f"rpki_prover_image_archive={image.get('archive', '')}")
|
||||
cur = current_live_status()
|
||||
if cur.get("experimentRoot"):
|
||||
print(f"current_experiment_root={cur['experimentRoot']}")
|
||||
@@ -668,6 +1068,8 @@ def cmd_start(args: argparse.Namespace) -> None:
|
||||
raise SystemExit("--runs must be >= 1")
|
||||
if not (ctl_root() / "versions.json").exists():
|
||||
raise SystemExit("rpctl is not initialized; run ./rpctl init --bundle <bundle-root> first")
|
||||
if PROFILES[profile] == "rpki-prover":
|
||||
require_rpki_prover_image()
|
||||
exp_id = args.exp_id or f"{utc_id()}_{profile}_{args.runs}runs_{interval_secs}s"
|
||||
runner_args = [
|
||||
sys.executable,
|
||||
@@ -717,6 +1119,8 @@ def cmd_continue(args: argparse.Namespace) -> None:
|
||||
if entry["state"] != "success":
|
||||
raise SystemExit(f"can only continue a successful experiment: exp_id={args.exp_id} state={entry['state']}")
|
||||
profile = entry["profile"]
|
||||
if PROFILES[profile] == "rpki-prover":
|
||||
require_rpki_prover_image()
|
||||
rirs = parse_entry_rirs(entry)
|
||||
interval_secs = parse_interval(args.interval)
|
||||
runner_args = [
|
||||
@@ -867,6 +1271,13 @@ def parse_command_summary(command: str) -> str:
|
||||
keys.append(f"--rsync-scope={tokens[idx + 1]}")
|
||||
elif token.startswith("--rsync-scope="):
|
||||
keys.append(token)
|
||||
elif token == "--parallel-max-repo-sync-workers-global" and idx + 1 < len(tokens):
|
||||
keys.append(f"sync-workers={tokens[idx + 1]}")
|
||||
elif token.startswith("--parallel-max-repo-sync-workers-global="):
|
||||
keys.append(f"sync-workers={token.rsplit('=', 1)[1]}")
|
||||
if RPKI_PROVER_IMAGE_TAG in token_set:
|
||||
algorithm = "full-every-iteration" if "--no-incremental-validation" in token_set else "incremental"
|
||||
keys.append(f"algorithm={algorithm}")
|
||||
return " ".join(keys)
|
||||
|
||||
|
||||
@@ -945,6 +1356,10 @@ def cache_profile_name(profile: str) -> str:
|
||||
return "pp+roa+crt"
|
||||
if profile == "ours-no-cache":
|
||||
return "off"
|
||||
if profile == "rpki-prover-incremental":
|
||||
return "incremental"
|
||||
if profile == "rpki-prover-full":
|
||||
return "full-every-iteration"
|
||||
return "n/a"
|
||||
|
||||
|
||||
@@ -971,6 +1386,8 @@ def profile_label(profile: str) -> str:
|
||||
"routinator-latest-release": "routinator",
|
||||
"rpki-client-latest-release": "rpki-client",
|
||||
"fort-latest-release": "fort",
|
||||
"rpki-prover-incremental": "prover-inc",
|
||||
"rpki-prover-full": "prover-full",
|
||||
}
|
||||
return labels.get(profile, profile)
|
||||
|
||||
@@ -1043,6 +1460,9 @@ def infer_output_counts(profile: str, run_dir: Path) -> tuple[str, str]:
|
||||
return vrps, vaps
|
||||
if rp == "fort":
|
||||
return count_csv_data_rows(run_dir / "vrps.csv"), "0" if (run_dir / "vrps.csv").exists() else ""
|
||||
if rp == "rpki-prover":
|
||||
raw, _ = collect_rpki_prover_vrps(run_dir / "vrps.csv")
|
||||
return str(raw) if (run_dir / "vrps.csv").exists() else "", "n/a" if (run_dir / "vrps.csv").exists() else ""
|
||||
return "", ""
|
||||
|
||||
|
||||
@@ -1355,6 +1775,7 @@ def fallback_run_details(entry: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"wall_ms": timing.get("wallMs", ""),
|
||||
"max_rss_kb": timing.get("maxRssKb", ""),
|
||||
"vrps": first_non_empty(meta.get("counts", {}).get("vrps") if meta else "", inferred_vrps),
|
||||
"vrps_unique": first_non_empty(meta.get("counts", {}).get("vrpsUnique") if meta else ""),
|
||||
"vaps": first_non_empty(meta.get("counts", {}).get("vaps") if meta else "", inferred_vaps),
|
||||
"remote_run_dir": str(run_dir),
|
||||
})
|
||||
@@ -1391,7 +1812,7 @@ def cmd_view(args: argparse.Namespace) -> None:
|
||||
print("command=" + entry["command"])
|
||||
if rows:
|
||||
print("\nper-run:")
|
||||
headers = ["run", "mode", "exit", "start", "end", "wall_ms", "rss_kb", "vrps", "vaps", "path"]
|
||||
headers = ["run", "mode", "exit", "start", "end", "wall_ms", "rss_kb", "vrps", "vrps_unique", "vaps", "path"]
|
||||
table_rows = []
|
||||
for row in rows:
|
||||
table_rows.append([
|
||||
@@ -1403,6 +1824,7 @@ def cmd_view(args: argparse.Namespace) -> None:
|
||||
first_non_empty(row.get("wall_ms"), row.get("wallMs")),
|
||||
first_non_empty(row.get("max_rss_kb"), row.get("maxRssKb")),
|
||||
first_non_empty(row.get("vrps"), row.get("VRPs")),
|
||||
first_non_empty(row.get("vrps_unique"), row.get("vrpsUnique")),
|
||||
first_non_empty(row.get("vaps"), row.get("VAPs")),
|
||||
first_non_empty(row.get("remote_run_dir"), row.get("remoteRunDir")),
|
||||
])
|
||||
@@ -1412,7 +1834,7 @@ def cmd_view(args: argparse.Namespace) -> None:
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
formatter = argparse.RawDescriptionHelpFormatter
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Control one local four-RP performance run on this server.",
|
||||
description="Control one local five-RP performance run on this server.",
|
||||
formatter_class=formatter,
|
||||
epilog="""
|
||||
RP/profile naming:
|
||||
@@ -1435,6 +1857,14 @@ RP/profile naming:
|
||||
fort
|
||||
Alias of fort-latest-release. Uses archived FORT validator release binary.
|
||||
|
||||
rpki-prover / rpki-prover-incremental
|
||||
Uses the archived rpki-prover Docker image with its default incremental
|
||||
validation algorithm and persistent state.
|
||||
|
||||
rpki-prover-full / rpki-prover-no-incremental
|
||||
Uses the same image with --no-incremental-validation. Its state is kept
|
||||
separate from the incremental profile.
|
||||
|
||||
Cache difference for ours RP:
|
||||
--rp ours adds:
|
||||
--enable-publication-point-validation-cache
|
||||
@@ -1443,11 +1873,17 @@ Cache difference for ours RP:
|
||||
|
||||
--rp ours-no-cache does not add those flags.
|
||||
|
||||
Ours RP sync parallelism:
|
||||
Both ours RP profiles explicitly use 8 global repository sync workers.
|
||||
This setting does not change Routinator, rpki-client, or FORT parameters.
|
||||
|
||||
Typical usage:
|
||||
./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle
|
||||
./rpctl init --bundle /data/rpki_4rp_ctl_runtime_bundle \\
|
||||
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz
|
||||
./rpctl list
|
||||
./rpctl start --rp ours --interval 10m --runs 6 --rirs all5
|
||||
./rpctl start --rp ours-no-cache --interval 10m --runs 6 --rirs all5
|
||||
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
|
||||
./rpctl status
|
||||
./rpctl show
|
||||
./rpctl show --failed
|
||||
@@ -1458,7 +1894,8 @@ Typical usage:
|
||||
|
||||
Data model:
|
||||
- Experiment data stays under ./runs/<profile>/<exp-id>/.
|
||||
- Each experiment starts with a snapshot; later child runs are deltas and reuse the profile state.
|
||||
- Each experiment starts with a snapshot; later child runs reuse the profile state.
|
||||
- rpki-prover labels reused-state runs as warm because its native storage decides snapshot/delta behavior.
|
||||
- By default only one RP/profile can run at a time.
|
||||
""",
|
||||
)
|
||||
@@ -1481,14 +1918,24 @@ The bundle must contain:
|
||||
scripts/write_group_report.py
|
||||
versions.json
|
||||
|
||||
Optional rpki-prover image archive:
|
||||
Pass --rpki-prover-image to copy and load the saved linux/amd64 Docker image.
|
||||
Init verifies image architecture, tag and rpki-prover version without pulling online.
|
||||
|
||||
Recommended source on 47.77.237.41:
|
||||
/root/rpki_4rp_ctl_runtime_bundle
|
||||
|
||||
Example:
|
||||
./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle
|
||||
./rpctl init --bundle /data/rpki_4rp_ctl_runtime_bundle \\
|
||||
--rpki-prover-image /data/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz
|
||||
""",
|
||||
)
|
||||
p.add_argument("--bundle", required=True, help="Path to the archived #100 runtime bundle root.")
|
||||
p.add_argument(
|
||||
"--rpki-prover-image",
|
||||
default="",
|
||||
help="Optional saved rpki-prover linux/amd64 Docker image archive. Existing four-RP profiles do not require it.",
|
||||
)
|
||||
p.set_defaults(func=cmd_init)
|
||||
|
||||
p = sub.add_parser(
|
||||
@@ -1502,6 +1949,10 @@ Use this command when unsure which value to pass to --rp.
|
||||
Important ours RP profiles:
|
||||
ours-pp-object-cache cache-enabled profile
|
||||
ours-no-cache no-cache baseline profile
|
||||
|
||||
rpki-prover profiles:
|
||||
rpki-prover-incremental default incremental validation
|
||||
rpki-prover-full --no-incremental-validation baseline
|
||||
""",
|
||||
)
|
||||
p.set_defaults(func=cmd_list)
|
||||
@@ -1525,11 +1976,17 @@ RP/profile examples:
|
||||
./rpctl start --rp routinator --interval 15m --runs 2 --rirs apnic
|
||||
./rpctl start --rp rpki-client --interval 0 --runs 1 --rirs apnic,arin
|
||||
./rpctl start --rp fort --interval 0 --runs 1 --rirs all5
|
||||
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
|
||||
./rpctl start --rp rpki-prover-full --interval 0 --runs 1 --rirs all5
|
||||
|
||||
Cache mapping:
|
||||
--rp ours enables PP cache + ROA cache + child certificate cache.
|
||||
--rp ours-no-cache disables those cache flags.
|
||||
|
||||
Ours RP sync workers:
|
||||
Both ours profiles pass --parallel-max-repo-sync-workers-global 8.
|
||||
This does not alter the parameters of Routinator, rpki-client, or FORT.
|
||||
|
||||
Safety:
|
||||
rpctl refuses to start a second RP while another rpctl runner is alive.
|
||||
""",
|
||||
@@ -1537,7 +1994,7 @@ Safety:
|
||||
p.add_argument(
|
||||
"--rp",
|
||||
required=True,
|
||||
help="RP/profile or alias. Common values: ours, ours-no-cache, routinator, rpki-client, fort.",
|
||||
help="RP/profile or alias. Common values: ours, ours-no-cache, routinator, rpki-client, fort, rpki-prover, rpki-prover-full.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--interval",
|
||||
@@ -1578,6 +2035,7 @@ Run semantics:
|
||||
Examples:
|
||||
./rpctl continue --exp-id 20260709T081122Z_ours-pp-object-cache_2runs_600s --runs 3 --interval 10m
|
||||
./rpctl continue --rp routinator --exp-id m18_237_routinator_all5_snapshot_delta_20260710T002726Z --runs 1 --interval 0
|
||||
./rpctl continue --rp rpki-prover --exp-id prover_all5 --runs 1 --interval 0
|
||||
""",
|
||||
)
|
||||
p.add_argument("--exp-id", required=True, help="Existing successful experiment id to append to.")
|
||||
@@ -1692,7 +2150,7 @@ Examples:
|
||||
./rpctl show --json
|
||||
""",
|
||||
)
|
||||
p.add_argument("--rp", default="", help="Optional RP/profile filter, e.g. ours, ours-no-cache, routinator, rpki-client, fort.")
|
||||
p.add_argument("--rp", default="", help="Optional RP/profile filter, e.g. ours, ours-no-cache, routinator, rpki-client, fort, rpki-prover.")
|
||||
p.add_argument("--failed", action="store_true", help="Only show failed, partial, incomplete or exited experiments.")
|
||||
p.add_argument("--limit", type=int, default=50, help="Maximum rows to show. 0 means no limit. Default: 50.")
|
||||
p.add_argument("--paths", action="store_true", help="Also show full experiment root paths. Hidden by default to keep the table readable.")
|
||||
|
||||
113
scripts/rpctl/test_rpctl.py
Normal file
113
scripts/rpctl/test_rpctl.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_rpctl():
|
||||
path = Path(__file__).with_name("rpctl")
|
||||
loader = importlib.machinery.SourceFileLoader("rpctl_module", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class RpkiProverProfileTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.rpctl = load_rpctl()
|
||||
|
||||
def test_profile_aliases_and_algorithms(self) -> None:
|
||||
self.assertEqual(self.rpctl.normalize_profile("rpki-prover"), "rpki-prover-incremental")
|
||||
self.assertEqual(self.rpctl.normalize_profile("rpki-prover-no-incremental"), "rpki-prover-full")
|
||||
self.assertNotIn("--no-incremental-validation", self.rpctl.rpki_prover_arguments("rpki-prover-incremental"))
|
||||
self.assertIn("--no-incremental-validation", self.rpctl.rpki_prover_arguments("rpki-prover-full"))
|
||||
incremental = self.rpctl.parse_command_summary(
|
||||
f"docker run {self.rpctl.RPKI_PROVER_IMAGE_TAG} --once"
|
||||
)
|
||||
full = self.rpctl.parse_command_summary(
|
||||
f"docker run {self.rpctl.RPKI_PROVER_IMAGE_TAG} --once --no-incremental-validation"
|
||||
)
|
||||
self.assertEqual(incremental, "algorithm=incremental")
|
||||
self.assertEqual(full, "algorithm=full-every-iteration")
|
||||
|
||||
def test_vrp_count_preserves_raw_and_unique(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
csv_path = Path(temp) / "vrps.csv"
|
||||
csv_path.write_text(
|
||||
"ASN,IP Prefix,Max Length,Trust Anchor\n"
|
||||
"AS64496,192.0.2.0/24,24,TA-A\n"
|
||||
"AS64496,192.0.2.0/24,24,TA-B\n"
|
||||
"AS64497,2001:db8::/32,48,TA-A\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
raw, unique = self.rpctl.collect_rpki_prover_vrps(csv_path)
|
||||
self.assertEqual(raw, 3)
|
||||
self.assertEqual(len(unique), 2)
|
||||
|
||||
def test_run_metadata_marks_vaps_not_applicable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
old_ctl_root = self.rpctl.ctl_root
|
||||
self.rpctl.ctl_root = lambda: root
|
||||
try:
|
||||
self.rpctl.save_config({"rpkiProverImage": {"tag": self.rpctl.RPKI_PROVER_IMAGE_TAG}})
|
||||
run_root = root / "runs" / "rpki-prover-incremental" / "test"
|
||||
run_dir = run_root / "profiles" / "rpki-prover-incremental" / "runs" / "run_0001"
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "start-utc.txt").write_text("2026-07-17T00:00:00Z\n", encoding="utf-8")
|
||||
(run_dir / "end-utc.txt").write_text("2026-07-17T00:01:00Z\n", encoding="utf-8")
|
||||
(run_dir / "vrps.csv").write_text(
|
||||
"ASN,IP Prefix,Max Length,Trust Anchor\nAS64496,192.0.2.0/24,24,TA-A\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
meta = self.rpctl.write_rpki_prover_run_metadata(
|
||||
run_root, "rpki-prover-incremental", 1, "snapshot", 60_000, 1234, 1200, 0
|
||||
)
|
||||
self.assertEqual(meta["counts"]["vrpsRaw"], 1)
|
||||
self.assertEqual(meta["counts"]["vrpsUnique"], 1)
|
||||
self.assertEqual(meta["counts"]["vaps"], "n/a")
|
||||
self.assertFalse(meta["vapsAvailable"])
|
||||
self.rpctl.write_rpki_prover_group_report(run_root, "rpki-prover-incremental")
|
||||
report = (run_root / "reports" / "per_run_metrics.csv").read_text(encoding="utf-8")
|
||||
self.assertIn("vrps_unique", report)
|
||||
self.assertIn("n/a", report)
|
||||
finally:
|
||||
self.rpctl.ctl_root = old_ctl_root
|
||||
|
||||
def test_missing_image_configuration_fails_before_docker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
old_ctl_root = self.rpctl.ctl_root
|
||||
self.rpctl.ctl_root = lambda: root
|
||||
try:
|
||||
self.rpctl.save_config({})
|
||||
with self.assertRaisesRegex(SystemExit, "not initialized"):
|
||||
self.rpctl.require_rpki_prover_image()
|
||||
finally:
|
||||
self.rpctl.ctl_root = old_ctl_root
|
||||
|
||||
def test_versions_include_prover_profiles(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
old_ctl_root = self.rpctl.ctl_root
|
||||
self.rpctl.ctl_root = lambda: root
|
||||
try:
|
||||
(root / "versions.json").write_text(json.dumps({"profiles": ["fort-latest-release"]}), encoding="utf-8")
|
||||
self.rpctl.save_config({"rpkiProverImage": {"tag": self.rpctl.RPKI_PROVER_IMAGE_TAG}})
|
||||
run_root = root / "experiment"
|
||||
self.rpctl.write_experiment_versions(run_root)
|
||||
versions = json.loads((run_root / "versions.json").read_text(encoding="utf-8"))
|
||||
self.assertIn("rpki-prover-incremental", versions["profiles"])
|
||||
self.assertIn("rpki-prover-full", versions["profiles"])
|
||||
finally:
|
||||
self.rpctl.ctl_root = old_ctl_root
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,7 +14,7 @@ Usage:
|
||||
scripts/soak/build_portable_soak_package.sh [--out-dir <path>] [--profile <profile>]
|
||||
|
||||
Requires release binaries to already exist. Build them first, for example:
|
||||
cargo build --release --bin rpki --bin rpki_daemon --bin db_stats --bin rpki_artifact_metrics --bin rpki_query_service --bin rpki_query_indexer
|
||||
cargo build --release --bin rpki --bin rpki_daemon --bin db_stats --bin rpki_artifact_metrics --bin rpki_query_service --bin rpki_query_indexer --bin verification_ccr_compare
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ else
|
||||
TARGET_BIN_DIR="$REPO_ROOT/target/$PROFILE"
|
||||
fi
|
||||
|
||||
REQUIRED_BINS=(rpki rpki_daemon db_stats rpki_artifact_metrics rpki_query_service rpki_query_indexer)
|
||||
REQUIRED_BINS=(rpki rpki_daemon db_stats rpki_artifact_metrics rpki_query_service rpki_query_indexer verification_ccr_compare)
|
||||
OPTIONAL_BINS=(
|
||||
ccr_dump
|
||||
ccr_state_compare
|
||||
@@ -82,16 +82,21 @@ STAGE_DIR="$BUILD_ROOT/$PACKAGE_DIR_NAME"
|
||||
ARCHIVE_PATH="$OUT_DIR/$PACKAGE_NAME.tar.gz"
|
||||
|
||||
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH"
|
||||
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" "$STAGE_DIR/scripts/soak" \
|
||||
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/fixtures" "$STAGE_DIR/scripts" "$STAGE_DIR/scripts/soak" "$STAGE_DIR/scripts/verification" \
|
||||
"$STAGE_DIR/runs" "$STAGE_DIR/state" "$STAGE_DIR/logs" "$STAGE_DIR/tmp"
|
||||
|
||||
install -m 0755 "$SCRIPT_DIR/run_soak.sh" "$STAGE_DIR/run_soak.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/run_24h_soak_with_metrics.sh" "$STAGE_DIR/run_24h_soak_with_metrics.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/run_cache_ablation_experiment.sh" "$STAGE_DIR/scripts/soak/run_cache_ablation_experiment.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/run_feature149_cohort.sh" "$STAGE_DIR/scripts/soak/run_feature149_cohort.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/summarize_feature149_cohort.py" "$STAGE_DIR/scripts/soak/summarize_feature149_cohort.py"
|
||||
install -m 0755 "$SCRIPT_DIR/render_feature149_report.py" "$STAGE_DIR/scripts/soak/render_feature149_report.py"
|
||||
install -m 0755 "$SCRIPT_DIR/fixed_phase_loop.sh" "$STAGE_DIR/scripts/soak/fixed_phase_loop.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/hourly_soak_report.py" "$STAGE_DIR/scripts/soak/hourly_soak_report.py"
|
||||
install -m 0755 "$SCRIPT_DIR/publish_remote231.sh" "$STAGE_DIR/scripts/soak/publish_remote231.sh"
|
||||
install -m 0755 "$SCRIPT_DIR/publish_remote231_full.sh" "$STAGE_DIR/scripts/soak/publish_remote231_full.sh"
|
||||
install -m 0755 "$REPO_ROOT/scripts/verification/run_verification_only.sh" "$STAGE_DIR/scripts/verification/run_verification_only.sh"
|
||||
install -m 0644 "$REPO_ROOT/scripts/verification/README.md" "$STAGE_DIR/scripts/verification/README.md"
|
||||
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/.env"
|
||||
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/portable-soak.env.example"
|
||||
|
||||
@@ -116,6 +121,7 @@ done
|
||||
|
||||
cp -a "$REPO_ROOT/tests/fixtures/tal" "$STAGE_DIR/fixtures/"
|
||||
cp -a "$REPO_ROOT/tests/fixtures/ta" "$STAGE_DIR/fixtures/"
|
||||
cp -a "$REPO_ROOT/tests/fixtures/ta_constraints" "$STAGE_DIR/fixtures/"
|
||||
cp -a "$REPO_ROOT/scripts/periodic" "$STAGE_DIR/scripts/"
|
||||
cp -a "$REPO_ROOT/scripts/cir" "$STAGE_DIR/scripts/"
|
||||
cp -a "$REPO_ROOT/scripts/inter_rp" "$STAGE_DIR/scripts/"
|
||||
|
||||
@@ -66,6 +66,14 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
|
||||
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
|
||||
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 表示开启。
|
||||
RPKI_PROGRESS_LOG=1
|
||||
|
||||
@@ -92,8 +100,21 @@ ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=0
|
||||
# 传给 rpki 子进程的额外参数。多个参数用空格分隔。
|
||||
# 示例:RPKI_EXTRA_ARGS="--enable-roa-validation-cache"
|
||||
# 实验性 transport 预热:RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-roa-validation-cache"
|
||||
# TA constraints 的 RIPE 单 TA soak 示例(policy 并非默认启用):
|
||||
# RIRS=ripe
|
||||
# RPKI_EXTRA_ARGS="--ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/ripe-ncc-afrinic-deny.constraints"
|
||||
# all5 性能 A/B 的完整 AFRINIC IPv4 deny 示例(仅四个非 AFRINIC TA):
|
||||
# RIRS=afrinic,apnic,arin,lacnic,ripe
|
||||
# RPKI_EXTRA_ARGS="--ta-constraints apnic-rfc7730-https=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints arin=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints lacnic=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints --ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-full-ipv4-deny.constraints"
|
||||
# M7 current-only AFRINIC holdings(delegated status=allocated/assigned;不含 legacy/ERX):
|
||||
# RIRS=afrinic,apnic,arin,lacnic,ripe
|
||||
# RPKI_EXTRA_ARGS="--ta-constraints apnic-rfc7730-https=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints arin=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints lacnic=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints --ta-constraints ripe-ncc=${PACKAGE_ROOT}/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints"
|
||||
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。
|
||||
# 性能 profile 或打点验证时设置为 1;普通 soak 建议保持 0,避免额外开销。
|
||||
RPKI_ANALYZE=0
|
||||
|
||||
390
scripts/soak/render_feature149_report.py
Normal file
390
scripts/soak/render_feature149_report.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a self-contained final report for Feature #149."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
EXPECTED_COHORTS = (
|
||||
"prefetch_pp_object_system",
|
||||
"prefetch_pp_object_data",
|
||||
"pp_object_system",
|
||||
"pp_object_data",
|
||||
)
|
||||
|
||||
|
||||
def number(value: Any, digits: int = 0) -> str:
|
||||
if not isinstance(value, (int, float)):
|
||||
return "n/a"
|
||||
return f"{value:,.{digits}f}"
|
||||
|
||||
|
||||
def load(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{path} is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def latest(record: dict[str, Any], key: str) -> Any:
|
||||
return (record.get("latestCounts") or {}).get(key)
|
||||
|
||||
|
||||
def stats(record: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
value = record.get(key) or {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def delta_median(record: dict[str, Any]) -> float | None:
|
||||
value = stats(record, "deltaWallMs").get("median")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def stable_median(record: dict[str, Any]) -> float | None:
|
||||
value = stats(record, "stableDeltaWallMs").get("median")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def delta_mean(record: dict[str, Any]) -> float | None:
|
||||
value = stats(record, "deltaWallMs").get("mean")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def stable_mean(record: dict[str, Any]) -> float | None:
|
||||
value = stats(record, "stableDeltaWallMs").get("mean")
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def percent_change(baseline: float | None, value: float | None) -> float | None:
|
||||
if baseline in (None, 0) or value is None:
|
||||
return None
|
||||
return (value - baseline) / baseline * 100
|
||||
|
||||
|
||||
def percent_text(baseline: float | None, value: float | None) -> str:
|
||||
change = percent_change(baseline, value)
|
||||
return "n/a" if change is None else f"{change:+.1f}%"
|
||||
|
||||
|
||||
def stat_value(record: dict[str, Any], field: str, key: str) -> float | None:
|
||||
value = stats(record, field).get(key)
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def svg_chart(records: list[dict[str, Any]]) -> str:
|
||||
values = [delta_median(record) or 0 for record in records]
|
||||
maximum = max(values) if values else 1
|
||||
width, height, left, baseline = 860, 250, 62, 212
|
||||
bar_width, gap = 112, 56
|
||||
bars: list[str] = []
|
||||
for index, (record, value) in enumerate(zip(records, values)):
|
||||
x = left + index * (bar_width + gap)
|
||||
bar_height = 0 if maximum == 0 else value / maximum * 164
|
||||
y = baseline - bar_height
|
||||
colour = "#0f766e" if record.get("disk") == "data" else "#2563eb"
|
||||
label = html.escape(str(record.get("cohortId")))
|
||||
bars.append(
|
||||
f'<rect x="{x}" y="{y:.1f}" width="{bar_width}" height="{bar_height:.1f}" rx="7" fill="{colour}"/>'
|
||||
f'<text x="{x + bar_width / 2:.1f}" y="{y - 8:.1f}" text-anchor="middle" class="value">{number(value / 1000, 2)}s</text>'
|
||||
f'<text x="{x + bar_width / 2:.1f}" y="{baseline + 20}" text-anchor="middle" class="label">{label}</text>'
|
||||
)
|
||||
return f'''<svg viewBox="0 0 {width} {height}" role="img" aria-label="Delta median wall clock by cohort">
|
||||
<line x1="{left}" y1="{baseline}" x2="{width - 28}" y2="{baseline}" stroke="#94a3b8"/>
|
||||
<text x="8" y="24" class="axis">delta median wall clock</text>
|
||||
{''.join(bars)}
|
||||
</svg>'''
|
||||
|
||||
|
||||
def timeline_svg(records: list[dict[str, Any]]) -> str:
|
||||
"""Render the fixed serial order and the six run slots in each cohort."""
|
||||
width, row_height, top = 930, 48, 36
|
||||
height = top + row_height * len(records) + 18
|
||||
parts = [
|
||||
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="Serial cohort timeline">',
|
||||
'<text x="8" y="18" class="axis">each row: snapshot (S) then five delta runs (D1–D5); adjacent starts are 600 s apart</text>',
|
||||
]
|
||||
for row, record in enumerate(records):
|
||||
y = top + row * row_height
|
||||
label = html.escape(str(record.get("cohortId")))
|
||||
parts.append(f'<text x="8" y="{y + 5}" class="label">{label}</text>')
|
||||
parts.append(f'<line x1="310" y1="{y}" x2="890" y2="{y}" stroke="#cbd5e1" stroke-width="2"/>')
|
||||
for index in range(6):
|
||||
x = 310 + index * 116
|
||||
mode = "S" if index == 0 else f"D{index}"
|
||||
fill = "#2563eb" if index == 0 else "#0f766e"
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="11" fill="{fill}"/>')
|
||||
parts.append(f'<text x="{x}" y="{y + 4}" text-anchor="middle" class="circle-label">{mode}</text>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def milestone_svg() -> str:
|
||||
labels = (
|
||||
("M1", "data disk init"),
|
||||
("M2", "package + dry-run"),
|
||||
("M3", "prefetch cohorts"),
|
||||
("M4", "no-prefetch cohorts"),
|
||||
("M5", "evidence + report"),
|
||||
)
|
||||
width, y = 900, 44
|
||||
parts = [f'<svg viewBox="0 0 {width} 90" role="img" aria-label="Milestone timeline"><line x1="72" y1="{y}" x2="828" y2="{y}" stroke="#cbd5e1" stroke-width="3"/>']
|
||||
for index, (milestone, label) in enumerate(labels):
|
||||
x = 72 + index * 189
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="17" fill="#0f766e"/><text x="{x}" y="{y + 5}" text-anchor="middle" class="circle-label">{milestone}</text><text x="{x}" y="78" text-anchor="middle" class="label">{label}</text>')
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def paired_rows(records: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
|
||||
return [
|
||||
("prefetch + PP + object", records["prefetch_pp_object_system"], records["prefetch_pp_object_data"]),
|
||||
("PP + object", records["pp_object_system"], records["pp_object_data"]),
|
||||
]
|
||||
|
||||
|
||||
def prefetch_rows(records: dict[str, dict[str, Any]]) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
|
||||
return [
|
||||
("system disk", records["pp_object_system"], records["prefetch_pp_object_system"]),
|
||||
("data disk", records["pp_object_data"], records["prefetch_pp_object_data"]),
|
||||
]
|
||||
|
||||
|
||||
def run_rows(records: list[dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]:
|
||||
return [(str(record.get("cohortId")), run) for record in records for run in (record.get("runs") or [])]
|
||||
|
||||
|
||||
def all_delta_stats(records: list[dict[str, Any]]) -> dict[str, float]:
|
||||
values = [
|
||||
float(run["wallMs"])
|
||||
for _, run in run_rows(records)
|
||||
if run.get("syncMode") == "delta" and isinstance(run.get("wallMs"), (int, float))
|
||||
]
|
||||
if not values:
|
||||
return {}
|
||||
return {
|
||||
"count": float(len(values)),
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"mean": statistics.mean(values),
|
||||
"median": statistics.median(values),
|
||||
"p90": statistics.quantiles(values, n=10, method="inclusive")[8] if len(values) > 1 else values[0],
|
||||
}
|
||||
|
||||
|
||||
def conclusion_lines(indexed: dict[str, dict[str, Any]]) -> list[str]:
|
||||
prefetch_system = indexed["prefetch_pp_object_system"]
|
||||
prefetch_data = indexed["prefetch_pp_object_data"]
|
||||
no_prefetch_system = indexed["pp_object_system"]
|
||||
no_prefetch_data = indexed["pp_object_data"]
|
||||
return [
|
||||
f"两种磁盘的差异没有在两个 profile 上呈现同一方向:prefetch profile 的稳定 delta median 为 system {number(stable_median(prefetch_system) / 1000 if stable_median(prefetch_system) else None, 2)}s、data {number(stable_median(prefetch_data) / 1000 if stable_median(prefetch_data) else None, 2)}s(data vs system {percent_text(stable_median(prefetch_system), stable_median(prefetch_data))});无 prefetch profile 为 system {number(stable_median(no_prefetch_system) / 1000 if stable_median(no_prefetch_system) else None, 2)}s、data {number(stable_median(no_prefetch_data) / 1000 if stable_median(no_prefetch_data) else None, 2)}s({percent_text(stable_median(no_prefetch_system), stable_median(no_prefetch_data))})。",
|
||||
f"在同一磁盘内,开启 transport prefetch 相对关闭 prefetch 的稳定 delta median 下降:system {percent_text(stable_median(no_prefetch_system), stable_median(prefetch_system))},data {percent_text(stable_median(no_prefetch_data), stable_median(prefetch_data))};这是本实验最稳定的方向性信号。",
|
||||
f"snapshot 不与 delta 同向:system 开启 prefetch 后为 {percent_text((no_prefetch_system.get('snapshot') or {}).get('wallMs'), (prefetch_system.get('snapshot') or {}).get('wallMs'))},data 为 {percent_text((no_prefetch_data.get('snapshot') or {}).get('wallMs'), (prefetch_data.get('snapshot') or {}).get('wallMs'))};因此不能把单次 snapshot wall 解释为纯磁盘因果。",
|
||||
"VRP/VAP/PP 产物量级和 warnings 随 live 发布窗口小幅变化,全部 run 成功且没有 recovery snapshot;网络同步长尾与 live 数据演进是主要限制。",
|
||||
]
|
||||
|
||||
|
||||
def write_markdown(records: list[dict[str, Any]], source_commit: str, package_sha256: str, remote_host: str, input_paths: list[Path], path: Path) -> None:
|
||||
indexed = {str(record.get("cohortId")): record for record in records}
|
||||
all_stats = all_delta_stats(records)
|
||||
lines = [
|
||||
"# Feature #149 远端系统盘/数据盘性能实验报告",
|
||||
"",
|
||||
f"- 远端:`{remote_host}`;源码:`{source_commit}`;portable archive SHA-256:`{package_sha256}`",
|
||||
"- 结论口径:snapshot 单样本;全部 delta 为 delta1–delta5;稳定 delta 为 delta2–delta5。",
|
||||
"",
|
||||
"## 结论摘要",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"- {line}" for line in conclusion_lines(indexed))
|
||||
lines += [
|
||||
"",
|
||||
"## Cohort 摘要",
|
||||
"",
|
||||
"| cohort | disk | profile | total elapsed | snapshot wall | all delta min/mean/median/p90/max | stable median | max RSS | latest VRP/VAP/PP/warnings |",
|
||||
"| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
for record in records:
|
||||
delta = stats(record, "deltaWallMs")
|
||||
lines.append(
|
||||
"| {id} | {disk} | {profile} | {elapsed}s | {snapshot}ms | {min}/{mean}/{median}/{p90}/{max}ms | {stable}ms | {rss} KiB | {vrp}/{vap}/{pp}/{warnings} |".format(
|
||||
id=record.get("cohortId"), disk=record.get("disk"), profile=record.get("profile"), elapsed=record.get("elapsedSeconds"),
|
||||
snapshot=number((record.get("snapshot") or {}).get("wallMs"), 1), min=number(delta.get("min"), 1), mean=number(delta.get("mean"), 1),
|
||||
median=number(delta.get("median"), 1), p90=number(delta.get("p90"), 1), max=number(delta.get("max"), 1), stable=number(stable_median(record), 1),
|
||||
rss=number(record.get("maxRssKb")), vrp=latest(record, "vrps"), vap=latest(record, "vaps"), pp=latest(record, "publicationPoints"), warnings=latest(record, "warnings"),
|
||||
)
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
"## 全部 delta 汇总",
|
||||
"",
|
||||
f"20 个 delta 样本:min/mean/median/p90/max = {number(all_stats.get('min'), 1)}/{number(all_stats.get('mean'), 1)}/{number(all_stats.get('median'), 1)}/{number(all_stats.get('p90'), 1)}/{number(all_stats.get('max'), 1)} ms。",
|
||||
"",
|
||||
"## 磁盘配对与 prefetch 对比",
|
||||
"",
|
||||
"| profile | system all/stable median | data all/stable median | data vs system(all / stable) |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for label, system, data in paired_rows(indexed):
|
||||
lines.append(f"| {label} | {number(delta_median(system), 1)} / {number(stable_median(system), 1)} ms | {number(delta_median(data), 1)} / {number(stable_median(data), 1)} ms | {percent_text(delta_median(system), delta_median(data))} / {percent_text(stable_median(system), stable_median(data))} |")
|
||||
lines += [
|
||||
"",
|
||||
"| profile | system all/stable mean | data all/stable mean | data vs system(all / stable) |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for label, system, data in paired_rows(indexed):
|
||||
lines.append(f"| {label} | {number(delta_mean(system), 1)} / {number(stable_mean(system), 1)} ms | {number(delta_mean(data), 1)} / {number(stable_mean(data), 1)} ms | {percent_text(delta_mean(system), delta_mean(data))} / {percent_text(stable_mean(system), stable_mean(data))} |")
|
||||
lines += [
|
||||
"",
|
||||
"| disk | no-prefetch stable median | prefetch stable median | prefetch vs no-prefetch |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for label, no_prefetch, prefetch in prefetch_rows(indexed):
|
||||
lines.append(f"| {label} | {number(stable_median(no_prefetch), 1)} ms | {number(stable_median(prefetch), 1)} ms | {percent_text(stable_median(no_prefetch), stable_median(prefetch))} |")
|
||||
lines += [
|
||||
"",
|
||||
"| disk | no-prefetch stable mean | prefetch stable mean | prefetch vs no-prefetch |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
for label, no_prefetch, prefetch in prefetch_rows(indexed):
|
||||
lines.append(f"| {label} | {number(stable_mean(no_prefetch), 1)} ms | {number(stable_mean(prefetch), 1)} ms | {percent_text(stable_mean(no_prefetch), stable_mean(prefetch))} |")
|
||||
lines += ["", "## 逐 run 证据", "", "| cohort | run | mode | actual start UTC | wall ms | max RSS KiB | schedule lag ms | VRP/VAP/PP/warnings | artifacts |", "| --- | --- | --- | --- | ---: | ---: | ---: | --- | --- |"]
|
||||
for cohort, run in run_rows(records):
|
||||
lines.append(f"| {cohort} | {run.get('runId')} | {run.get('syncMode')} | {run.get('actualStartRfc3339Utc')} | {number(run.get('wallMs'), 1)} | {number(run.get('maxRssKb'))} | {number(run.get('scheduleLagMs'), 1)} | {run.get('vrps')}/{run.get('vaps')}/{run.get('publicationPoints')}/{run.get('warnings')} | {'complete' if run.get('artifactsComplete') and not run.get('missingArtifacts') else 'INCOMPLETE'} |")
|
||||
lines += [
|
||||
"",
|
||||
"## 验收、异常与证据",
|
||||
"",
|
||||
"- 4 个 cohort 均为 6/6 success、首轮 snapshot 后连续五轮 delta,实际相邻启动节拍为 600 秒,`scheduleLagMs=0`;没有 recovery snapshot 或缺失核心产物。",
|
||||
"- warnings 的最新值范围为 524–540(各 run 约 518–544),VRP/VAP/PP 在 live 窗口内小幅变化;这些属于可解释的 live/网络窗口变化,不是失败状态。",
|
||||
"- 完整 run root 保留在远端对应系统盘或 `/data`;本地只回传四份 `cohort-summary.json/.md` 轻量证据。",
|
||||
"",
|
||||
"本报告由四份 `cohort-summary.json` 自动生成;由于 live RPKI 数据、网络和运行时间窗口不同,磁盘结果仅作方向性比较。",
|
||||
"",
|
||||
"### 本地证据",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"- `{path}`" for path in input_paths)
|
||||
lines += ["", "### 远端原始证据根", ""]
|
||||
for record in records:
|
||||
package_root = record.get("packageRoot", "n/a")
|
||||
cohort = record.get("cohortId", "unknown")
|
||||
lines.append(f"- `{cohort}` package/run root:`{package_root}`;cohort summary:`{package_root}/experiments/feature149-{cohort}/cohort-summary.json`")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cohort-summary", action="append", required=True, type=Path)
|
||||
parser.add_argument("--out-html", required=True, type=Path)
|
||||
parser.add_argument("--out-markdown", required=True, type=Path)
|
||||
parser.add_argument("--remote-host", required=True)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--package-sha256", default="see M2 package report")
|
||||
args = parser.parse_args()
|
||||
input_paths = list(args.cohort_summary)
|
||||
records = [load(path) for path in input_paths]
|
||||
indexed = {str(record.get("cohortId")): record for record in records}
|
||||
if set(indexed) != set(EXPECTED_COHORTS):
|
||||
raise ValueError(f"expected cohorts {EXPECTED_COHORTS}, got {tuple(indexed)}")
|
||||
ordered = [indexed[name] for name in EXPECTED_COHORTS]
|
||||
for record in ordered:
|
||||
runs = record.get("runs") or []
|
||||
if record.get("successCount") != 6 or record.get("runCount") != 6 or record.get("artifactStatus") != "complete" or len(runs) != 6:
|
||||
raise ValueError(f"incomplete cohort {record.get('cohortId')}")
|
||||
if runs[0].get("syncMode") != "snapshot" or any(run.get("syncMode") != "delta" for run in runs[1:]):
|
||||
raise ValueError(f"unexpected sync modes in {record.get('cohortId')}")
|
||||
|
||||
all_stats = all_delta_stats(ordered)
|
||||
cards = [
|
||||
("Cohorts", "4", "all complete"),
|
||||
("Runs", "24 / 24", "snapshot + 5 delta per cohort"),
|
||||
("All delta median", f"{number(all_stats.get('median', 0) / 1000, 2)}s", "20 live delta samples"),
|
||||
("Source", html.escape(args.source_commit), html.escape(args.remote_host)),
|
||||
]
|
||||
card_html = "".join(f'<article class="card"><div>{title}</div><strong>{value}</strong><small>{detail}</small></article>' for title, value, detail in cards)
|
||||
|
||||
cohort_rows_html = "".join(
|
||||
"<tr><td>{id}</td><td>{disk}</td><td>{profile}</td><td>{elapsed}s</td><td>{snapshot} ms</td><td>{min}/{mean}/{median}/{p90}/{max} ms</td><td>{stable} ms</td><td>{rss} KiB</td><td>{vrp}</td><td>{vap}</td><td>{pp}</td><td>{warnings}</td></tr>".format(
|
||||
id=html.escape(str(record.get("cohortId"))), disk=html.escape(str(record.get("disk"))), profile=html.escape(str(record.get("profile"))), elapsed=number(record.get("elapsedSeconds")),
|
||||
snapshot=number((record.get("snapshot") or {}).get("wallMs"), 1), min=number(stat_value(record, "deltaWallMs", "min"), 1), mean=number(stat_value(record, "deltaWallMs", "mean"), 1),
|
||||
median=number(stat_value(record, "deltaWallMs", "median"), 1), p90=number(stat_value(record, "deltaWallMs", "p90"), 1), max=number(stat_value(record, "deltaWallMs", "max"), 1), stable=number(stable_median(record), 1),
|
||||
rss=number(record.get("maxRssKb")), vrp=number(latest(record, "vrps")), vap=number(latest(record, "vaps")), pp=number(latest(record, "publicationPoints")), warnings=number(latest(record, "warnings")),
|
||||
)
|
||||
for record in ordered
|
||||
)
|
||||
paired_html = "".join(
|
||||
"<tr><td>{label}</td><td>{system_all} / {system_stable}</td><td>{data_all} / {data_stable}</td><td>{all_change} / {stable_change}</td></tr>".format(
|
||||
label=html.escape(label), system_all=number(delta_median(system), 1), system_stable=number(stable_median(system), 1), data_all=number(delta_median(data), 1), data_stable=number(stable_median(data), 1),
|
||||
all_change=percent_text(delta_median(system), delta_median(data)), stable_change=percent_text(stable_median(system), stable_median(data)),
|
||||
)
|
||||
for label, system, data in paired_rows(indexed)
|
||||
)
|
||||
paired_mean_html = "".join(
|
||||
"<tr><td>{label}</td><td>{system_all} / {system_stable}</td><td>{data_all} / {data_stable}</td><td>{all_change} / {stable_change}</td></tr>".format(
|
||||
label=html.escape(label), system_all=number(delta_mean(system), 1), system_stable=number(stable_mean(system), 1), data_all=number(delta_mean(data), 1), data_stable=number(stable_mean(data), 1),
|
||||
all_change=percent_text(delta_mean(system), delta_mean(data)), stable_change=percent_text(stable_mean(system), stable_mean(data)),
|
||||
)
|
||||
for label, system, data in paired_rows(indexed)
|
||||
)
|
||||
prefetch_html = "".join(
|
||||
"<tr><td>{label}</td><td>{no_prefetch}</td><td>{prefetch}</td><td>{change}</td></tr>".format(
|
||||
label=html.escape(label), no_prefetch=number(stable_median(no_prefetch), 1), prefetch=number(stable_median(prefetch), 1), change=percent_text(stable_median(no_prefetch), stable_median(prefetch)),
|
||||
)
|
||||
for label, no_prefetch, prefetch in prefetch_rows(indexed)
|
||||
)
|
||||
prefetch_mean_html = "".join(
|
||||
"<tr><td>{label}</td><td>{no_prefetch}</td><td>{prefetch}</td><td>{change}</td></tr>".format(
|
||||
label=html.escape(label), no_prefetch=number(stable_mean(no_prefetch), 1), prefetch=number(stable_mean(prefetch), 1), change=percent_text(stable_mean(no_prefetch), stable_mean(prefetch)),
|
||||
)
|
||||
for label, no_prefetch, prefetch in prefetch_rows(indexed)
|
||||
)
|
||||
run_rows_html = "".join(
|
||||
"<tr><td>{cohort}</td><td>{run}</td><td>{mode}</td><td>{start}</td><td>{wall}</td><td>{rss}</td><td>{lag}</td><td>{vrp}/{vap}/{pp}/{warnings}</td><td>{artifact}</td></tr>".format(
|
||||
cohort=html.escape(cohort), run=html.escape(str(run.get("runId"))), mode=html.escape(str(run.get("syncMode"))), start=html.escape(str(run.get("actualStartRfc3339Utc"))), wall=number(run.get("wallMs"), 1), rss=number(run.get("maxRssKb")), lag=number(run.get("scheduleLagMs"), 1),
|
||||
vrp=number(run.get("vrps")), vap=number(run.get("vaps")), pp=number(run.get("publicationPoints")), warnings=number(run.get("warnings")), artifact="complete" if run.get("artifactsComplete") and not run.get("missingArtifacts") else "INCOMPLETE",
|
||||
)
|
||||
for cohort, run in run_rows(ordered)
|
||||
)
|
||||
evidence_rows_html = "".join(
|
||||
"<tr><td>{cohort}</td><td><code>{root}</code></td><td><code>{summary}</code></td></tr>".format(
|
||||
cohort=html.escape(str(record.get("cohortId"))),
|
||||
root=html.escape(str(record.get("packageRoot", "n/a"))),
|
||||
summary=html.escape(f"{record.get('packageRoot', 'n/a')}/experiments/feature149-{record.get('cohortId')}/cohort-summary.json"),
|
||||
)
|
||||
for record in ordered
|
||||
)
|
||||
warning_values = [latest(record, "warnings") for record in ordered if isinstance(latest(record, "warnings"), (int, float))]
|
||||
max_delta = max((run.get("wallMs", 0) for _, run in run_rows(ordered) if run.get("syncMode") == "delta"), default=0)
|
||||
report_html = f'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Feature #149 性能实验报告</title>
|
||||
<style>
|
||||
body{{font-family:Inter,system-ui,sans-serif;margin:0;background:#f8fafc;color:#0f172a}}main{{max-width:1380px;margin:auto;padding:34px}}h1{{margin-bottom:4px}}.sub{{color:#475569}}.cards{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:26px 0}}.card{{background:white;border:1px solid #e2e8f0;border-radius:12px;padding:16px}}.card strong{{display:block;font-size:25px;margin:8px 0}}.card small{{color:#64748b}}section{{background:white;border:1px solid #e2e8f0;border-radius:12px;padding:20px;margin-top:18px;overflow:auto}}table{{border-collapse:collapse;width:100%;font-size:13px}}th,td{{padding:9px;border-bottom:1px solid #e2e8f0;text-align:left;white-space:nowrap}}th{{background:#f1f5f9}}svg{{width:100%;height:auto;min-width:760px}}.axis,.label{{font-size:12px;fill:#475569}}.value{{font-size:13px;font-weight:700;fill:#0f172a}}.circle-label{{font-size:10px;fill:#fff;font-weight:700}}.note{{color:#475569;line-height:1.6}}li{{margin:7px 0;line-height:1.5}}code{{background:#f1f5f9;padding:2px 5px;border-radius:4px}}
|
||||
</style>
|
||||
<main><h1>Feature #149:系统盘 / 数据盘缓存性能实验</h1><p class="sub">远端 {html.escape(args.remote_host)} · source {html.escape(args.source_commit)} · archive SHA-256 {html.escape(args.package_sha256)} · 4 cohorts / 24 serial all5 runs</p><div class="cards">{card_html}</div>
|
||||
<section><h2>实验拓扑与时序</h2><p class="note">amd64 远端主机的 system cohort 将 portable package、state、数据和日志全部写在系统盘;data cohort 将对应内容全部写在 <code>/data</code>。四个 cohort 按 A-system → A-data → B-system → B-data 串行执行,每个 row 是 1 snapshot + 5 delta,相邻启动节拍 600 秒。</p>{timeline_svg(ordered)}</section>
|
||||
<section><h2>Milestone timeline</h2>{milestone_svg()}<p class="note">M1–M5 均已通过:磁盘初始化、同 checksum package 部署与 dry-run、A/B 两组正式运行、证据归档和报告收口。</p></section>
|
||||
<section><h2>主机、磁盘与 provenance</h2><p class="note"><code>{html.escape(args.remote_host)}</code>;system 为远端根文件系统(/dev/vda3),data 为 /data(/dev/vdb1、ext4、rpki-data)。同一 archive checksum 为 <code>{html.escape(args.package_sha256)}</code>,源码 commit 为 <code>{html.escape(args.source_commit)}</code>。对象 cache 固定包含 ROA 与 child-certificate validation cache;两 profile 的唯一 cache 差异是 transport prefetch。</p></section>
|
||||
<section><h2>结论摘要</h2><ul>{''.join(f'<li>{html.escape(line)}</li>' for line in conclusion_lines(indexed))}</ul></section>
|
||||
<section><h2>Delta median wall clock</h2>{svg_chart(ordered)}<p class="note">颜色:蓝色 system,青绿色 data。全体 delta 的 median 为 {number(all_stats.get('median'), 1)} ms;最大 delta 为 {number(max_delta, 1)} ms,长尾包含网络同步窗口。</p></section>
|
||||
<section><h2>逐 cohort KPI</h2><table><tr><th>cohort</th><th>disk</th><th>profile</th><th>elapsed</th><th>snapshot</th><th>delta min/mean/median/p90/max</th><th>stable median</th><th>max RSS</th><th>VRP</th><th>VAP</th><th>PP</th><th>warnings</th></tr>{cohort_rows_html}</table></section>
|
||||
<section><h2>磁盘配对(all delta / stable delta median)</h2><table><tr><th>profile</th><th>system ms</th><th>data ms</th><th>data vs system(all / stable)</th></tr>{paired_html}</table><p class="note">负值表示 data median 更快,正值表示更慢;snapshot 单样本另行列在 cohort KPI 表中。</p></section>
|
||||
<section><h2>磁盘配对(all delta / stable delta mean)</h2><table><tr><th>profile</th><th>system ms</th><th>data ms</th><th>data vs system(all / stable)</th></tr>{paired_mean_html}</table><p class="note">这里的 mean 是对应 cohort 的 deltaWallMs 平均值;负值表示 data mean 更快。</p></section>
|
||||
<section><h2>Prefetch 开关对比(stable delta median)</h2><table><tr><th>disk</th><th>no prefetch ms</th><th>prefetch ms</th><th>prefetch vs no prefetch</th></tr>{prefetch_html}</table></section>
|
||||
<section><h2>Prefetch 开关对比(stable delta mean)</h2><table><tr><th>disk</th><th>no prefetch ms</th><th>prefetch ms</th><th>prefetch vs no prefetch</th></tr>{prefetch_mean_html}</table></section>
|
||||
<section><h2>逐 run 原始指标</h2><table><tr><th>cohort</th><th>run</th><th>mode</th><th>actual start UTC</th><th>wall ms</th><th>max RSS KiB</th><th>schedule lag ms</th><th>VRP/VAP/PP/warnings</th><th>artifacts</th></tr>{run_rows_html}</table></section>
|
||||
<section><h2>验收与异常诊断</h2><ul><li>4 个 cohort 均为 6/6 success、首轮 snapshot 后连续五轮 delta;summary 中每 run 的核心产物均完整,实际启动间隔校验为 600 秒。</li><li>最新 warnings 范围为 {number(min(warning_values) if warning_values else None)}–{number(max(warning_values) if warning_values else None)};VRP/VAP/PP 在 live 窗口内小幅变化,未出现输出塌缩或 recovery snapshot。</li><li>结果受 live RPKI 发布变化、RRDP/rsync 网络长尾和顺序时间窗口影响;不能仅凭四组 wall 值声称 IOPS 的严格因果效应。</li></ul><table><tr><th>cohort</th><th>远端 package/run root</th><th>远端 cohort summary</th></tr>{evidence_rows_html}</table><p class="note">本地输入证据为:{'; '.join(f'<code>{html.escape(str(path))}</code>' for path in input_paths)}。</p></section>
|
||||
<section><h2>统计口径</h2><p class="note">snapshot 统计只包含每个 cohort 的首轮;all delta 统计包含 delta1–delta5 共 20 个样本;stable delta 统计包含 delta2–delta5 共 16 个样本。详细 cache 命中、stage timing、命令 argv、run-meta/run-summary 和完整产物仍在远端 run root。</p></section></main></html>'''
|
||||
args.out_html.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out_markdown.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out_html.write_text(report_html, encoding="utf-8")
|
||||
write_markdown(ordered, args.source_commit, args.package_sha256, args.remote_host, input_paths, args.out_markdown)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -38,7 +38,8 @@ Environment:
|
||||
EXPERIMENT_RUN_ROOT shared run root/state root; default PACKAGE_ROOT
|
||||
EXPERIMENT_DIR experiment metadata output directory
|
||||
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, sig-pp-compare,
|
||||
feature149-prefetch or feature149-no-prefetch
|
||||
RUN_START_INTERVAL_SECS fixed start cadence for all runs; default 600
|
||||
FIRST_RUN_DELAY_SECS delay before the first scheduled run; default 0
|
||||
SNAPSHOT_EXTRA_ARGS extra rpki args for snapshot warmup
|
||||
@@ -95,7 +96,11 @@ case_count() {
|
||||
case "$EXPERIMENT_CASE_SET" in
|
||||
default) printf '%s' 4 ;;
|
||||
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 ;;
|
||||
feature149-prefetch|feature149-no-prefetch) printf '%s' 1 ;;
|
||||
*) die "unknown EXPERIMENT_CASE_SET: $EXPERIMENT_CASE_SET" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -108,6 +113,16 @@ case_id_for_index() {
|
||||
cache-only:1) printf '%s' "pp-only" ;;
|
||||
cache-only:2) printf '%s' "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" ;;
|
||||
feature149-prefetch:1) printf '%s' "prefetch-pp-object" ;;
|
||||
feature149-no-prefetch:1) printf '%s' "pp-object" ;;
|
||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||
esac
|
||||
}
|
||||
@@ -121,6 +136,16 @@ case_name_for_index() {
|
||||
cache-only:1) printf '%s' "pp-cache-only" ;;
|
||||
cache-only:2) printf '%s' "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" ;;
|
||||
feature149-prefetch:1) printf '%s' "prefetch-pp-object-cache" ;;
|
||||
feature149-no-prefetch:1) printf '%s' "pp-object-cache" ;;
|
||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||
esac
|
||||
}
|
||||
@@ -134,14 +159,25 @@ case_extra_args_for_index() {
|
||||
cache-only:1) printf '%s' "--enable-publication-point-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" ;;
|
||||
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" ;;
|
||||
feature149-prefetch:1) printf '%s' "--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation" ;;
|
||||
feature149-no-prefetch:1) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation" ;;
|
||||
*) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case_child_cert_cache_for_index() {
|
||||
case "$EXPERIMENT_CASE_SET:$1" in
|
||||
default:4|cache-only:2|cache-only:3) printf '%s' "1" ;;
|
||||
default:1|default:2|default:3|cache-only:1) printf '%s' "0" ;;
|
||||
default:4|cache-only:2|cache-only:3|crypto-sig:3|crypto-sig:4|baseline-combo:2) printf '%s' "1" ;;
|
||||
feature149-prefetch:1|feature149-no-prefetch:1) printf '%s' "1" ;;
|
||||
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" ;;
|
||||
esac
|
||||
}
|
||||
@@ -163,6 +199,31 @@ elif case_set == "cache-only":
|
||||
{"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},
|
||||
]
|
||||
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},
|
||||
]
|
||||
elif case_set == "feature149-prefetch":
|
||||
cases = [
|
||||
{"caseId": "prefetch-pp-object", "caseName": "prefetch-pp-object-cache", "extraArgs": "--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation", "enableChildCertificateValidationCache": True},
|
||||
]
|
||||
elif case_set == "feature149-no-prefetch":
|
||||
cases = [
|
||||
{"caseId": "pp-object", "caseName": "pp-object-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation", "enableChildCertificateValidationCache": True},
|
||||
]
|
||||
else:
|
||||
raise SystemExit(f"unknown case set: {case_set}")
|
||||
print(json.dumps(cases, ensure_ascii=False, indent=8))
|
||||
@@ -345,6 +406,7 @@ else:
|
||||
"publication_point_cache_index_load",
|
||||
"publication_point_cache_index_refresh",
|
||||
"roa_validation_cache",
|
||||
"crypto_signature_cache_observe",
|
||||
]:
|
||||
if key in stage:
|
||||
record[key] = stage.get(key)
|
||||
@@ -360,6 +422,11 @@ else:
|
||||
"child_certificate_cache_miss_not_found",
|
||||
"fresh_publication_points",
|
||||
"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["repoSyncStats"] = summary.get("repoSyncStats")
|
||||
|
||||
79
scripts/soak/run_feature149_cohort.sh
Normal file
79
scripts/soak/run_feature149_cohort.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PACKAGE_ROOT="${PACKAGE_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
||||
COHORT_ID="${COHORT_ID:?COHORT_ID is required}"
|
||||
COHORT_DISK="${COHORT_DISK:?COHORT_DISK is required (system or data)}"
|
||||
COHORT_PROFILE="${COHORT_PROFILE:?COHORT_PROFILE is required (prefetch-pp-object or pp-object)}"
|
||||
RUN_START_INTERVAL_SECS="${RUN_START_INTERVAL_SECS:-600}"
|
||||
CASE_RUNS="${CASE_RUNS:-5}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
FIRST_RUN_NOT_BEFORE_EPOCH="${FIRST_RUN_NOT_BEFORE_EPOCH:-0}"
|
||||
|
||||
die() {
|
||||
echo "error: $*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
is_true() {
|
||||
case "${1:-}" in
|
||||
1|true|TRUE|yes|YES|on|ON) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$COHORT_DISK" in
|
||||
system|data) ;;
|
||||
*) die "COHORT_DISK must be system or data: $COHORT_DISK" ;;
|
||||
esac
|
||||
|
||||
case "$COHORT_PROFILE" in
|
||||
prefetch-pp-object) case_set="feature149-prefetch" ;;
|
||||
pp-object) case_set="feature149-no-prefetch" ;;
|
||||
*) die "COHORT_PROFILE must be prefetch-pp-object or pp-object: $COHORT_PROFILE" ;;
|
||||
esac
|
||||
|
||||
[[ "$CASE_RUNS" == "5" ]] || die "Feature #149 requires CASE_RUNS=5, got $CASE_RUNS"
|
||||
[[ "$RUN_START_INTERVAL_SECS" == "600" ]] || die "Feature #149 requires RUN_START_INTERVAL_SECS=600, got $RUN_START_INTERVAL_SECS"
|
||||
[[ -x "$SCRIPT_DIR/run_cache_ablation_experiment.sh" ]] || die "missing cache ablation driver"
|
||||
[[ -x "$SCRIPT_DIR/summarize_feature149_cohort.py" ]] || die "missing Feature #149 summarizer"
|
||||
[[ -f "$PACKAGE_ROOT/.env" ]] || die "missing package environment: $PACKAGE_ROOT/.env"
|
||||
|
||||
experiment_dir="$PACKAGE_ROOT/experiments/feature149-$COHORT_ID"
|
||||
summary_jsonl="$experiment_dir/experiment-summary.jsonl"
|
||||
|
||||
if (( FIRST_RUN_NOT_BEFORE_EPOCH > 0 )) && ! is_true "$DRY_RUN"; then
|
||||
now_epoch="$(date +%s)"
|
||||
if (( now_epoch < FIRST_RUN_NOT_BEFORE_EPOCH )); then
|
||||
wait_secs=$(( FIRST_RUN_NOT_BEFORE_EPOCH - now_epoch ))
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] waiting ${wait_secs}s before cohort=$COHORT_ID" >&2
|
||||
sleep "$wait_secs"
|
||||
fi
|
||||
fi
|
||||
|
||||
env \
|
||||
PACKAGE_ROOT="$PACKAGE_ROOT" \
|
||||
ENV_FILE="$PACKAGE_ROOT/.env" \
|
||||
EXPERIMENT_RUN_ROOT="$PACKAGE_ROOT" \
|
||||
EXPERIMENT_DIR="$experiment_dir" \
|
||||
EXPERIMENT_CASE_SET="$case_set" \
|
||||
CASE_RUNS="$CASE_RUNS" \
|
||||
RUN_SNAPSHOT=1 \
|
||||
RUN_START_INTERVAL_SECS="$RUN_START_INTERVAL_SECS" \
|
||||
FIRST_RUN_DELAY_SECS=0 \
|
||||
RETAIN_RUNS=6 \
|
||||
DRY_RUN="$DRY_RUN" \
|
||||
"$SCRIPT_DIR/run_cache_ablation_experiment.sh"
|
||||
|
||||
if ! is_true "$DRY_RUN"; then
|
||||
"$SCRIPT_DIR/summarize_feature149_cohort.py" \
|
||||
--cohort-id "$COHORT_ID" \
|
||||
--cohort-disk "$COHORT_DISK" \
|
||||
--cohort-profile "$COHORT_PROFILE" \
|
||||
--package-root "$PACKAGE_ROOT" \
|
||||
--summary-jsonl "$summary_jsonl" \
|
||||
--out-dir "$experiment_dir"
|
||||
fi
|
||||
|
||||
printf 'feature149_cohort_dir=%s\n' "$experiment_dir"
|
||||
@@ -36,6 +36,10 @@ DISABLE_COMPETING_RPS="${DISABLE_COMPETING_RPS:-1}"
|
||||
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE="${ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE:-0}"
|
||||
RPKI_EXTRA_ARGS="${RPKI_EXTRA_ARGS:-}"
|
||||
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}"
|
||||
FIXTURE_DIR="${FIXTURE_DIR:-$PACKAGE_ROOT/fixtures}"
|
||||
@@ -48,6 +52,7 @@ TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}"
|
||||
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}"
|
||||
INVALID_ROOT="$STATE_ROOT/invalid"
|
||||
RESET_STAGING_ROOT="$STATE_ROOT/reset-staging"
|
||||
STATE_EXECUTION_LOCK_PATH="$STATE_ROOT/locks/state-execution.lock"
|
||||
LIVE_TA_REFRESH_DIR="${LIVE_TA_REFRESH_DIR:-$META_DIR/live-ta-refresh}"
|
||||
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS="${LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS:-15}"
|
||||
LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}"
|
||||
@@ -118,10 +123,10 @@ validate_rsync_scope() {
|
||||
|
||||
validate_tal_input_mode() {
|
||||
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
|
||||
}
|
||||
@@ -155,14 +160,46 @@ parse_rirs() {
|
||||
afrinic|apnic|arin|lacnic|ripe)
|
||||
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
|
||||
done
|
||||
[[ "${#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() {
|
||||
case "$1" in
|
||||
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
|
||||
@@ -170,6 +207,7 @@ tal_file_for_rir() {
|
||||
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
|
||||
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
|
||||
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
|
||||
custom) printf '%s' "$CUSTOM_TAL_PATH" ;;
|
||||
*) die "unknown RIR: $1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -181,6 +219,7 @@ ta_file_for_rir() {
|
||||
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
|
||||
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
|
||||
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
|
||||
custom) printf '%s' "$CUSTOM_TA_PATH" ;;
|
||||
*) die "unknown RIR: $1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -192,6 +231,7 @@ tal_url_for_rir() {
|
||||
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" ;;
|
||||
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
|
||||
custom) printf '%s' "$CUSTOM_TAL_URI" ;;
|
||||
*) die "unknown RIR: $1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -1057,6 +1097,17 @@ build_child_args() {
|
||||
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
|
||||
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+=(
|
||||
--parallel-phase2-ready-batch-size 256
|
||||
--parallel-phase2-ready-batch-wall-time-budget-ms 100
|
||||
@@ -1080,6 +1131,7 @@ build_child_args() {
|
||||
|
||||
CHILD_ARGS+=(
|
||||
--report-json "{run_out}/report.json"
|
||||
--validation-contract-out "{run_out}/validation-contract.json"
|
||||
)
|
||||
if is_true "$OUTPUT_COMPACT_REPORT"; then
|
||||
CHILD_ARGS+=(--report-json-compact)
|
||||
@@ -1109,6 +1161,59 @@ build_child_args() {
|
||||
fi
|
||||
}
|
||||
|
||||
write_current_state_binding() {
|
||||
local run_dir="$1"
|
||||
local run_id="$2"
|
||||
local output_path="$META_DIR/current-state-binding.json"
|
||||
python3 - "$run_dir" "$run_id" "$output_path" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
run_dir = Path(sys.argv[1])
|
||||
run_id = sys.argv[2]
|
||||
output_path = Path(sys.argv[3])
|
||||
summary = json.loads((run_dir / "run-summary.json").read_text(encoding="utf-8"))
|
||||
if summary.get("status") != "success":
|
||||
raise SystemExit(f"cannot bind unsuccessful source run: {run_id}")
|
||||
|
||||
def digest(name):
|
||||
path = run_dir / name
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"current state binding input is missing: {path}")
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
binding = {
|
||||
"schemaVersion": 1,
|
||||
"currentRunId": run_id,
|
||||
"sourceCirSha256": digest("input.cir"),
|
||||
"sourceCcrSha256": digest("result.ccr"),
|
||||
"validationContractSha256": digest("validation-contract.json"),
|
||||
"updatedAtRfc3339Utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=output_path.name + ".", dir=output_path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(binding, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_name, output_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
PY
|
||||
}
|
||||
|
||||
copy_inner_run_outputs() {
|
||||
local daemon_state_root="$1"
|
||||
local run_dir="$2"
|
||||
@@ -1276,6 +1381,17 @@ run_one_round() {
|
||||
daemon_args+=(--db-stats-exact-every "$DB_STATS_EXACT_EVERY")
|
||||
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
|
||||
env \
|
||||
@@ -1304,6 +1420,12 @@ run_one_round() {
|
||||
final_status="failed"
|
||||
fi
|
||||
fi
|
||||
if [[ "$final_status" == "success" ]]; then
|
||||
if ! write_current_state_binding "$run_dir" "$run_id"; then
|
||||
warn "failed to bind successful run to current state: $run_id"
|
||||
final_status="failed"
|
||||
fi
|
||||
fi
|
||||
write_run_meta "$run_dir/run-meta.json" "$final_status" "$run_index" "$run_id" "$sync_mode" \
|
||||
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "$completed_at" \
|
||||
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "$daemon_exit_code" "$PACKAGE_ROOT" "$ENV_FILE" \
|
||||
@@ -1327,6 +1449,7 @@ main() {
|
||||
require_command python3
|
||||
require_command date
|
||||
require_command find
|
||||
require_command flock
|
||||
if [[ "$TAL_INPUT_MODE" == "file-live-ta" ]]; then
|
||||
require_command curl
|
||||
validate_positive_int "LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS" "$LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS"
|
||||
@@ -1344,6 +1467,7 @@ main() {
|
||||
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
|
||||
fi
|
||||
parse_rirs
|
||||
validate_custom_inputs
|
||||
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
|
||||
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"
|
||||
|
||||
@@ -1360,7 +1484,7 @@ main() {
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$RESET_STAGING_ROOT" "$LIVE_TA_REFRESH_DIR"
|
||||
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$RESET_STAGING_ROOT" "$LIVE_TA_REFRESH_DIR" "$(dirname "$STATE_EXECUTION_LOCK_PATH")"
|
||||
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
|
||||
mkdir -p "$RSYNC_MIRROR_ROOT"
|
||||
fi
|
||||
@@ -1386,6 +1510,9 @@ main() {
|
||||
local any_failed=0
|
||||
|
||||
while (( run_forever == 1 || next_index <= stop_index )); do
|
||||
local state_lock_fd
|
||||
exec {state_lock_fd}>"$STATE_EXECUTION_LOCK_PATH"
|
||||
flock "$state_lock_fd"
|
||||
INVALID_DB_PATH=""
|
||||
INVALID_STATE_PATH=""
|
||||
INVALID_TMP_PATH=""
|
||||
@@ -1472,6 +1599,8 @@ main() {
|
||||
echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2
|
||||
any_failed=1
|
||||
fi
|
||||
flock -u "$state_lock_fd"
|
||||
exec {state_lock_fd}>&-
|
||||
if (( STOP_AFTER_SECS > 0 )); then
|
||||
elapsed_secs=$(( $(date +%s) - started_epoch ))
|
||||
if (( elapsed_secs >= STOP_AFTER_SECS )); then
|
||||
|
||||
169
scripts/soak/summarize_feature149_cohort.py
Normal file
169
scripts/soak/summarize_feature149_cohort.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and summarize one Feature #149 six-run cohort."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REQUIRED_ARTIFACTS = {
|
||||
"run-meta.json",
|
||||
"run-summary.json",
|
||||
"input.cir",
|
||||
"result.ccr",
|
||||
"report.json",
|
||||
"vrps.csv",
|
||||
"vaps.csv",
|
||||
"stage-timing.json",
|
||||
"process-time.txt",
|
||||
}
|
||||
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if line.strip():
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{path}:{line_number} is not an object")
|
||||
records.append(value)
|
||||
return records
|
||||
|
||||
|
||||
def require_success(records: list[dict[str, Any]], package_root: Path) -> list[dict[str, Any]]:
|
||||
if len(records) != 6:
|
||||
raise ValueError(f"expected 6 records (snapshot + 5 delta), found {len(records)}")
|
||||
for index, record in enumerate(records):
|
||||
expected_mode = "snapshot" if index == 0 else "delta"
|
||||
if record.get("status") != "success":
|
||||
raise ValueError(f"run {index + 1} status is {record.get('status')!r}")
|
||||
if record.get("syncMode") != expected_mode:
|
||||
raise ValueError(
|
||||
f"run {index + 1} syncMode is {record.get('syncMode')!r}, expected {expected_mode!r}"
|
||||
)
|
||||
run_dir = package_root / "runs" / f"run_{int(record['maxRunIndexAfter']):04d}"
|
||||
missing = sorted(name for name in REQUIRED_ARTIFACTS if not (run_dir / name).is_file())
|
||||
record["runDir"] = str(run_dir)
|
||||
record["artifactsComplete"] = not missing
|
||||
record["missingArtifacts"] = missing
|
||||
if missing:
|
||||
raise ValueError(f"run {index + 1} missing required artifacts: {', '.join(missing)}")
|
||||
return records
|
||||
|
||||
|
||||
def percentile(values: list[float], fraction: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
position = (len(ordered) - 1) * fraction
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
||||
|
||||
|
||||
def stats(values: list[int | float | None]) -> dict[str, float | None]:
|
||||
nums = [float(value) for value in values if isinstance(value, (int, float))]
|
||||
if not nums:
|
||||
return {"count": 0, "min": None, "median": None, "p90": None, "max": None, "mean": None}
|
||||
return {
|
||||
"count": len(nums),
|
||||
"min": min(nums),
|
||||
"median": statistics.median(nums),
|
||||
"p90": percentile(nums, 0.9),
|
||||
"max": max(nums),
|
||||
"mean": statistics.fmean(nums),
|
||||
}
|
||||
|
||||
|
||||
def iso(epoch: Any) -> str | None:
|
||||
if not isinstance(epoch, (int, float)):
|
||||
return None
|
||||
return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cohort-id", required=True)
|
||||
parser.add_argument("--cohort-disk", required=True, choices=("system", "data"))
|
||||
parser.add_argument("--cohort-profile", required=True, choices=("prefetch-pp-object", "pp-object"))
|
||||
parser.add_argument("--package-root", required=True, type=Path)
|
||||
parser.add_argument("--summary-jsonl", required=True, type=Path)
|
||||
parser.add_argument("--out-dir", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
records = require_success(load_jsonl(args.summary_jsonl), args.package_root)
|
||||
delta = records[1:]
|
||||
elapsed_seconds = records[-1]["completedEpoch"] - records[0]["actualStartEpoch"]
|
||||
report = {
|
||||
"feature": "149",
|
||||
"createdAtUtc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"cohortId": args.cohort_id,
|
||||
"disk": args.cohort_disk,
|
||||
"profile": args.cohort_profile,
|
||||
"packageRoot": str(args.package_root),
|
||||
"runCount": len(records),
|
||||
"successCount": len(records),
|
||||
"artifactStatus": "complete",
|
||||
"elapsedSeconds": elapsed_seconds,
|
||||
"actualStartedAtUtc": iso(records[0].get("actualStartEpoch")),
|
||||
"completedAtUtc": iso(records[-1].get("completedEpoch")),
|
||||
"maxRssKb": max(int(record["maxRssKb"]) for record in records if isinstance(record.get("maxRssKb"), (int, float))),
|
||||
"snapshot": {"wallMs": records[0].get("wallMs"), "maxRssKb": records[0].get("maxRssKb")},
|
||||
"deltaWallMs": stats([record.get("wallMs") for record in delta]),
|
||||
"stableDeltaWallMs": stats([record.get("wallMs") for record in delta[1:]]),
|
||||
"latestCounts": {
|
||||
key: records[-1].get(key)
|
||||
for key in ("vrps", "vaps", "publicationPoints", "warnings")
|
||||
},
|
||||
"runs": records,
|
||||
}
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = args.out_dir / "cohort-summary.json"
|
||||
markdown_path = args.out_dir / "cohort-summary.md"
|
||||
json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
latest = report["latestCounts"]
|
||||
markdown_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"# Feature #149 cohort: {args.cohort_id}",
|
||||
"",
|
||||
f"- 磁盘:`{args.cohort_disk}`",
|
||||
f"- profile:`{args.cohort_profile}`",
|
||||
f"- 状态:`success`,6/6 run 成功,必要产物完整",
|
||||
f"- 总执行耗时:`{elapsed_seconds}s`",
|
||||
f"- 最高 RSS:`{report['maxRssKb']} KiB`",
|
||||
f"- snapshot wall:`{records[0].get('wallMs')} ms`",
|
||||
f"- delta median / p90:`{report['deltaWallMs']['median']} ms` / `{report['deltaWallMs']['p90']} ms`",
|
||||
f"- 最新产物:VRP `{latest['vrps']}`、VAP `{latest['vaps']}`、PP `{latest['publicationPoints']}`、warnings `{latest['warnings']}`",
|
||||
"",
|
||||
"| run | mode | wall ms | max RSS KiB | VRP | VAP | PP | warnings | artifacts |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
*[
|
||||
"| {run} | {mode} | {wall} | {rss} | {vrps} | {vaps} | {pps} | {warnings} | {artifacts} |".format(
|
||||
run=index + 1,
|
||||
mode=record.get("syncMode"),
|
||||
wall=record.get("wallMs"),
|
||||
rss=record.get("maxRssKb"),
|
||||
vrps=record.get("vrps"),
|
||||
vaps=record.get("vaps"),
|
||||
pps=record.get("publicationPoints"),
|
||||
warnings=record.get("warnings"),
|
||||
artifacts="complete" if record.get("artifactsComplete") else "missing",
|
||||
)
|
||||
for index, record in enumerate(records)
|
||||
],
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"cohortSummaryJson": str(json_path), "cohortSummaryMarkdown": str(markdown_path), "elapsedSeconds": elapsed_seconds, "maxRssKb": report["maxRssKb"]}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
178
scripts/ta_constraints/generate_afrinic_current_holdings.py
Executable file
178
scripts/ta_constraints/generate_afrinic_current_holdings.py
Executable file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a current-only AFRINIC IPv4 holdings constraints fixture.
|
||||
|
||||
The AFRINIC delegated extended format contains one IPv4 allocation/assignment
|
||||
per line. This tool keeps only active registry holdings (``allocated`` and
|
||||
``assigned``), computes their exact union, and emits an allow-all policy with
|
||||
the union as IPv4 deny entries. ``reserved`` and ``available`` records are
|
||||
intentionally excluded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
DEFAULT_SOURCE_URL = (
|
||||
"https://ftp.afrinic.net/pub/stats/afrinic/"
|
||||
"delegated-afrinic-extended-latest"
|
||||
)
|
||||
ACTIVE_STATUSES = {"allocated", "assigned"}
|
||||
|
||||
|
||||
def merge_intervals(
|
||||
intervals: Iterable[tuple[int, int]],
|
||||
) -> list[tuple[int, int]]:
|
||||
merged: list[tuple[int, int]] = []
|
||||
for start, end in sorted(intervals):
|
||||
if merged and start <= merged[-1][1] + 1:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def parse_source(raw: bytes) -> tuple[list[tuple[int, int]], Counter[str], Counter[str]]:
|
||||
status_counts: Counter[str] = Counter()
|
||||
ipv4_status_counts: Counter[str] = Counter()
|
||||
active: list[tuple[int, int]] = []
|
||||
for line_number, raw_line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
|
||||
if not raw_line or raw_line.startswith("#"):
|
||||
continue
|
||||
fields = raw_line.split("|")
|
||||
if len(fields) < 7 or fields[2] != "ipv4":
|
||||
continue
|
||||
status = fields[6]
|
||||
status_counts[status] += 1
|
||||
ipv4_status_counts[status] += 1
|
||||
if status not in ACTIVE_STATUSES:
|
||||
continue
|
||||
try:
|
||||
start = int(ipaddress.IPv4Address(fields[3]))
|
||||
count = int(fields[4])
|
||||
except (ValueError, TypeError) as error:
|
||||
raise ValueError(f"line {line_number}: invalid IPv4 record: {raw_line}") from error
|
||||
if count <= 0 or start + count > 1 << 32:
|
||||
raise ValueError(f"line {line_number}: invalid IPv4 range: {raw_line}")
|
||||
active.append((start, start + count - 1))
|
||||
return active, status_counts, ipv4_status_counts
|
||||
|
||||
|
||||
def render(
|
||||
source_url: str,
|
||||
retrieved: str,
|
||||
source_sha256: str,
|
||||
source_ipv4_count: int,
|
||||
status_counts: Counter[str],
|
||||
active_source: list[tuple[int, int]],
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
merged = merge_intervals(active_source)
|
||||
networks = [
|
||||
network
|
||||
for start, end in merged
|
||||
for network in ipaddress.summarize_address_range(
|
||||
ipaddress.IPv4Address(start), ipaddress.IPv4Address(end)
|
||||
)
|
||||
]
|
||||
networks.sort(key=lambda network: int(network.network_address))
|
||||
active_addresses = sum(end - start + 1 for start, end in merged)
|
||||
counts = {
|
||||
"source_ipv4_records": source_ipv4_count,
|
||||
"allocated": status_counts["allocated"],
|
||||
"assigned": status_counts["assigned"],
|
||||
"reserved": status_counts["reserved"],
|
||||
"available": status_counts["available"],
|
||||
"selected_source_records": len(active_source),
|
||||
"selected_addresses": active_addresses,
|
||||
"merged_intervals": len(merged),
|
||||
"cidr_rules": len(networks),
|
||||
}
|
||||
lines = [
|
||||
"# Feature #151 M7 current AFRINIC IPv4 holdings deny fixture.",
|
||||
"#",
|
||||
"# Source: AFRINIC delegated extended latest.",
|
||||
f"# URL: {source_url}",
|
||||
f"# Retrieved: {retrieved}",
|
||||
f"# Source SHA-256: {source_sha256}",
|
||||
"#",
|
||||
"# Selection: IPv4 records with status allocated or assigned only.",
|
||||
"# Excluded statuses: reserved and available (not current registry holdings).",
|
||||
(
|
||||
"# Source IPv4 records: {source_ipv4_records}; allocated={allocated}; "
|
||||
"assigned={assigned}; reserved={reserved}; available={available}."
|
||||
).format(**counts),
|
||||
"# Selected source records: {selected_source_records}; selected addresses: {selected_addresses}.".format(
|
||||
**counts
|
||||
),
|
||||
"# Overlap/adjacency union intervals: {merged_intervals}; exact CIDR deny rules: {cidr_rules}.".format(
|
||||
**counts
|
||||
),
|
||||
"# IPv6 and ASN remain allow-all to isolate the current AFRINIC IPv4 policy.",
|
||||
"allow 0.0.0.0/0",
|
||||
"allow ::/0",
|
||||
"allow 0 - 4294967295",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"deny {network}" for network in networks)
|
||||
return "\n".join(lines) + "\n", counts
|
||||
|
||||
|
||||
def fetch_source(url: str, timeout: float) -> bytes:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "rpki-dev-feature151-afrinic-holdings/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument("--url", default=DEFAULT_SOURCE_URL, help="AFRINIC source URL")
|
||||
source.add_argument("--input", type=pathlib.Path, help="local delegated file for replay")
|
||||
parser.add_argument("--output", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--retrieved", help="UTC date recorded in the fixture header")
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
source_url = args.url if args.input is None else f"file://{args.input.resolve()}"
|
||||
raw = args.input.read_bytes() if args.input is not None else fetch_source(args.url, args.timeout)
|
||||
retrieved = args.retrieved or dt.datetime.now(dt.timezone.utc).date().isoformat()
|
||||
active, status_counts, ipv4_status_counts = parse_source(raw)
|
||||
rendered, counts = render(
|
||||
source_url,
|
||||
retrieved,
|
||||
hashlib.sha256(raw).hexdigest(),
|
||||
sum(ipv4_status_counts.values()),
|
||||
status_counts,
|
||||
active,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered, encoding="utf-8")
|
||||
print(
|
||||
"generated {output}: {cidr_rules} CIDRs from {selected_source_records} active "
|
||||
"records ({selected_addresses} addresses); source_sha256={sha256}".format(
|
||||
output=args.output,
|
||||
sha256=hashlib.sha256(raw).hexdigest(),
|
||||
**counts,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, urllib.error.URLError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
50
scripts/verification/README.md
Normal file
50
scripts/verification/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# verification-only 使用说明
|
||||
|
||||
`verification-only` 用于复核启用验证缓存的最新成功 normal run。它冻结当前仓库视图,关闭 PP、ROA、child certificate cache、prefetch、VCIR 持久化和网络访问,在源 CIR 的同一验证时间重新执行完整验证,并比较 CCR 的 MFT、VRP、VAP、TA、router key 五类 state digest。
|
||||
|
||||
## 前提
|
||||
|
||||
- 源 run 必须是当前 state DB 对应的最新成功 normal run。
|
||||
- 源 run 必须包含 `input.cir`、`result.ccr`、`run-summary.json` 和 `validation-contract.json`。
|
||||
- state root 必须包含 `db/work-db`、`db/repo-bytes.db` 和 `meta/current-state-binding.json`。
|
||||
- 源 normal run 必须启用至少一种验证缓存;仅启用 transport prefetch 不算验证缓存。
|
||||
- verification-only 与 normal soak 共用 `state/locks/state-execution.lock`,不能绕过 wrapper 并发执行。
|
||||
|
||||
## 执行
|
||||
|
||||
在 portable package 根目录执行:
|
||||
|
||||
```bash
|
||||
./scripts/verification/run_verification_only.sh \
|
||||
--source-run-dir /data/ours-rp/runs/run_0123 \
|
||||
--source-state-root /data/ours-rp/state \
|
||||
--out-dir /data/ours-rp/verification-runs/run_0123
|
||||
```
|
||||
|
||||
验证时间自动从源 `input.cir` 读取,不接受命令行覆盖。成功后 scratch work-db 默认删除;排障时可加 `--keep-scratch`。
|
||||
|
||||
## 输出
|
||||
|
||||
- `input.cir`、`result.ccr`、`report.json`
|
||||
- `vrps.csv`、`vaps.csv`
|
||||
- `stage-timing.json`、`validation-events.jsonl`
|
||||
- `validation-contract.json`、`verification-meta.json`
|
||||
- `compare/ccr-state-digest.json`、`compare/ccr-state-digest.md`
|
||||
- `stdout.log`、`stderr.log`、`process-time.txt`、`wrapper-timing.json`
|
||||
|
||||
通过条件是 compare JSON 中 `stateDigestMatch=true`。`producedAt` 不参与比较。
|
||||
|
||||
## 隔离保证
|
||||
|
||||
- 不分配 normal run id,不更新 lifecycle、periodic snapshot 计数和 retention。
|
||||
- 源 work-db 只用于创建 checkpoint,后续写入只发生在独立 scratch work-db。
|
||||
- 源 repo-bytes.db 以只读模式打开。
|
||||
- 输出目录必须不存在,防止覆盖旧验证证据。
|
||||
|
||||
## 常见失败
|
||||
|
||||
- `current state binding`:所选 run 已不是当前 state 对应的最新成功 run,需选择 binding 指向的 run。
|
||||
- `differs from current state binding`:源 CIR、CCR 或 contract 已变化,不能继续复核。
|
||||
- `blob is missing`:repository view 引用的原始 bytes 不完整,验证不会启动。
|
||||
- `did not enable a validation cache`:源 run 不是缓存正确性对比的有效基准。
|
||||
- `CCR state digest mismatch`:查看 `compare/` 下 JSON/Markdown,按不匹配状态集合继续排查。
|
||||
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"
|
||||
102
scripts/verification/run_verification_only.sh
Executable file
102
scripts/verification/run_verification_only.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
run_verification_only.sh \
|
||||
--source-run-dir <run_dir> \
|
||||
--source-state-root <state_dir> \
|
||||
--out-dir <verification_dir> \
|
||||
[--rpki-bin <path>] [--keep-scratch]
|
||||
|
||||
validation time is always read from the source CIR. The wrapper serializes access
|
||||
with normal soak runs and never writes into the normal run sequence.
|
||||
USAGE
|
||||
}
|
||||
|
||||
SOURCE_RUN_DIR=""
|
||||
SOURCE_STATE_ROOT=""
|
||||
OUT_DIR=""
|
||||
RPKI_BIN=""
|
||||
KEEP_SCRATCH=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-run-dir) SOURCE_RUN_DIR="${2:?missing value}"; shift 2 ;;
|
||||
--source-state-root) SOURCE_STATE_ROOT="${2:?missing value}"; shift 2 ;;
|
||||
--out-dir) OUT_DIR="${2:?missing value}"; shift 2 ;;
|
||||
--rpki-bin) RPKI_BIN="${2:?missing value}"; shift 2 ;;
|
||||
--keep-scratch) KEEP_SCRATCH=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$SOURCE_RUN_DIR" ]] || { echo "--source-run-dir is required" >&2; exit 2; }
|
||||
[[ -n "$SOURCE_STATE_ROOT" ]] || { echo "--source-state-root is required" >&2; exit 2; }
|
||||
[[ -n "$OUT_DIR" ]] || { echo "--out-dir is required" >&2; exit 2; }
|
||||
if [[ -z "$RPKI_BIN" ]]; then
|
||||
RPKI_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/target/release/rpki"
|
||||
fi
|
||||
[[ -x "$RPKI_BIN" ]] || { echo "rpki binary is not executable: $RPKI_BIN" >&2; exit 2; }
|
||||
command -v flock >/dev/null || { echo "flock is required" >&2; exit 2; }
|
||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
||||
|
||||
now_ms() {
|
||||
python3 -c 'import time; print(time.time_ns() // 1_000_000)'
|
||||
}
|
||||
|
||||
mkdir -p "$SOURCE_STATE_ROOT/locks" "$(dirname "$OUT_DIR")"
|
||||
[[ ! -e "$OUT_DIR" ]] || { echo "verification output already exists: $OUT_DIR" >&2; exit 2; }
|
||||
LOCK_PATH="$SOURCE_STATE_ROOT/locks/state-execution.lock"
|
||||
STDOUT_TMP="${OUT_DIR}.stdout.tmp"
|
||||
STDERR_TMP="${OUT_DIR}.stderr.tmp"
|
||||
PROCESS_TIME_TMP="${OUT_DIR}.process-time.tmp"
|
||||
WAIT_STARTED_MS="$(now_ms)"
|
||||
exec {LOCK_FD}>"$LOCK_PATH"
|
||||
flock "$LOCK_FD"
|
||||
LOCK_ACQUIRED_MS="$(now_ms)"
|
||||
|
||||
ARGS=(
|
||||
--verification-only
|
||||
--verification-source-run "$SOURCE_RUN_DIR"
|
||||
--verification-source-state "$SOURCE_STATE_ROOT"
|
||||
--verification-out "$OUT_DIR"
|
||||
)
|
||||
if [[ "$KEEP_SCRATCH" -eq 1 ]]; then
|
||||
ARGS+=(--keep-verification-scratch)
|
||||
fi
|
||||
|
||||
set +e
|
||||
if [[ -x /usr/bin/time ]]; then
|
||||
RPKI_STATE_EXECUTION_LOCK_HELD=1 /usr/bin/time -v -o "$PROCESS_TIME_TMP" -- \
|
||||
"$RPKI_BIN" "${ARGS[@]}" >"$STDOUT_TMP" 2>"$STDERR_TMP"
|
||||
else
|
||||
RPKI_STATE_EXECUTION_LOCK_HELD=1 "$RPKI_BIN" "${ARGS[@]}" >"$STDOUT_TMP" 2>"$STDERR_TMP"
|
||||
fi
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
LOCK_RELEASED_MS="$(now_ms)"
|
||||
flock -u "$LOCK_FD"
|
||||
exec {LOCK_FD}>&-
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
mv "$STDOUT_TMP" "$OUT_DIR/stdout.log"
|
||||
mv "$STDERR_TMP" "$OUT_DIR/stderr.log"
|
||||
if [[ -f "$PROCESS_TIME_TMP" ]]; then
|
||||
mv "$PROCESS_TIME_TMP" "$OUT_DIR/process-time.txt"
|
||||
fi
|
||||
python3 - "$OUT_DIR/wrapper-timing.json" "$WAIT_STARTED_MS" "$LOCK_ACQUIRED_MS" "$LOCK_RELEASED_MS" "$EXIT_CODE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
wait_started_ms, lock_acquired_ms, lock_released_ms, exit_code = map(int, sys.argv[2:])
|
||||
path.write_text(json.dumps({
|
||||
"lockWaitMs": lock_acquired_ms - wait_started_ms,
|
||||
"lockHeldMs": lock_released_ms - lock_acquired_ms,
|
||||
"exitCode": exit_code,
|
||||
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
PY
|
||||
exit "$EXIT_CODE"
|
||||
11
src/audit.rs
11
src/audit.rs
@@ -36,6 +36,7 @@ pub struct ObjectAuditEntry {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AuditWarning {
|
||||
pub message: String,
|
||||
pub category: String,
|
||||
pub rfc_refs: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
@@ -102,6 +103,7 @@ impl From<&crate::report::Warning> for AuditWarning {
|
||||
fn from(w: &crate::report::Warning) -> Self {
|
||||
Self {
|
||||
message: w.message.clone(),
|
||||
category: w.category.as_str().to_string(),
|
||||
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
|
||||
context: w.context.clone(),
|
||||
}
|
||||
@@ -238,6 +240,15 @@ mod tests {
|
||||
assert!(value.get("cir_fresh_objects").is_none());
|
||||
assert!(value.get("cir_cached_objects").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_audit_keeps_the_warning_category() {
|
||||
let warning = crate::report::Warning::new("BER-compatible CMS accepted")
|
||||
.with_category(crate::report::WarningCategory::BerCompatibleCmsEncoding);
|
||||
|
||||
let audit_warning = AuditWarning::from(&warning);
|
||||
assert_eq!(audit_warning.category, "ber_compatible_cms_encoding");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
|
||||
437
src/bin/rpki_der_ber_audit.rs
Normal file
437
src/bin/rpki_der_ber_audit.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use rocksdb::{DB, IteratorMode, Options};
|
||||
use rpki::data_model::crl::RpkixCrl;
|
||||
use rpki::data_model::rc::ResourceCertificate;
|
||||
use rpki::data_model::signed_object::RpkiSignedObject;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Args {
|
||||
input: Option<PathBuf>,
|
||||
repo_bytes_db: Option<PathBuf>,
|
||||
output: Option<PathBuf>,
|
||||
pretty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum EncodingStatus {
|
||||
Der,
|
||||
BerCompatibleOnly,
|
||||
ParseFailed,
|
||||
NotRpki,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ObjectRecord {
|
||||
sha256: String,
|
||||
object_type: String,
|
||||
encoding_status: EncodingStatus,
|
||||
bytes: u64,
|
||||
path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuditReport {
|
||||
generated_by: &'static str,
|
||||
input: String,
|
||||
files_seen: u64,
|
||||
recognized_rpki_objects: u64,
|
||||
duplicate_content_files: u64,
|
||||
non_rpki_entries: u64,
|
||||
scan_errors: Vec<String>,
|
||||
status_counts: BTreeMap<String, u64>,
|
||||
object_type_status_counts: BTreeMap<String, BTreeMap<String, u64>>,
|
||||
objects: Vec<ObjectRecord>,
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: rpki_der_ber_audit (--input <raw-rpki-object-directory>|--repo-bytes-db <repo-bytes.db>) [--output <audit.json>] [--compact]"
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = real_main() {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn real_main() -> Result<(), String> {
|
||||
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
|
||||
let input_label = args
|
||||
.input
|
||||
.as_ref()
|
||||
.or(args.repo_bytes_db.as_ref())
|
||||
.expect("validated input");
|
||||
let mut report = AuditReport {
|
||||
generated_by: "rpki_der_ber_audit",
|
||||
input: input_label.display().to_string(),
|
||||
files_seen: 0,
|
||||
recognized_rpki_objects: 0,
|
||||
duplicate_content_files: 0,
|
||||
non_rpki_entries: 0,
|
||||
scan_errors: Vec::new(),
|
||||
status_counts: BTreeMap::new(),
|
||||
object_type_status_counts: BTreeMap::new(),
|
||||
objects: Vec::new(),
|
||||
};
|
||||
let mut seen_hashes = BTreeSet::new();
|
||||
if let Some(input) = args.input.as_ref() {
|
||||
scan_directory(input, &mut report, &mut seen_hashes)?;
|
||||
} else if let Some(repo_bytes_db) = args.repo_bytes_db.as_ref() {
|
||||
scan_repo_bytes_db(repo_bytes_db, &mut report, &mut seen_hashes)?;
|
||||
}
|
||||
report
|
||||
.objects
|
||||
.sort_by(|left, right| left.sha256.cmp(&right.sha256));
|
||||
let rendered = if args.pretty {
|
||||
serde_json::to_string_pretty(&report)
|
||||
} else {
|
||||
serde_json::to_string(&report)
|
||||
}
|
||||
.map_err(|err| format!("serialize audit report: {err}"))?;
|
||||
|
||||
if let Some(output) = args.output {
|
||||
if let Some(parent) = output.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("create output directory {}: {err}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&output, rendered)
|
||||
.map_err(|err| format!("write audit report {}: {err}", output.display()))?;
|
||||
} else {
|
||||
println!("{rendered}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
pretty: true,
|
||||
..Args::default()
|
||||
};
|
||||
let mut index = 1usize;
|
||||
while index < argv.len() {
|
||||
match argv[index].as_str() {
|
||||
"--input" | "--in" => {
|
||||
index += 1;
|
||||
args.input = Some(PathBuf::from(
|
||||
argv.get(index).ok_or("--input requires a value")?,
|
||||
));
|
||||
}
|
||||
"--repo-bytes-db" => {
|
||||
index += 1;
|
||||
args.repo_bytes_db = Some(PathBuf::from(
|
||||
argv.get(index).ok_or("--repo-bytes-db requires a value")?,
|
||||
));
|
||||
}
|
||||
"--output" | "--out" => {
|
||||
index += 1;
|
||||
args.output = Some(PathBuf::from(
|
||||
argv.get(index).ok_or("--output requires a value")?,
|
||||
));
|
||||
}
|
||||
"--compact" => args.pretty = false,
|
||||
"--pretty" => args.pretty = true,
|
||||
"-h" | "--help" => return Err(usage().to_string()),
|
||||
other => return Err(format!("unknown argument: {other}\n{}", usage())),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if args.input.is_some() == args.repo_bytes_db.is_some() {
|
||||
return Err(format!("select exactly one input source\n{}", usage()));
|
||||
}
|
||||
if let Some(input) = args.input.as_ref()
|
||||
&& !input.is_dir()
|
||||
{
|
||||
return Err(format!("--input must be a directory: {}", input.display()));
|
||||
}
|
||||
if let Some(repo_bytes_db) = args.repo_bytes_db.as_ref()
|
||||
&& !repo_bytes_db.is_dir()
|
||||
{
|
||||
return Err(format!(
|
||||
"--repo-bytes-db must be a RocksDB directory: {}",
|
||||
repo_bytes_db.display()
|
||||
));
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn scan_directory(
|
||||
directory: &Path,
|
||||
report: &mut AuditReport,
|
||||
seen_hashes: &mut BTreeSet<String>,
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(directory)
|
||||
.map_err(|err| format!("read directory {}: {err}", directory.display()))?;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
report.scan_errors.push(format!(
|
||||
"read directory entry under {}: {err}",
|
||||
directory.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(err) => {
|
||||
report
|
||||
.scan_errors
|
||||
.push(format!("read file type {}: {err}", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
scan_directory(&path, report, seen_hashes)?;
|
||||
} else if file_type.is_file() {
|
||||
scan_file(&path, report, seen_hashes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_file(path: &Path, report: &mut AuditReport, seen_hashes: &mut BTreeSet<String>) {
|
||||
report.files_seen += 1;
|
||||
let Some(object_type) = object_type_from_path(path) else {
|
||||
return;
|
||||
};
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
report
|
||||
.scan_errors
|
||||
.push(format!("read {}: {err}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
let (encoding_status, detail) = classify_object(&object_type, &bytes);
|
||||
record_object(
|
||||
report,
|
||||
seen_hashes,
|
||||
sha256,
|
||||
object_type,
|
||||
encoding_status,
|
||||
bytes.len() as u64,
|
||||
path.display().to_string(),
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
fn scan_repo_bytes_db(
|
||||
repo_bytes_db: &Path,
|
||||
report: &mut AuditReport,
|
||||
seen_hashes: &mut BTreeSet<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut options = Options::default();
|
||||
options.create_if_missing(false);
|
||||
let db = DB::open_for_read_only(&options, repo_bytes_db, false)
|
||||
.map_err(|err| format!("open repo-bytes DB {}: {err}", repo_bytes_db.display()))?;
|
||||
for entry in db.iterator(IteratorMode::Start) {
|
||||
let (key, bytes) = entry.map_err(|err| format!("iterate repo-bytes DB: {err}"))?;
|
||||
let Ok(key) = std::str::from_utf8(&key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(sha256) = key.strip_prefix("sha256:") else {
|
||||
continue;
|
||||
};
|
||||
report.files_seen += 1;
|
||||
let (object_type, encoding_status, detail) = classify_repo_blob(&bytes);
|
||||
record_object(
|
||||
report,
|
||||
seen_hashes,
|
||||
sha256.to_string(),
|
||||
object_type,
|
||||
encoding_status,
|
||||
bytes.len() as u64,
|
||||
format!("rocksdb://{}/{}", repo_bytes_db.display(), key),
|
||||
detail,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn record_object(
|
||||
report: &mut AuditReport,
|
||||
seen_hashes: &mut BTreeSet<String>,
|
||||
sha256: String,
|
||||
object_type: String,
|
||||
encoding_status: EncodingStatus,
|
||||
bytes: u64,
|
||||
path: String,
|
||||
detail: Option<String>,
|
||||
) {
|
||||
if matches!(encoding_status, EncodingStatus::NotRpki) {
|
||||
report.non_rpki_entries += 1;
|
||||
} else {
|
||||
report.recognized_rpki_objects += 1;
|
||||
}
|
||||
if !seen_hashes.insert(sha256.clone()) {
|
||||
report.duplicate_content_files += 1;
|
||||
return;
|
||||
}
|
||||
let status_name = encoding_status_name(&encoding_status).to_string();
|
||||
*report.status_counts.entry(status_name.clone()).or_default() += 1;
|
||||
*report
|
||||
.object_type_status_counts
|
||||
.entry(object_type.clone())
|
||||
.or_default()
|
||||
.entry(status_name)
|
||||
.or_default() += 1;
|
||||
report.objects.push(ObjectRecord {
|
||||
sha256,
|
||||
object_type,
|
||||
encoding_status,
|
||||
bytes,
|
||||
path,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
fn object_type_from_path(path: &Path) -> Option<String> {
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match extension.as_str() {
|
||||
"mft" | "roa" | "asa" | "aspa" | "gbr" | "cer" | "crl" => Some(extension),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_object(object_type: &str, bytes: &[u8]) -> (EncodingStatus, Option<String>) {
|
||||
match object_type {
|
||||
"mft" | "roa" | "asa" | "aspa" | "gbr" => match RpkiSignedObject::parse_der(bytes) {
|
||||
Ok(_) => match RpkiSignedObject::strict_cms_der_error(bytes) {
|
||||
Some(err) => (EncodingStatus::BerCompatibleOnly, Some(err.to_string())),
|
||||
None => (EncodingStatus::Der, None),
|
||||
},
|
||||
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
|
||||
},
|
||||
"cer" => match ResourceCertificate::parse_der(bytes) {
|
||||
Ok(_) => (EncodingStatus::Der, None),
|
||||
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
|
||||
},
|
||||
"crl" => match RpkixCrl::parse_der(bytes) {
|
||||
Ok(_) => (EncodingStatus::Der, None),
|
||||
Err(err) => (EncodingStatus::ParseFailed, Some(err.to_string())),
|
||||
},
|
||||
_ => unreachable!("object type was filtered before classification"),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_repo_blob(bytes: &[u8]) -> (String, EncodingStatus, Option<String>) {
|
||||
if looks_like_tal_metadata(bytes) {
|
||||
return (
|
||||
"tal".to_string(),
|
||||
EncodingStatus::NotRpki,
|
||||
Some("TAL text metadata, not an RPKI object".to_string()),
|
||||
);
|
||||
}
|
||||
if RpkiSignedObject::parse_der(bytes).is_ok() {
|
||||
return match RpkiSignedObject::strict_cms_der_error(bytes) {
|
||||
Some(err) => (
|
||||
"cms_signed_object".to_string(),
|
||||
EncodingStatus::BerCompatibleOnly,
|
||||
Some(err.to_string()),
|
||||
),
|
||||
None => ("cms_signed_object".to_string(), EncodingStatus::Der, None),
|
||||
};
|
||||
}
|
||||
if ResourceCertificate::parse_der(bytes).is_ok() {
|
||||
return ("cer".to_string(), EncodingStatus::Der, None);
|
||||
}
|
||||
if RpkixCrl::parse_der(bytes).is_ok() {
|
||||
return ("crl".to_string(), EncodingStatus::Der, None);
|
||||
}
|
||||
(
|
||||
"unknown".to_string(),
|
||||
EncodingStatus::ParseFailed,
|
||||
Some("not parsed as CMS, RPKI resource certificate, or CRL".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn encoding_status_name(status: &EncodingStatus) -> &'static str {
|
||||
match status {
|
||||
EncodingStatus::Der => "der",
|
||||
EncodingStatus::BerCompatibleOnly => "ber_compatible_only",
|
||||
EncodingStatus::ParseFailed => "parse_failed",
|
||||
EncodingStatus::NotRpki => "not_rpki",
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true for the TAL text format persisted by the live-TA refresh.
|
||||
///
|
||||
/// A TAL is intentionally not DER: it contains an rsync URI, an HTTPS URI,
|
||||
/// a blank separator, and the base64-encoded trust-anchor certificate. It
|
||||
/// can share the repo-bytes DB with downloaded RPKI objects, so the audit must
|
||||
/// keep it out of the object encoding verdict rather than report a false
|
||||
/// parse failure.
|
||||
fn looks_like_tal_metadata(bytes: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return false;
|
||||
};
|
||||
let mut lines = text.lines();
|
||||
let Some(rsync_uri) = lines.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(https_uri) = lines.next() else {
|
||||
return false;
|
||||
};
|
||||
if !rsync_uri.starts_with("rsync://") || !https_uri.starts_with("https://") {
|
||||
return false;
|
||||
}
|
||||
// `str::lines` removes the blank line, so inspect the raw prefix to make
|
||||
// sure the URI pair is followed by the TAL separator.
|
||||
let Some(separator) = text.find("\n\n") else {
|
||||
return false;
|
||||
};
|
||||
let base64 = &text[separator + 2..];
|
||||
!base64.is_empty()
|
||||
&& base64.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'\n' | b'\r')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_known_der_manifest_fixture() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
|
||||
);
|
||||
let bytes = std::fs::read(path).expect("read manifest fixture");
|
||||
assert!(matches!(
|
||||
classify_object("mft", &bytes).0,
|
||||
EncodingStatus::Der
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_supported_object_extensions() {
|
||||
assert_eq!(
|
||||
object_type_from_path(Path::new("a.roa")),
|
||||
Some("roa".to_string())
|
||||
);
|
||||
assert_eq!(object_type_from_path(Path::new("a.txt")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_tal_metadata_as_not_rpki() {
|
||||
let tal = b"rsync://rpki.example/ta.cer\nhttps://rpki.example/ta.cer\n\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n";
|
||||
let (object_type, status, detail) = classify_repo_blob(tal);
|
||||
assert_eq!(object_type, "tal");
|
||||
assert!(matches!(status, EncodingStatus::NotRpki));
|
||||
assert_eq!(
|
||||
detail.as_deref(),
|
||||
Some("TAL text metadata, not an RPKI object")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rpki::blob_store::ExternalRepoBytesDb;
|
||||
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
|
||||
use rpki::query::vrp::VrpLookup;
|
||||
use rpki::query_db::{
|
||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError,
|
||||
ValidationExplainRecord,
|
||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
||||
QueryDbError, RepoSortOrder, ValidationExplainRecord,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -148,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 export_jobs = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let repo_bytes = match args.repo_bytes_db.as_ref() {
|
||||
Some(path) => Some(Arc::new(
|
||||
ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?,
|
||||
)),
|
||||
Some(path) => {
|
||||
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,
|
||||
};
|
||||
if let Some(run_root) = args.watch_run_root.clone() {
|
||||
@@ -161,6 +174,7 @@ fn real_main() -> Result<(), String> {
|
||||
)?;
|
||||
spawn_indexer_watcher(
|
||||
Arc::clone(&db),
|
||||
repo_bytes.clone(),
|
||||
args.query_db.clone(),
|
||||
resolve_indexer_bin(args.indexer_bin.clone())?,
|
||||
run_root,
|
||||
@@ -227,6 +241,7 @@ fn resolve_effective_watch_min_run_seq(
|
||||
|
||||
fn spawn_indexer_watcher(
|
||||
db: Arc<QueryDb>,
|
||||
repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
|
||||
query_db_path: PathBuf,
|
||||
indexer_bin: PathBuf,
|
||||
run_root: PathBuf,
|
||||
@@ -248,6 +263,9 @@ fn spawn_indexer_watcher(
|
||||
) {
|
||||
Ok(message) => {
|
||||
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());
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
@@ -258,6 +276,9 @@ fn spawn_indexer_watcher(
|
||||
}
|
||||
Ok(None) => {
|
||||
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}"),
|
||||
}
|
||||
@@ -521,7 +542,18 @@ impl ApiError {
|
||||
|
||||
impl From<QueryDbError> for ApiError {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,16 +566,27 @@ fn route_request(
|
||||
let (path, query) = split_target(target);
|
||||
let query = parse_query(query);
|
||||
let path = path.trim_end_matches('/');
|
||||
let segments = path
|
||||
.trim_start_matches("/api/v1")
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
let decoded_segments = decode_path_segments(path);
|
||||
let segments = decoded_segments
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
if segments.is_empty() {
|
||||
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
||||
}
|
||||
match segments.as_slice() {
|
||||
["healthz"] => {
|
||||
let latest_ready_run = db.latest_ready_run()?;
|
||||
data_response(
|
||||
&json!({
|
||||
"status": "ok",
|
||||
"service": "rpki_query_service",
|
||||
"version": 1,
|
||||
"latestReadyRun": latest_ready_run,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
|
||||
["latest_run"] => {
|
||||
let run_id = db
|
||||
@@ -585,13 +628,30 @@ fn route_request(
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "repos"] => {
|
||||
["runs", raw_run_id, "vrps", "lookup"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let lookup = vrp_lookup_from_query(&query)?;
|
||||
let asn = query
|
||||
.get("asn")
|
||||
.map(|value| {
|
||||
value.parse::<u32>().map_err(|_| {
|
||||
ApiError::new(400, format!("invalid ASN query parameter: {value}"))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
page_response(
|
||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||
db.lookup_vrps(&run_id, &lookup, asn, limit(&query), cursor(&query))?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "repos"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let page = match repo_sort_from_query(&query)? {
|
||||
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||
};
|
||||
page_response(page, Some(run_id))
|
||||
}
|
||||
["runs", raw_run_id, "repos", repo_id] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let repo = db
|
||||
@@ -630,8 +690,15 @@ fn route_request(
|
||||
}
|
||||
["runs", raw_run_id, "repos", repo_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects_for_repo(&run_id, repo_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::Repo(repo_id.to_string()),
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
@@ -669,18 +736,63 @@ fn route_request(
|
||||
}
|
||||
["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects_for_pp(&run_id, pp_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::PublicationPoint(pp_id.to_string()),
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects(&run_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::All,
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "issues"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let mut filter = object_filter_from_query(&query)?;
|
||||
filter.issues_only = true;
|
||||
filter.repo_id = query.get("repoId").cloned();
|
||||
filter.pp_id = query.get("ppId").cloned();
|
||||
page_response(
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::All,
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "search"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let q = query
|
||||
.get("q")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if q.is_empty() {
|
||||
return Err(ApiError::new(400, "q query parameter is required"));
|
||||
}
|
||||
let limit = search_limit(&query);
|
||||
data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id))
|
||||
}
|
||||
["runs", raw_run_id, "objects", "by-uri"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let uri = query
|
||||
@@ -792,6 +904,18 @@ fn route_request(
|
||||
offset_cursor(&query),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "exports"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let limit = exports_limit(&query);
|
||||
page_response(
|
||||
rpki::query_db::QueryPage {
|
||||
data: list_export_jobs(export_jobs, &run_id, limit)?,
|
||||
next_cursor: None,
|
||||
limit,
|
||||
},
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "exports", job_id] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let job = get_export_job(db, export_jobs, &run_id, job_id)?
|
||||
@@ -813,10 +937,11 @@ fn route_request(
|
||||
}
|
||||
["runs", raw_run_id, "stats", "repos"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
page_response(
|
||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||
Some(run_id),
|
||||
)
|
||||
let page = match repo_sort_from_query(&query)? {
|
||||
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||
};
|
||||
page_response(page, Some(run_id))
|
||||
}
|
||||
["runs", raw_run_id, "stats", "publication-points"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
@@ -889,11 +1014,10 @@ fn route_post_request(
|
||||
) -> Result<Value, ApiError> {
|
||||
let (path, _) = split_target(target);
|
||||
let path = path.trim_end_matches('/');
|
||||
let segments = path
|
||||
.trim_start_matches("/api/v1")
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
let decoded_segments = decode_path_segments(path);
|
||||
let segments = decoded_segments
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
match segments.as_slice() {
|
||||
["runs", raw_run_id, "exports"] => {
|
||||
@@ -1046,11 +1170,10 @@ fn route_raw_request(
|
||||
) -> Option<Result<Vec<u8>, ApiError>> {
|
||||
let (path, _) = split_target(target);
|
||||
let path = path.trim_end_matches('/');
|
||||
let segments = path
|
||||
.trim_start_matches("/api/v1")
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
let decoded_segments = decode_path_segments(path);
|
||||
let segments = decoded_segments
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
match segments.as_slice() {
|
||||
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
|
||||
@@ -1111,7 +1234,20 @@ fn export_download_response(
|
||||
.as_ref()
|
||||
.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()))?;
|
||||
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 {
|
||||
@@ -1150,6 +1286,24 @@ fn get_export_job(
|
||||
Ok(db.get_export_job(run_id, job_id)?)
|
||||
}
|
||||
|
||||
fn list_export_jobs(
|
||||
export_jobs: &ExportJobStore,
|
||||
run_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExportJobRecord>, ApiError> {
|
||||
let jobs = export_jobs
|
||||
.lock()
|
||||
.map_err(|_| ApiError::new(500, "export job store lock poisoned"))?;
|
||||
let mut out = jobs
|
||||
.values()
|
||||
.filter(|job| job.run_id == run_id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
out.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
out.truncate(limit);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_export_job(
|
||||
db: &QueryDb,
|
||||
repo_bytes: &ExternalRepoBytesDb,
|
||||
@@ -1575,6 +1729,103 @@ fn projection_for_explain(
|
||||
Ok(Some(projection))
|
||||
}
|
||||
|
||||
fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result<Value, ApiError> {
|
||||
let objects = search_run_objects(db, run_id, q, limit)?;
|
||||
let lower = q.to_lowercase();
|
||||
let repos = db.search_repos(run_id, &lower, limit)?;
|
||||
let publication_points = db.search_publication_points(run_id, &lower, limit)?;
|
||||
Ok(json!({
|
||||
"objects": objects,
|
||||
"repos": repos,
|
||||
"publicationPoints": publication_points,
|
||||
}))
|
||||
}
|
||||
|
||||
fn search_run_objects(
|
||||
db: &QueryDb,
|
||||
run_id: &str,
|
||||
q: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ObjectInstanceRecord>, ApiError> {
|
||||
if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") {
|
||||
let Some(index) = db.get_object_by_uri(run_id, q)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? {
|
||||
Some(full) => full,
|
||||
None => object_instance_from_uri_index(&index),
|
||||
};
|
||||
return Ok(vec![object]);
|
||||
}
|
||||
let lower = q.to_lowercase();
|
||||
if is_sha256_hex(q) {
|
||||
if let Some(projection) = db.get_object_projection(&lower)? {
|
||||
if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
|
||||
return Ok(vec![full]);
|
||||
}
|
||||
return Ok(vec![object_instance_from_projection(run_id, &projection)]);
|
||||
}
|
||||
if let Some(object) = db.get_object_by_sha256(run_id, &lower)? {
|
||||
return Ok(vec![object]);
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
if is_hex {
|
||||
let mut objects = Vec::new();
|
||||
for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? {
|
||||
let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
|
||||
Some(full) => full,
|
||||
None => object_instance_from_projection(run_id, &projection),
|
||||
};
|
||||
objects.push(object);
|
||||
}
|
||||
return Ok(objects);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord {
|
||||
ObjectInstanceRecord {
|
||||
schema_version: 1,
|
||||
run_id: index.run_id.clone(),
|
||||
object_instance_id: index.object_instance_id.clone(),
|
||||
uri: index.uri.clone(),
|
||||
uri_hash: String::new(),
|
||||
sha256: index.sha256.clone(),
|
||||
object_type: String::new(),
|
||||
result: String::new(),
|
||||
detail_summary: None,
|
||||
repo_id: index.repo_id.clone(),
|
||||
pp_id: index.pp_id.clone(),
|
||||
source_section: "uri_index".to_string(),
|
||||
rejected: false,
|
||||
reject_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_instance_from_projection(
|
||||
run_id: &str,
|
||||
projection: &rpki::object_projection::ObjectProjectionRecord,
|
||||
) -> ObjectInstanceRecord {
|
||||
ObjectInstanceRecord {
|
||||
schema_version: 1,
|
||||
run_id: run_id.to_string(),
|
||||
object_instance_id: String::new(),
|
||||
uri: String::new(),
|
||||
uri_hash: String::new(),
|
||||
sha256: projection.sha256.clone(),
|
||||
object_type: projection.object_type.clone(),
|
||||
result: projection.parse_status.clone(),
|
||||
detail_summary: projection.error_summary.clone(),
|
||||
repo_id: String::new(),
|
||||
pp_id: String::new(),
|
||||
source_section: "hash_index".to_string(),
|
||||
rejected: false,
|
||||
reject_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn global_projection_for_hash(
|
||||
db: &QueryDb,
|
||||
repo_bytes: Option<&ExternalRepoBytesDb>,
|
||||
@@ -1870,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 {
|
||||
query
|
||||
.get("limit")
|
||||
@@ -1878,6 +2141,105 @@ fn limit(query: &BTreeMap<String, String>) -> usize {
|
||||
.clamp(1, 1000)
|
||||
}
|
||||
|
||||
fn search_limit(query: &BTreeMap<String, String>) -> usize {
|
||||
query
|
||||
.get("limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 50)
|
||||
}
|
||||
|
||||
fn exports_limit(query: &BTreeMap<String, String>) -> usize {
|
||||
query
|
||||
.get("limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(50)
|
||||
.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> {
|
||||
let mut filter = ObjectFilter::default();
|
||||
if let Some(value) = query.get("type") {
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::new(400, "type filter must not be empty"));
|
||||
}
|
||||
// Accept projection-vocabulary aliases so both `mft` and `manifest`
|
||||
// (etc.) match the URI-derived report stream types.
|
||||
let lower = value.to_ascii_lowercase();
|
||||
let normalized = match lower.as_str() {
|
||||
"mft" => "manifest",
|
||||
"cer" => "certificate",
|
||||
"asa" => "aspa",
|
||||
_ => lower.as_str(),
|
||||
};
|
||||
filter.object_type = Some(normalized.to_string());
|
||||
}
|
||||
if let Some(value) = query.get("result") {
|
||||
if !matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"ok" | "error" | "skipped"
|
||||
) {
|
||||
return Err(ApiError::new(
|
||||
400,
|
||||
format!("invalid result filter: {value}"),
|
||||
));
|
||||
}
|
||||
filter.result = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = query.get("rejected") {
|
||||
filter.rejected = Some(match value.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => {
|
||||
return Err(ApiError::new(
|
||||
400,
|
||||
format!("invalid rejected filter: {value}"),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(value) = query.get("reason") {
|
||||
filter.reason = Some(value.to_lowercase());
|
||||
}
|
||||
if let Some(value) = query.get("q") {
|
||||
filter.query = Some(value.to_lowercase());
|
||||
}
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
fn vrp_lookup_from_query(query: &BTreeMap<String, String>) -> Result<VrpLookup, ApiError> {
|
||||
let ip = query.get("ip");
|
||||
let prefix = query.get("prefix");
|
||||
match (ip, prefix) {
|
||||
(Some(_), Some(_)) => Err(ApiError::new(
|
||||
400,
|
||||
"ip and prefix query parameters are mutually exclusive",
|
||||
)),
|
||||
(None, None) => Err(ApiError::new(
|
||||
400,
|
||||
"one of ip or prefix query parameters is required",
|
||||
)),
|
||||
(Some(value), None) => {
|
||||
VrpLookup::parse_ip(value).map_err(|error| ApiError::new(400, error))
|
||||
}
|
||||
(None, Some(value)) => {
|
||||
VrpLookup::parse_prefix(value).map_err(|error| ApiError::new(400, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
|
||||
query.get("cursor").map(String::as_str)
|
||||
}
|
||||
@@ -1933,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> {
|
||||
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 {
|
||||
200 => "OK",
|
||||
404 => "Not Found",
|
||||
_ => "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!(
|
||||
"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()
|
||||
)
|
||||
.into_bytes();
|
||||
@@ -1947,6 +2322,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
||||
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 {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
@@ -2223,6 +2607,28 @@ mod tests {
|
||||
.as_u64(),
|
||||
Some(1)
|
||||
);
|
||||
let vrp_ip = test_route_request(
|
||||
&db,
|
||||
Some(&repo_bytes),
|
||||
"/api/v1/runs/latest/vrps/lookup?ip=2001:da8::1",
|
||||
)
|
||||
.expect("VRP IP lookup");
|
||||
assert_eq!(vrp_ip["data"][0]["asn"].as_u64(), Some(4538));
|
||||
assert_eq!(vrp_ip["meta"]["runId"].as_str(), Some("run_0001"));
|
||||
let vrp_prefix = test_route_request(
|
||||
&db,
|
||||
Some(&repo_bytes),
|
||||
"/api/v1/runs/latest/vrps/lookup?prefix=2001%3Ada8%3A%3A%2F32&asn=4538",
|
||||
)
|
||||
.expect("VRP prefix lookup");
|
||||
assert_eq!(vrp_prefix["data"].as_array().unwrap().len(), 1);
|
||||
let invalid = test_route_request(
|
||||
&db,
|
||||
Some(&repo_bytes),
|
||||
"/api/v1/runs/latest/vrps/lookup?ip=192.0.2.1&prefix=192.0.2.0%2F24",
|
||||
)
|
||||
.expect_err("conflicting VRP lookup parameters");
|
||||
assert_eq!(invalid.status, 400);
|
||||
|
||||
for target in [
|
||||
"/api/v1/runs/latest/repos",
|
||||
@@ -2289,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]
|
||||
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -2476,6 +2970,31 @@ mod tests {
|
||||
assert_eq!(job.status, "complete");
|
||||
assert_eq!(job.object_count, 1);
|
||||
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]
|
||||
|
||||
171
src/bin/verification_ccr_compare.rs
Normal file
171
src/bin/verification_ccr_compare.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: verification_ccr_compare --source-ccr <path> --verification-ccr <path> --out-json <path> [--out-md <path>]"
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Args {
|
||||
source_ccr: PathBuf,
|
||||
verification_ccr: PathBuf,
|
||||
out_json: PathBuf,
|
||||
out_md: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
let mut source_ccr = None;
|
||||
let mut verification_ccr = None;
|
||||
let mut out_json = None;
|
||||
let mut out_md = None;
|
||||
let mut index = 1usize;
|
||||
while index < argv.len() {
|
||||
let target = match argv[index].as_str() {
|
||||
"--source-ccr" => &mut source_ccr,
|
||||
"--verification-ccr" => &mut verification_ccr,
|
||||
"--out-json" => &mut out_json,
|
||||
"--out-md" => &mut out_md,
|
||||
"-h" | "--help" => return Err(usage().to_string()),
|
||||
other => return Err(format!("unknown argument: {other}\n{}", usage())),
|
||||
};
|
||||
index += 1;
|
||||
*target =
|
||||
Some(PathBuf::from(argv.get(index).ok_or_else(|| {
|
||||
format!("{} requires a value", argv[index - 1])
|
||||
})?));
|
||||
index += 1;
|
||||
}
|
||||
let source_ccr = source_ccr.ok_or_else(|| format!("--source-ccr is required\n{}", usage()))?;
|
||||
let verification_ccr =
|
||||
verification_ccr.ok_or_else(|| format!("--verification-ccr is required\n{}", usage()))?;
|
||||
let out_json = out_json.ok_or_else(|| format!("--out-json is required\n{}", usage()))?;
|
||||
Ok(Args {
|
||||
source_ccr,
|
||||
verification_ccr,
|
||||
out_json,
|
||||
out_md,
|
||||
})
|
||||
}
|
||||
|
||||
fn run(args: &Args) -> Result<(), String> {
|
||||
let comparison =
|
||||
rpki::verification_only::compare_ccr_files(&args.source_ccr, &args.verification_ccr)?;
|
||||
rpki::verification_only::write_ccr_comparison(
|
||||
&args.out_json,
|
||||
args.out_md.as_deref(),
|
||||
&comparison,
|
||||
)?;
|
||||
if !comparison.state_digest_match {
|
||||
return Err(format!(
|
||||
"CCR state digest mismatch: {}",
|
||||
comparison.mismatched_states.join(",")
|
||||
));
|
||||
}
|
||||
println!("CCR state digests match: {}", args.out_json.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let argv = std::env::args().collect::<Vec<_>>();
|
||||
run(&parse_args(&argv)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rpki::ccr::{
|
||||
CcrContentInfo, CcrDigestAlgorithm, RpkiCanonicalCacheRepresentation,
|
||||
build_roa_payload_state, encode_content_info,
|
||||
};
|
||||
use rpki::data_model::roa::{IpPrefix, RoaAfi};
|
||||
use rpki::validation::objects::Vrp;
|
||||
use std::fs;
|
||||
|
||||
fn argv(values: &[&str]) -> Vec<String> {
|
||||
std::iter::once("verification_ccr_compare")
|
||||
.chain(values.iter().copied())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_ccr(asn: u32) -> Vec<u8> {
|
||||
let vrps = build_roa_payload_state(&[Vrp {
|
||||
asn,
|
||||
prefix: IpPrefix {
|
||||
afi: RoaAfi::Ipv4,
|
||||
prefix_len: 24,
|
||||
addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
max_length: 24,
|
||||
}])
|
||||
.expect("build VRP state");
|
||||
encode_content_info(&CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
|
||||
version: 0,
|
||||
hash_alg: CcrDigestAlgorithm::Sha256,
|
||||
produced_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
mfts: None,
|
||||
vrps: Some(vrps),
|
||||
vaps: None,
|
||||
tas: None,
|
||||
rks: None,
|
||||
}))
|
||||
.expect("encode CCR")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_accepts_complete_input_and_rejects_bad_forms() {
|
||||
let parsed = parse_args(&argv(&[
|
||||
"--source-ccr",
|
||||
"source.ccr",
|
||||
"--verification-ccr",
|
||||
"verification.ccr",
|
||||
"--out-json",
|
||||
"result.json",
|
||||
"--out-md",
|
||||
"result.md",
|
||||
]))
|
||||
.expect("parse");
|
||||
assert_eq!(parsed.source_ccr, PathBuf::from("source.ccr"));
|
||||
assert_eq!(parsed.out_md, Some(PathBuf::from("result.md")));
|
||||
assert!(parse_args(&argv(&[])).unwrap_err().contains("source-ccr"));
|
||||
assert!(
|
||||
parse_args(&argv(&["--source-ccr"]))
|
||||
.unwrap_err()
|
||||
.contains("requires a value")
|
||||
);
|
||||
assert!(
|
||||
parse_args(&argv(&["--unknown"]))
|
||||
.unwrap_err()
|
||||
.contains("unknown argument")
|
||||
);
|
||||
assert!(parse_args(&argv(&["--help"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_writes_matching_outputs_and_rejects_mismatch() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let source = temp.path().join("source.ccr");
|
||||
let verification = temp.path().join("verification.ccr");
|
||||
let out_json = temp.path().join("out/result.json");
|
||||
let out_md = temp.path().join("out/result.md");
|
||||
fs::write(&source, sample_ccr(64496)).expect("source");
|
||||
fs::write(&verification, sample_ccr(64496)).expect("verification");
|
||||
let args = Args {
|
||||
source_ccr: source.clone(),
|
||||
verification_ccr: verification.clone(),
|
||||
out_json: out_json.clone(),
|
||||
out_md: Some(out_md.clone()),
|
||||
};
|
||||
run(&args).expect("matching run");
|
||||
assert!(out_json.is_file());
|
||||
assert!(out_md.is_file());
|
||||
|
||||
fs::write(&verification, sample_ccr(64497)).expect("mismatch");
|
||||
let error = run(&args).unwrap_err();
|
||||
assert!(error.contains("vrps"));
|
||||
assert!(
|
||||
fs::read_to_string(out_json)
|
||||
.expect("comparison JSON")
|
||||
.contains("mismatchedStates")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ pub struct ExternalRawStoreDb {
|
||||
pub struct ExternalRepoBytesDb {
|
||||
path: PathBuf,
|
||||
db: Arc<DB>,
|
||||
read_only: bool,
|
||||
secondary: bool,
|
||||
}
|
||||
|
||||
impl ExternalRawStoreDb {
|
||||
@@ -88,6 +90,7 @@ impl ExternalRawStoreDb {
|
||||
let mut opts = Options::default();
|
||||
opts.create_if_missing(true);
|
||||
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()))?;
|
||||
Ok(Self {
|
||||
path,
|
||||
@@ -185,10 +188,13 @@ impl ExternalRepoBytesDb {
|
||||
let mut opts = Options::default();
|
||||
opts.create_if_missing(true);
|
||||
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()))?;
|
||||
Ok(Self {
|
||||
path,
|
||||
db: Arc::new(db),
|
||||
read_only: false,
|
||||
secondary: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,18 +202,70 @@ impl ExternalRepoBytesDb {
|
||||
let path = path.into();
|
||||
let mut opts = Options::default();
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
let db = DB::open_for_read_only(&opts, &path, false)
|
||||
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||
Ok(Self {
|
||||
path,
|
||||
db: Arc::new(db),
|
||||
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<()> {
|
||||
if blobs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.read_only {
|
||||
return Err(StorageError::RocksDb(format!(
|
||||
"repo-bytes DB is read-only: {}",
|
||||
self.path.display()
|
||||
)));
|
||||
}
|
||||
let mut batch = WriteBatch::default();
|
||||
for (sha256_hex, bytes) in blobs {
|
||||
validate_blob_sha256_hex(sha256_hex)?;
|
||||
@@ -221,6 +279,42 @@ impl ExternalRepoBytesDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_read_only(&self) -> bool {
|
||||
self.read_only
|
||||
}
|
||||
|
||||
pub(crate) fn require_existing_blob_bytes_batch(
|
||||
&self,
|
||||
blobs: &[(String, Vec<u8>)],
|
||||
) -> StorageResult<()> {
|
||||
if blobs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let hashes = blobs
|
||||
.iter()
|
||||
.map(|(sha256_hex, _)| sha256_hex.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let existing = self.get_blob_bytes_batch(&hashes)?;
|
||||
for ((sha256_hex, expected_bytes), actual_bytes) in blobs.iter().zip(existing) {
|
||||
match actual_bytes {
|
||||
Some(actual_bytes) if actual_bytes == *expected_bytes => {}
|
||||
Some(_) => {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "read_only_repo_bytes",
|
||||
detail: format!("existing bytes differ for SHA-256 {sha256_hex}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "read_only_repo_bytes",
|
||||
detail: format!("blob is missing for SHA-256 {sha256_hex}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
|
||||
validate_blob_sha256_hex(sha256_hex)?;
|
||||
let key = repo_bytes_key(sha256_hex);
|
||||
@@ -695,4 +789,48 @@ mod tests {
|
||||
);
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::ccr::build::{
|
||||
};
|
||||
use crate::ccr::encode::encode_manifest_state_payload_der;
|
||||
use crate::ccr::hash::compute_state_hash;
|
||||
use crate::ccr::manifest_location::select_manifest_signed_object_location_from_der;
|
||||
use crate::ccr::model::{
|
||||
CcrDigestAlgorithm, ManifestInstance, ManifestState, RpkiCanonicalCacheRepresentation,
|
||||
};
|
||||
@@ -54,7 +55,10 @@ impl CcrManifestContribution {
|
||||
aki: projection.manifest_ee_aki.clone(),
|
||||
manifest_number_be: projection.manifest_number_be.clone(),
|
||||
this_update,
|
||||
locations_der: projection.manifest_sia_locations_der.clone(),
|
||||
locations_der: vec![select_manifest_signed_object_location_from_der(
|
||||
&projection.manifest_rsync_uri,
|
||||
&projection.manifest_sia_locations_der,
|
||||
)?],
|
||||
subordinate_skis: projection.subordinate_skis.clone(),
|
||||
})
|
||||
}
|
||||
@@ -246,7 +250,8 @@ mod tests {
|
||||
use crate::ccr::export::build_ccr_from_run;
|
||||
use crate::ccr::verify::verify_content_info;
|
||||
use crate::data_model::manifest::ManifestObject;
|
||||
use crate::data_model::rc::SubjectInfoAccess;
|
||||
use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT};
|
||||
use crate::data_model::rc::{AccessDescription, SubjectInfoAccess};
|
||||
use crate::data_model::roa::{IpPrefix, RoaAfi};
|
||||
use crate::data_model::ta::TrustAnchor;
|
||||
use crate::data_model::tal::Tal;
|
||||
@@ -283,16 +288,35 @@ mod tests {
|
||||
)
|
||||
.expect("read manifest");
|
||||
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest");
|
||||
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
|
||||
.resource_cert
|
||||
.tbs
|
||||
.extensions
|
||||
.subject_info_access
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.find(|ad| {
|
||||
ad.access_method_oid == OID_AD_SIGNED_OBJECT
|
||||
&& ad.access_location.starts_with("rsync://")
|
||||
})
|
||||
.expect("manifest rsync signedObject")
|
||||
.access_location
|
||||
.clone(),
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
|
||||
};
|
||||
let manifest_hash = hex::encode(sha2::Sha256::digest(&manifest_der));
|
||||
let mut raw = RawByHashEntry::from_bytes(manifest_hash.clone(), manifest_der.clone());
|
||||
raw.origin_uris
|
||||
.push("rsync://example.test/repo/current.mft".to_string());
|
||||
raw.origin_uris.push(manifest_uri.clone());
|
||||
raw.object_type = Some("mft".to_string());
|
||||
raw.encoding = Some("der".to_string());
|
||||
store.put_raw_by_hash_entry(&raw).expect("put raw");
|
||||
|
||||
let projection = VcirCcrManifestProjection {
|
||||
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
|
||||
manifest_size: manifest_der.len() as u64,
|
||||
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
|
||||
@@ -312,23 +336,20 @@ mod tests {
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(|ad| {
|
||||
let oid = encode_oid_for_test(&ad.access_method_oid)
|
||||
.expect("encode access method oid");
|
||||
let uri = encode_tlv_for_test(0x86, ad.access_location.as_bytes().to_vec());
|
||||
encode_sequence_for_test(&[oid, uri])
|
||||
})
|
||||
.collect(),
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![
|
||||
crate::ccr::manifest_location::select_manifest_signed_object_location(
|
||||
&manifest_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)
|
||||
.expect("select manifest signedObject"),
|
||||
],
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
|
||||
},
|
||||
subordinate_skis: vec![vec![0x33; 20]],
|
||||
};
|
||||
|
||||
let vcir = ValidatedCaInstanceResult {
|
||||
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
parent_manifest_rsync_uri: None,
|
||||
tal_id: "apnic".to_string(),
|
||||
ca_subject_name: "CN=test".to_string(),
|
||||
@@ -337,8 +358,8 @@ mod tests {
|
||||
last_successful_validation_time: PackTime::from_utc_offset_datetime(
|
||||
manifest.manifest.this_update,
|
||||
),
|
||||
current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
|
||||
current_manifest_rsync_uri: manifest_uri.clone(),
|
||||
current_crl_rsync_uri: format!("{manifest_uri}.crl"),
|
||||
validated_manifest_meta: ValidatedManifestMeta {
|
||||
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
|
||||
validated_manifest_this_update: PackTime::from_utc_offset_datetime(
|
||||
@@ -381,10 +402,11 @@ mod tests {
|
||||
related_artifacts: vec![VcirRelatedArtifact {
|
||||
artifact_role: VcirArtifactRole::Manifest,
|
||||
artifact_kind: VcirArtifactKind::Mft,
|
||||
uri: Some("rsync://example.test/repo/current.mft".to_string()),
|
||||
uri: Some(manifest_uri.clone()),
|
||||
sha256: manifest_hash,
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
}],
|
||||
summary: VcirSummary {
|
||||
local_vrp_count: 0,
|
||||
@@ -429,55 +451,6 @@ mod tests {
|
||||
(vcir, vrps, aspas, router_keys)
|
||||
}
|
||||
|
||||
fn encode_oid_for_test(oid: &str) -> Result<Vec<u8>, String> {
|
||||
let arcs = oid
|
||||
.split('.')
|
||||
.map(|part| part.parse::<u64>().map_err(|_| format!("bad oid: {oid}")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if arcs.len() < 2 {
|
||||
return Err(format!("bad oid: {oid}"));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
body.push((arcs[0] * 40 + arcs[1]) as u8);
|
||||
for arc in &arcs[2..] {
|
||||
encode_base128_for_test(*arc, &mut body);
|
||||
}
|
||||
Ok(encode_tlv_for_test(0x06, body))
|
||||
}
|
||||
|
||||
fn encode_base128_for_test(mut value: u64, out: &mut Vec<u8>) {
|
||||
let mut tmp = vec![(value & 0x7F) as u8];
|
||||
value >>= 7;
|
||||
while value > 0 {
|
||||
tmp.push(((value & 0x7F) as u8) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
tmp.reverse();
|
||||
out.extend_from_slice(&tmp);
|
||||
}
|
||||
|
||||
fn encode_sequence_for_test(elements: &[Vec<u8>]) -> Vec<u8> {
|
||||
let total_len: usize = elements.iter().map(Vec::len).sum();
|
||||
let mut buf = Vec::with_capacity(total_len);
|
||||
for element in elements {
|
||||
buf.extend_from_slice(element);
|
||||
}
|
||||
encode_tlv_for_test(0x30, buf)
|
||||
}
|
||||
|
||||
fn encode_tlv_for_test(tag: u8, value: Vec<u8>) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(1 + 9 + value.len());
|
||||
out.push(tag);
|
||||
if value.len() < 0x80 {
|
||||
out.push(value.len() as u8);
|
||||
} else {
|
||||
out.push(0x81);
|
||||
out.push(value.len() as u8);
|
||||
}
|
||||
out.extend_from_slice(&value);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulator_finish_matches_builder_on_fresh_vcir_inputs() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
@@ -512,4 +485,32 @@ mod tests {
|
||||
let ci = crate::ccr::model::CcrContentInfo::new(accumulated_ccr);
|
||||
verify_content_info(&ci).expect("verify accumulated ccr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulator_sanitizes_historical_projection_with_rpki_notify() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let store = RocksStore::open(td.path()).expect("open rocksdb");
|
||||
let (mut vcir, _, _, _) = sample_vcir_and_manifest(&store);
|
||||
let notify_der =
|
||||
crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
|
||||
access_method_oid: OID_AD_RPKI_NOTIFY.to_string(),
|
||||
access_location: "https://rrdp.example.test/notification.xml".to_string(),
|
||||
})
|
||||
.expect("encode rpkiNotify");
|
||||
let expected_location = vcir.ccr_manifest_projection.manifest_sia_locations_der[0].clone();
|
||||
vcir.ccr_manifest_projection
|
||||
.manifest_sia_locations_der
|
||||
.push(notify_der);
|
||||
|
||||
let mut accumulator = CcrAccumulator::new(Vec::new());
|
||||
accumulator
|
||||
.append_manifest_projection(&vcir.ccr_manifest_projection)
|
||||
.expect("sanitize historical projection");
|
||||
let contribution = accumulator
|
||||
.manifests_by_hash
|
||||
.values()
|
||||
.next()
|
||||
.expect("manifest contribution");
|
||||
assert_eq!(contribution.locations_der, vec![expected_location]);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/ccr/build.rs
122
src/ccr/build.rs
@@ -9,12 +9,13 @@ use crate::ccr::encode::{
|
||||
encode_trust_anchor_state_payload_der,
|
||||
};
|
||||
use crate::ccr::hash::compute_state_hash;
|
||||
use crate::ccr::manifest_location::select_manifest_signed_object_location;
|
||||
use crate::ccr::model::{
|
||||
AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet,
|
||||
RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState,
|
||||
};
|
||||
use crate::data_model::manifest::ManifestObject;
|
||||
use crate::data_model::rc::{AccessDescription, SubjectInfoAccess};
|
||||
use crate::data_model::rc::SubjectInfoAccess;
|
||||
use crate::data_model::roa::RoaAfi;
|
||||
use crate::data_model::router_cert::BgpsecRouterCertificate;
|
||||
use crate::data_model::ta::TrustAnchor;
|
||||
@@ -68,12 +69,17 @@ pub enum CcrBuildError {
|
||||
#[error("manifest EE certificate SIA is not the EE form: {0}")]
|
||||
ManifestEeSiaWrongVariant(String),
|
||||
|
||||
#[error(
|
||||
"manifest EE certificate SIA location selection failed for {manifest_rsync_uri}: {detail}"
|
||||
)]
|
||||
ManifestSiaLocationSelection {
|
||||
manifest_rsync_uri: String,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
#[error("manifest child SKI is not valid lowercase hex or not 20 bytes: {0}")]
|
||||
InvalidChildSki(String),
|
||||
|
||||
#[error("manifest AccessDescription contains unsupported accessMethod OID: {0}")]
|
||||
UnsupportedAccessMethodOid(String),
|
||||
|
||||
#[error("manifest state encoding failed: {0}")]
|
||||
ManifestEncode(String),
|
||||
|
||||
@@ -262,11 +268,16 @@ pub fn build_manifest_state_from_vcirs_with_breakdown(
|
||||
CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone())
|
||||
})?;
|
||||
let locations = match sia {
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(encode_access_description_der)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![
|
||||
select_manifest_signed_object_location(
|
||||
&vcir.current_manifest_rsync_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)
|
||||
.map_err(|detail| CcrBuildError::ManifestSiaLocationSelection {
|
||||
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
|
||||
detail,
|
||||
})?,
|
||||
],
|
||||
SubjectInfoAccess::Ca(_) => {
|
||||
return Err(CcrBuildError::ManifestEeSiaWrongVariant(
|
||||
vcir.current_manifest_rsync_uri.clone(),
|
||||
@@ -353,45 +364,6 @@ fn collect_subordinate_ski_bytes(
|
||||
Ok(skis.into_iter().collect())
|
||||
}
|
||||
|
||||
fn encode_access_description_der(ad: &AccessDescription) -> Result<Vec<u8>, CcrBuildError> {
|
||||
let oid = encode_oid_from_string(&ad.access_method_oid)?;
|
||||
let uri = encode_tlv(0x86, ad.access_location.as_bytes().to_vec());
|
||||
Ok(encode_sequence(&[oid, uri]))
|
||||
}
|
||||
|
||||
fn encode_oid_from_string(oid: &str) -> Result<Vec<u8>, CcrBuildError> {
|
||||
let arcs = oid
|
||||
.split('.')
|
||||
.map(|part| {
|
||||
part.parse::<u64>()
|
||||
.map_err(|_| CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if arcs.len() < 2 {
|
||||
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
|
||||
}
|
||||
if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
|
||||
return Err(CcrBuildError::UnsupportedAccessMethodOid(oid.to_string()));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
body.push((arcs[0] * 40 + arcs[1]) as u8);
|
||||
for arc in &arcs[2..] {
|
||||
encode_base128(*arc, &mut body);
|
||||
}
|
||||
Ok(encode_tlv(0x06, body))
|
||||
}
|
||||
|
||||
fn encode_base128(mut value: u64, out: &mut Vec<u8>) {
|
||||
let mut tmp = vec![(value & 0x7F) as u8];
|
||||
value >>= 7;
|
||||
while value > 0 {
|
||||
tmp.push(((value & 0x7F) as u8) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
tmp.reverse();
|
||||
out.extend_from_slice(&tmp);
|
||||
}
|
||||
|
||||
pub fn build_router_key_state(
|
||||
router_certs: &[BgpsecRouterCertificate],
|
||||
) -> Result<RouterKeyState, CcrBuildError> {
|
||||
@@ -650,17 +622,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_manifest_vcir(
|
||||
manifest_uri: &str,
|
||||
manifest_der: &[u8],
|
||||
child_ski_hex: &str,
|
||||
) -> ValidatedCaInstanceResult {
|
||||
fn sample_manifest_vcir(manifest_der: &[u8], child_ski_hex: &str) -> ValidatedCaInstanceResult {
|
||||
let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture");
|
||||
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
|
||||
.resource_cert
|
||||
.tbs
|
||||
.extensions
|
||||
.subject_info_access
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.find(|ad| {
|
||||
ad.access_method_oid == crate::data_model::oid::OID_AD_SIGNED_OBJECT
|
||||
&& ad.access_location.starts_with("rsync://")
|
||||
})
|
||||
.expect("manifest rsync signedObject")
|
||||
.access_location
|
||||
.clone(),
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
|
||||
};
|
||||
let hash = hex::encode(sha2::Sha256::digest(manifest_der));
|
||||
let now = manifest.manifest.this_update;
|
||||
let next = manifest.manifest.next_update;
|
||||
let projection = crate::storage::VcirCcrManifestProjection {
|
||||
manifest_rsync_uri: manifest_uri.to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
manifest_sha256: sha2::Sha256::digest(manifest_der).to_vec(),
|
||||
manifest_size: manifest_der.len() as u64,
|
||||
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
|
||||
@@ -680,18 +668,19 @@ mod tests {
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(encode_access_description_der)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("encode access descriptions"),
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![
|
||||
select_manifest_signed_object_location(
|
||||
&manifest_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)
|
||||
.expect("select manifest signedObject"),
|
||||
],
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
|
||||
},
|
||||
subordinate_skis: vec![hex::decode(child_ski_hex).expect("decode child ski")],
|
||||
};
|
||||
crate::storage::ValidatedCaInstanceResult {
|
||||
manifest_rsync_uri: manifest_uri.to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
parent_manifest_rsync_uri: None,
|
||||
tal_id: "test-tal".to_string(),
|
||||
ca_subject_name: "CN=test".to_string(),
|
||||
@@ -700,7 +689,7 @@ mod tests {
|
||||
last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime(
|
||||
now,
|
||||
),
|
||||
current_manifest_rsync_uri: manifest_uri.to_string(),
|
||||
current_manifest_rsync_uri: manifest_uri.clone(),
|
||||
current_crl_rsync_uri: format!("{manifest_uri}.crl"),
|
||||
validated_manifest_meta: crate::storage::ValidatedManifestMeta {
|
||||
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
|
||||
@@ -736,10 +725,11 @@ mod tests {
|
||||
related_artifacts: vec![crate::storage::VcirRelatedArtifact {
|
||||
artifact_role: crate::storage::VcirArtifactRole::Manifest,
|
||||
artifact_kind: crate::storage::VcirArtifactKind::Mft,
|
||||
uri: Some(manifest_uri.to_string()),
|
||||
uri: Some(manifest_uri.clone()),
|
||||
sha256: hash,
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
}],
|
||||
summary: crate::storage::VcirSummary {
|
||||
local_vrp_count: 0,
|
||||
@@ -766,10 +756,8 @@ mod tests {
|
||||
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
|
||||
))
|
||||
.expect("read manifest b");
|
||||
let vcir_a =
|
||||
sample_manifest_vcir("rsync://example.test/a.mft", &manifest_a, &"33".repeat(20));
|
||||
let vcir_b =
|
||||
sample_manifest_vcir("rsync://example.test/b.mft", &manifest_b, &"44".repeat(20));
|
||||
let vcir_a = sample_manifest_vcir(&manifest_a, &"33".repeat(20));
|
||||
let vcir_b = sample_manifest_vcir(&manifest_b, &"44".repeat(20));
|
||||
let store_dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
|
||||
for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] {
|
||||
|
||||
@@ -471,3 +471,71 @@ fn decode_big_unsigned(bytes: &[u8]) -> Result<BigUnsigned, CcrDecodeError> {
|
||||
};
|
||||
Ok(BigUnsigned { bytes_be })
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "full"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ccr::encode::{encode_content_info, encode_manifest_state_payload_der};
|
||||
use crate::ccr::hash::compute_state_hash;
|
||||
use crate::ccr::manifest_location::encode_access_description_der;
|
||||
use crate::ccr::model::{
|
||||
CcrContentInfo, CcrDigestAlgorithm, ManifestState, RpkiCanonicalCacheRepresentation,
|
||||
};
|
||||
use crate::data_model::oid::{OID_AD_RPKI_NOTIFY, OID_AD_SIGNED_OBJECT};
|
||||
use crate::data_model::rc::AccessDescription;
|
||||
|
||||
#[test]
|
||||
fn generic_decoder_preserves_multiple_manifest_locations() {
|
||||
let signed_object = encode_access_description_der(&AccessDescription {
|
||||
access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
|
||||
access_location: "rsync://example.test/repository/current.mft".to_string(),
|
||||
})
|
||||
.expect("encode signedObject");
|
||||
let rpki_notify = encode_access_description_der(&AccessDescription {
|
||||
access_method_oid: OID_AD_RPKI_NOTIFY.to_string(),
|
||||
access_location: "https://rrdp.example.test/notification.xml".to_string(),
|
||||
})
|
||||
.expect("encode rpkiNotify");
|
||||
let manifest = ManifestInstance {
|
||||
hash: vec![0x11; 32],
|
||||
size: 1024,
|
||||
aki: vec![0x22; 20],
|
||||
manifest_number: BigUnsigned { bytes_be: vec![1] },
|
||||
this_update: time::OffsetDateTime::parse(
|
||||
"2026-07-20T00:00:00Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.expect("parse time"),
|
||||
locations: vec![signed_object, rpki_notify],
|
||||
subordinates: Vec::new(),
|
||||
};
|
||||
let manifest_payload = encode_manifest_state_payload_der(&[manifest.clone()])
|
||||
.expect("encode manifest payload");
|
||||
let content = CcrContentInfo::new(RpkiCanonicalCacheRepresentation {
|
||||
version: 0,
|
||||
hash_alg: CcrDigestAlgorithm::Sha256,
|
||||
produced_at: time::OffsetDateTime::parse(
|
||||
"2026-07-20T00:00:00Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.expect("parse time"),
|
||||
mfts: Some(ManifestState {
|
||||
mis: vec![manifest],
|
||||
most_recent_update: time::OffsetDateTime::parse(
|
||||
"2026-07-20T00:00:00Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.expect("parse time"),
|
||||
hash: compute_state_hash(&manifest_payload),
|
||||
}),
|
||||
vrps: None,
|
||||
vaps: None,
|
||||
tas: None,
|
||||
rks: None,
|
||||
});
|
||||
|
||||
let encoded = encode_content_info(&content).expect("encode CCR");
|
||||
let decoded = decode_content_info(&encoded).expect("decode CCR");
|
||||
assert_eq!(decoded.content.mfts.unwrap().mis[0].locations.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::ccr::decode::decode_content_info;
|
||||
use crate::data_model::manifest::ManifestObject;
|
||||
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
|
||||
use crate::data_model::rc::SubjectInfoAccess;
|
||||
use crate::data_model::roa::{IpPrefix, RoaAfi};
|
||||
use crate::data_model::ta::TrustAnchor;
|
||||
use crate::data_model::tal::Tal;
|
||||
@@ -149,15 +151,34 @@ mod tests {
|
||||
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let manifest_der = std::fs::read(base.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft")).expect("read manifest");
|
||||
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode manifest");
|
||||
let manifest_uri = match manifest.signed_object.signed_data.certificates[0]
|
||||
.resource_cert
|
||||
.tbs
|
||||
.extensions
|
||||
.subject_info_access
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.find(|ad| {
|
||||
ad.access_method_oid == OID_AD_SIGNED_OBJECT
|
||||
&& ad.access_location.starts_with("rsync://")
|
||||
})
|
||||
.expect("manifest rsync signedObject")
|
||||
.access_location
|
||||
.clone(),
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"),
|
||||
};
|
||||
let hash = hex::encode(sha2::Sha256::digest(&manifest_der));
|
||||
let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone());
|
||||
raw.origin_uris
|
||||
.push("rsync://example.test/repo/current.mft".to_string());
|
||||
raw.origin_uris.push(manifest_uri.clone());
|
||||
raw.object_type = Some("mft".to_string());
|
||||
raw.encoding = Some("der".to_string());
|
||||
store.put_raw_by_hash_entry(&raw).expect("put raw");
|
||||
let projection = VcirCcrManifestProjection {
|
||||
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
manifest_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
|
||||
manifest_size: manifest_der.len() as u64,
|
||||
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
|
||||
@@ -169,14 +190,27 @@ mod tests {
|
||||
.expect("manifest aki"),
|
||||
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
|
||||
manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update),
|
||||
manifest_sia_locations_der: vec![vec![
|
||||
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05,
|
||||
b'r', b's', b'y', b'n', b'c',
|
||||
]],
|
||||
manifest_sia_locations_der: match manifest.signed_object.signed_data.certificates[0]
|
||||
.resource_cert
|
||||
.tbs
|
||||
.extensions
|
||||
.subject_info_access
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![
|
||||
crate::ccr::manifest_location::select_manifest_signed_object_location(
|
||||
&manifest_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)
|
||||
.expect("select manifest signedObject"),
|
||||
],
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest EE SIA should not be CA variant"),
|
||||
},
|
||||
subordinate_skis: vec![vec![0x33; 20]],
|
||||
};
|
||||
ValidatedCaInstanceResult {
|
||||
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
parent_manifest_rsync_uri: None,
|
||||
tal_id: "apnic".to_string(),
|
||||
ca_subject_name: "CN=test".to_string(),
|
||||
@@ -185,7 +219,7 @@ mod tests {
|
||||
last_successful_validation_time: PackTime::from_utc_offset_datetime(
|
||||
manifest.manifest.this_update,
|
||||
),
|
||||
current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
current_manifest_rsync_uri: manifest_uri.clone(),
|
||||
current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
|
||||
validated_manifest_meta: ValidatedManifestMeta {
|
||||
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
|
||||
@@ -229,10 +263,11 @@ mod tests {
|
||||
related_artifacts: vec![VcirRelatedArtifact {
|
||||
artifact_role: VcirArtifactRole::Manifest,
|
||||
artifact_kind: VcirArtifactKind::Mft,
|
||||
uri: Some("rsync://example.test/repo/current.mft".to_string()),
|
||||
uri: Some(manifest_uri),
|
||||
sha256: hash,
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
}],
|
||||
summary: VcirSummary {
|
||||
local_vrp_count: 0,
|
||||
|
||||
424
src/ccr/manifest_location.rs
Normal file
424
src/ccr/manifest_location.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use crate::data_model::common::DerReader;
|
||||
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
|
||||
use crate::data_model::rc::AccessDescription;
|
||||
|
||||
pub(crate) fn select_manifest_signed_object_location(
|
||||
manifest_rsync_uri: &str,
|
||||
access_descriptions: &[AccessDescription],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let matching = access_descriptions
|
||||
.iter()
|
||||
.filter(|access_description| {
|
||||
access_description.access_method_oid == OID_AD_SIGNED_OBJECT
|
||||
&& access_description.access_location == manifest_rsync_uri
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let access_description = expect_single_matching_location(
|
||||
manifest_rsync_uri,
|
||||
matching.len(),
|
||||
"parsed Manifest EE SIA",
|
||||
)?;
|
||||
encode_access_description_der(matching[access_description])
|
||||
}
|
||||
|
||||
pub(crate) fn select_manifest_signed_object_location_from_der(
|
||||
manifest_rsync_uri: &str,
|
||||
locations_der: &[Vec<u8>],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let matching = locations_der
|
||||
.iter()
|
||||
.filter_map(
|
||||
|location_der| match decode_access_description_der(location_der) {
|
||||
Ok(access_description)
|
||||
if access_description.access_method_oid == OID_AD_SIGNED_OBJECT
|
||||
&& access_description.access_location == manifest_rsync_uri =>
|
||||
{
|
||||
Some(Ok(location_der.clone()))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(detail) => Some(Err(detail)),
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let index = expect_single_matching_location(
|
||||
manifest_rsync_uri,
|
||||
matching.len(),
|
||||
"persisted VCIR CCR projection",
|
||||
)?;
|
||||
Ok(matching[index].clone())
|
||||
}
|
||||
|
||||
pub(crate) fn encode_access_description_der(
|
||||
access_description: &AccessDescription,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let oid = encode_oid_der(&access_description.access_method_oid)?;
|
||||
let uri = encode_tlv(0x86, access_description.access_location.as_bytes().to_vec());
|
||||
Ok(encode_sequence(&[oid, uri]))
|
||||
}
|
||||
|
||||
fn expect_single_matching_location(
|
||||
manifest_rsync_uri: &str,
|
||||
matching_count: usize,
|
||||
source: &str,
|
||||
) -> Result<usize, String> {
|
||||
if matching_count == 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
Err(format!(
|
||||
"{source} contains {matching_count} id-ad-signedObject locations matching manifest URI {manifest_rsync_uri}; expected exactly one"
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_access_description_der(der: &[u8]) -> Result<AccessDescription, String> {
|
||||
let mut top = DerReader::new(der);
|
||||
let mut sequence = top.take_sequence()?;
|
||||
if !top.is_empty() {
|
||||
return Err("trailing bytes after AccessDescription".to_string());
|
||||
}
|
||||
let access_method_oid = decode_oid_der(sequence.take_tag(0x06)?)?;
|
||||
let access_location = std::str::from_utf8(sequence.take_tag(0x86)?)
|
||||
.map_err(|error| format!("AccessDescription URI is not UTF-8: {error}"))?
|
||||
.to_string();
|
||||
if !sequence.is_empty() {
|
||||
return Err("trailing fields in AccessDescription".to_string());
|
||||
}
|
||||
Ok(AccessDescription {
|
||||
access_method_oid,
|
||||
access_location,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_oid_der(value: &[u8]) -> Result<String, String> {
|
||||
let mut offset = 0usize;
|
||||
let first = decode_base128(value, &mut offset)?;
|
||||
let (first_arc, second_arc) = match first {
|
||||
0..=39 => (0, first),
|
||||
40..=79 => (1, first - 40),
|
||||
value => (2, value - 80),
|
||||
};
|
||||
let mut arcs = vec![first_arc, second_arc];
|
||||
while offset < value.len() {
|
||||
arcs.push(decode_base128(value, &mut offset)?);
|
||||
}
|
||||
Ok(arcs
|
||||
.into_iter()
|
||||
.map(|arc| arc.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("."))
|
||||
}
|
||||
|
||||
fn decode_base128(value: &[u8], offset: &mut usize) -> Result<u64, String> {
|
||||
let first = *value
|
||||
.get(*offset)
|
||||
.ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?;
|
||||
if first == 0x80 {
|
||||
return Err("non-minimal OBJECT IDENTIFIER base-128 encoding".to_string());
|
||||
}
|
||||
let mut out = 0u64;
|
||||
loop {
|
||||
let byte = *value
|
||||
.get(*offset)
|
||||
.ok_or_else(|| "truncated OBJECT IDENTIFIER".to_string())?;
|
||||
*offset += 1;
|
||||
out = out
|
||||
.checked_shl(7)
|
||||
.ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())?
|
||||
.checked_add((byte & 0x7f) as u64)
|
||||
.ok_or_else(|| "OBJECT IDENTIFIER arc overflows u64".to_string())?;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_oid_der(oid: &str) -> Result<Vec<u8>, String> {
|
||||
let arcs = oid
|
||||
.split('.')
|
||||
.map(|part| {
|
||||
part.parse::<u64>()
|
||||
.map_err(|_| format!("unsupported accessMethod OID: {oid}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if arcs.len() < 2 || arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
|
||||
return Err(format!("unsupported accessMethod OID: {oid}"));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
encode_base128(
|
||||
arcs[0]
|
||||
.checked_mul(40)
|
||||
.and_then(|value| value.checked_add(arcs[1]))
|
||||
.ok_or_else(|| format!("unsupported accessMethod OID: {oid}"))?,
|
||||
&mut body,
|
||||
);
|
||||
for arc in &arcs[2..] {
|
||||
encode_base128(*arc, &mut body);
|
||||
}
|
||||
Ok(encode_tlv(0x06, body))
|
||||
}
|
||||
|
||||
fn encode_base128(mut value: u64, out: &mut Vec<u8>) {
|
||||
let mut encoded = vec![(value & 0x7f) as u8];
|
||||
value >>= 7;
|
||||
while value > 0 {
|
||||
encoded.push(((value & 0x7f) as u8) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
encoded.reverse();
|
||||
out.extend_from_slice(&encoded);
|
||||
}
|
||||
|
||||
fn encode_sequence(elements: &[Vec<u8>]) -> Vec<u8> {
|
||||
let total_len = elements.iter().map(Vec::len).sum();
|
||||
let mut value = Vec::with_capacity(total_len);
|
||||
for element in elements {
|
||||
value.extend_from_slice(element);
|
||||
}
|
||||
encode_tlv(0x30, value)
|
||||
}
|
||||
|
||||
fn encode_tlv(tag: u8, value: Vec<u8>) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(1 + 9 + value.len());
|
||||
out.push(tag);
|
||||
encode_length(value.len(), &mut out);
|
||||
out.extend_from_slice(&value);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_length(len: usize, out: &mut Vec<u8>) {
|
||||
if len < 0x80 {
|
||||
out.push(len as u8);
|
||||
return;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let mut value = len;
|
||||
while value > 0 {
|
||||
bytes.push((value & 0xff) as u8);
|
||||
value >>= 8;
|
||||
}
|
||||
bytes.reverse();
|
||||
out.push(0x80 | bytes.len() as u8);
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_model::oid::OID_AD_RPKI_NOTIFY;
|
||||
|
||||
const MANIFEST_URI: &str = "rsync://example.test/repo/manifest.mft";
|
||||
|
||||
fn access_description(access_method_oid: &str, access_location: &str) -> AccessDescription {
|
||||
AccessDescription {
|
||||
access_method_oid: access_method_oid.to_string(),
|
||||
access_location: access_location.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_matching_signed_object_and_excludes_rpki_notify() {
|
||||
let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
|
||||
let selected = select_manifest_signed_object_location(
|
||||
MANIFEST_URI,
|
||||
&[
|
||||
signed_object.clone(),
|
||||
access_description(
|
||||
OID_AD_RPKI_NOTIFY,
|
||||
"https://rrdp.example.test/notification.xml",
|
||||
),
|
||||
],
|
||||
)
|
||||
.expect("select signed object");
|
||||
|
||||
assert_eq!(
|
||||
selected,
|
||||
encode_access_description_der(&signed_object).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_only_the_signed_object_matching_manifest_uri() {
|
||||
let expected = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
|
||||
let selected = select_manifest_signed_object_location(
|
||||
MANIFEST_URI,
|
||||
&[
|
||||
access_description(
|
||||
OID_AD_SIGNED_OBJECT,
|
||||
"https://backup.example.test/manifest.mft",
|
||||
),
|
||||
expected.clone(),
|
||||
],
|
||||
)
|
||||
.expect("select matching signed object");
|
||||
|
||||
assert_eq!(selected, encode_access_description_der(&expected).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_duplicate_matching_signed_object() {
|
||||
let missing = select_manifest_signed_object_location(
|
||||
MANIFEST_URI,
|
||||
&[access_description(
|
||||
OID_AD_RPKI_NOTIFY,
|
||||
"https://rrdp.example.test/notification.xml",
|
||||
)],
|
||||
)
|
||||
.expect_err("missing signed object must fail");
|
||||
assert!(missing.contains("contains 0"), "{missing}");
|
||||
|
||||
let duplicate = select_manifest_signed_object_location(
|
||||
MANIFEST_URI,
|
||||
&[
|
||||
access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI),
|
||||
access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI),
|
||||
],
|
||||
)
|
||||
.expect_err("duplicate signed object must fail");
|
||||
assert!(duplicate.contains("contains 2"), "{duplicate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_matching_signed_object_from_historical_projection() {
|
||||
let signed_object = access_description(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
|
||||
let signed_object_der = encode_access_description_der(&signed_object).unwrap();
|
||||
let notify_der = encode_access_description_der(&access_description(
|
||||
OID_AD_RPKI_NOTIFY,
|
||||
"https://rrdp.example.test/notification.xml",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let selected = select_manifest_signed_object_location_from_der(
|
||||
MANIFEST_URI,
|
||||
&[notify_der, signed_object_der.clone()],
|
||||
)
|
||||
.expect("select historical signed object");
|
||||
assert_eq!(selected, signed_object_der);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_historical_projection() {
|
||||
let error = select_manifest_signed_object_location_from_der(
|
||||
MANIFEST_URI,
|
||||
&[vec![0x30, 0x01, 0x06]],
|
||||
)
|
||||
.expect_err("malformed historical projection must fail");
|
||||
assert!(error.contains("truncated DER"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_description_der_codec_covers_long_and_invalid_forms() {
|
||||
let long = access_description(
|
||||
"2.999.200.1",
|
||||
&format!("rsync://example.test/repo/{}", "x".repeat(160)),
|
||||
);
|
||||
let encoded = encode_access_description_der(&long).expect("encode long access description");
|
||||
assert_eq!(
|
||||
decode_access_description_der(&encoded).expect("decode long access description"),
|
||||
long
|
||||
);
|
||||
|
||||
let mut trailing = encoded.clone();
|
||||
trailing.push(0);
|
||||
assert!(
|
||||
decode_access_description_der(&trailing)
|
||||
.expect_err("trailing bytes must fail")
|
||||
.contains("trailing bytes")
|
||||
);
|
||||
|
||||
let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID");
|
||||
let uri = encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec());
|
||||
assert!(
|
||||
decode_access_description_der(&encode_sequence(&[
|
||||
oid.clone(),
|
||||
uri.clone(),
|
||||
encode_tlv(0x05, Vec::new()),
|
||||
]))
|
||||
.expect_err("trailing field must fail")
|
||||
.contains("trailing fields")
|
||||
);
|
||||
assert!(
|
||||
decode_access_description_der(&encode_sequence(&[oid, encode_tlv(0x86, vec![0xff]),]))
|
||||
.expect_err("non-UTF8 URI must fail")
|
||||
.contains("not UTF-8")
|
||||
);
|
||||
assert!(
|
||||
decode_access_description_der(&encode_sequence(&[
|
||||
encode_tlv(0x06, vec![0x80, 0x00]),
|
||||
uri.clone(),
|
||||
]))
|
||||
.expect_err("non-minimal OID must fail")
|
||||
.contains("non-minimal")
|
||||
);
|
||||
assert!(
|
||||
decode_access_description_der(&encode_sequence(&[encode_tlv(0x06, vec![0x81]), uri,]))
|
||||
.expect_err("truncated OID must fail")
|
||||
.contains("truncated OBJECT IDENTIFIER")
|
||||
);
|
||||
assert!(
|
||||
encode_access_description_der(&access_description("3.1", MANIFEST_URI))
|
||||
.expect_err("invalid OID must fail")
|
||||
.contains("unsupported accessMethod OID")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_description_der_codec_reports_malformed_selector_inputs() {
|
||||
let malformed_sequence = decode_access_description_der(&[0x31, 0x00])
|
||||
.expect_err("non-sequence AccessDescription must fail");
|
||||
assert!(!malformed_sequence.is_empty());
|
||||
|
||||
let missing_method = decode_access_description_der(&encode_sequence(&[encode_tlv(
|
||||
0x86,
|
||||
MANIFEST_URI.as_bytes().to_vec(),
|
||||
)]))
|
||||
.expect_err("missing accessMethod must fail");
|
||||
assert!(!missing_method.is_empty());
|
||||
|
||||
let oid = encode_oid_der(OID_AD_SIGNED_OBJECT).expect("encode signedObject OID");
|
||||
let missing_location = decode_access_description_der(&encode_sequence(&[oid.clone()]))
|
||||
.expect_err("missing accessLocation must fail");
|
||||
assert!(!missing_location.is_empty());
|
||||
|
||||
let wrong_location_tag = decode_access_description_der(&encode_sequence(&[
|
||||
oid.clone(),
|
||||
encode_tlv(0x04, MANIFEST_URI.as_bytes().to_vec()),
|
||||
]))
|
||||
.expect_err("wrong accessLocation tag must fail");
|
||||
assert!(!wrong_location_tag.is_empty());
|
||||
|
||||
let empty_oid = decode_access_description_der(&encode_sequence(&[
|
||||
encode_tlv(0x06, Vec::new()),
|
||||
encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()),
|
||||
]))
|
||||
.expect_err("empty OID must fail");
|
||||
assert!(!empty_oid.is_empty());
|
||||
|
||||
let first_arc_zero = decode_access_description_der(&encode_sequence(&[
|
||||
encode_tlv(0x06, vec![0x01, 0x02]),
|
||||
encode_tlv(0x86, MANIFEST_URI.as_bytes().to_vec()),
|
||||
]))
|
||||
.expect("decode first OID arc zero");
|
||||
assert_eq!(first_arc_zero.access_method_oid, "0.1.2");
|
||||
assert_eq!(first_arc_zero.access_location, MANIFEST_URI);
|
||||
|
||||
let non_numeric =
|
||||
encode_access_description_der(&access_description("no.such.oid", MANIFEST_URI))
|
||||
.expect_err("non-numeric OID must fail");
|
||||
assert!(!non_numeric.is_empty());
|
||||
|
||||
let too_short = encode_access_description_der(&access_description("1", MANIFEST_URI))
|
||||
.expect_err("OID without second arc must fail");
|
||||
assert!(!too_short.is_empty());
|
||||
|
||||
let invalid_second_arc =
|
||||
encode_access_description_der(&access_description("1.40", MANIFEST_URI))
|
||||
.expect_err("invalid second OID arc must fail");
|
||||
assert!(!invalid_second_arc.is_empty());
|
||||
|
||||
let overflowing_first_subidentifier = encode_access_description_der(&access_description(
|
||||
"2.18446744073709551615",
|
||||
MANIFEST_URI,
|
||||
))
|
||||
.expect_err("overflowing first OID subidentifier must fail");
|
||||
assert!(!overflowing_first_subidentifier.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub mod encode;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod export;
|
||||
pub mod hash;
|
||||
#[cfg(feature = "full")]
|
||||
pub(crate) mod manifest_location;
|
||||
pub mod model;
|
||||
pub mod state_digest;
|
||||
#[cfg(feature = "full")]
|
||||
|
||||
@@ -645,6 +645,7 @@ mod tests {
|
||||
sha256: "ff".repeat(32),
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
}],
|
||||
summary: VcirSummary {
|
||||
local_vrp_count: 0,
|
||||
|
||||
@@ -140,6 +140,26 @@ impl CirInputAccumulator {
|
||||
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 {
|
||||
CirInputSnapshot {
|
||||
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.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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
768
src/cli.rs
768
src/cli.rs
File diff suppressed because it is too large
Load Diff
263
src/cli/tests.rs
263
src/cli/tests.rs
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
|
||||
use crate::data_model::rc::AccessDescription;
|
||||
use crate::memory_telemetry::{
|
||||
MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot,
|
||||
};
|
||||
@@ -21,6 +23,9 @@ fn parse_help_returns_usage() {
|
||||
assert!(err.contains("--memory-trim-after-validation"), "{err}");
|
||||
assert!(err.contains("--enable-roa-validation-cache"), "{err}");
|
||||
assert!(err.contains("--resource-validation-mode"), "{err}");
|
||||
assert!(err.contains("--ta-constraints"), "{err}");
|
||||
assert!(err.contains("--max-ca-depth"), "{err}");
|
||||
assert!(err.contains("default: 32"), "{err}");
|
||||
assert!(
|
||||
err.contains("--publication-point-cache-observe-only"),
|
||||
"{err}"
|
||||
@@ -33,6 +38,26 @@ fn parse_help_returns_usage() {
|
||||
assert!(!err.contains("--parallel-phase2 "), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_explicit_ta_constraints_for_known_tal() {
|
||||
let dir = tempfile::tempdir().expect("tmpdir");
|
||||
let constraints_path = dir.path().join("example.constraints");
|
||||
std::fs::write(&constraints_path, "allow 192.0.2.0/24\n").expect("write constraints");
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--db".to_string(),
|
||||
"db".to_string(),
|
||||
"--tal-path".to_string(),
|
||||
"example.tal".to_string(),
|
||||
"--ta-path".to_string(),
|
||||
"example.cer".to_string(),
|
||||
"--ta-constraints".to_string(),
|
||||
format!("example={}", constraints_path.display()),
|
||||
];
|
||||
let args = parse_args(&argv).expect("parse args");
|
||||
assert!(!args.ta_constraints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unknown_argument() {
|
||||
let argv = vec![
|
||||
@@ -47,6 +72,131 @@ fn parse_rejects_unknown_argument() {
|
||||
assert!(err.contains("unknown argument"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_verification_only_without_manual_validation_time() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--verification-only".to_string(),
|
||||
"--verification-source-run".to_string(),
|
||||
"/run/run_0002".to_string(),
|
||||
"--verification-source-state".to_string(),
|
||||
"/run/state".to_string(),
|
||||
"--verification-out".to_string(),
|
||||
"/verification/check_0002".to_string(),
|
||||
];
|
||||
let args = parse_args(&argv).expect("parse verification-only args");
|
||||
assert!(args.verification_only);
|
||||
assert!(args.validation_time.is_none());
|
||||
assert_eq!(
|
||||
args.db_path,
|
||||
PathBuf::from("/verification/check_0002/scratch/work-db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_manual_validation_time_in_verification_only() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--verification-only".to_string(),
|
||||
"--verification-source-run".to_string(),
|
||||
"/run/run_0002".to_string(),
|
||||
"--verification-source-state".to_string(),
|
||||
"/run/state".to_string(),
|
||||
"--verification-out".to_string(),
|
||||
"/verification/check_0002".to_string(),
|
||||
"--validation-time".to_string(),
|
||||
"2026-07-16T00:00:00Z".to_string(),
|
||||
];
|
||||
let error = parse_args(&argv).expect_err("manual time must be rejected");
|
||||
assert!(error.contains("validation time"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_normal_validation_contract_output() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--db".to_string(),
|
||||
"db".to_string(),
|
||||
"--tal-url".to_string(),
|
||||
"https://example.test/root.tal".to_string(),
|
||||
"--validation-contract-out".to_string(),
|
||||
"out/validation-contract.json".to_string(),
|
||||
];
|
||||
let args = parse_args(&argv).expect("parse normal contract output");
|
||||
assert_eq!(
|
||||
args.validation_contract_out_path,
|
||||
Some(PathBuf::from("out/validation-contract.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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()
|
||||
},
|
||||
ta_constraints_fingerprint: None,
|
||||
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]
|
||||
fn parse_rejects_both_tal_url_and_tal_path() {
|
||||
let argv = vec![
|
||||
@@ -80,6 +230,51 @@ fn parse_rejects_invalid_max_depth() {
|
||||
assert!(err.contains("invalid --max-depth"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_invalid_max_ca_depth() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--db".to_string(),
|
||||
"db".to_string(),
|
||||
"--tal-url".to_string(),
|
||||
"https://example.test/x.tal".to_string(),
|
||||
"--max-ca-depth".to_string(),
|
||||
"nope".to_string(),
|
||||
];
|
||||
let err = parse_args(&argv).unwrap_err();
|
||||
assert!(err.contains("invalid --max-ca-depth"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_both_ca_depth_options() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--db".to_string(),
|
||||
"db".to_string(),
|
||||
"--tal-url".to_string(),
|
||||
"https://example.test/x.tal".to_string(),
|
||||
"--max-ca-depth".to_string(),
|
||||
"32".to_string(),
|
||||
"--max-depth".to_string(),
|
||||
"12".to_string(),
|
||||
];
|
||||
let err = parse_args(&argv).unwrap_err();
|
||||
assert!(err.contains("cannot be combined"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_defaults_max_ca_depth_to_32() {
|
||||
let argv = vec![
|
||||
"rpki".to_string(),
|
||||
"--db".to_string(),
|
||||
"db".to_string(),
|
||||
"--tal-url".to_string(),
|
||||
"https://example.test/x.tal".to_string(),
|
||||
];
|
||||
let args = parse_args(&argv).expect("parse");
|
||||
assert_eq!(args.max_ca_depth, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_ccr_out_path() {
|
||||
let argv = vec![
|
||||
@@ -183,6 +378,56 @@ fn parse_accepts_publication_point_cache_flags() {
|
||||
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]
|
||||
fn parse_accepts_enable_transport_request_prefetch() {
|
||||
let argv = vec![
|
||||
@@ -1103,7 +1348,7 @@ fn parse_accepts_offline_mode_requires_ta() {
|
||||
assert_eq!(args.ta_paths, vec![PathBuf::from("ta.cer")]);
|
||||
assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal")));
|
||||
assert_eq!(args.ta_path.as_deref(), Some(Path::new("ta.cer")));
|
||||
assert_eq!(args.max_depth, Some(0));
|
||||
assert_eq!(args.max_ca_depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1618,8 +1863,9 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator {
|
||||
)
|
||||
.expect("discover root");
|
||||
let mut accumulator = CcrAccumulator::new(vec![discovery.trust_anchor.clone()]);
|
||||
let manifest_uri = "rsync://example.test/repo/current.mft".to_string();
|
||||
let projection = crate::storage::VcirCcrManifestProjection {
|
||||
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
|
||||
manifest_rsync_uri: manifest_uri.clone(),
|
||||
manifest_sha256: vec![0x44; 32],
|
||||
manifest_size: 2048,
|
||||
manifest_ee_aki: vec![0x55; 20],
|
||||
@@ -1627,10 +1873,13 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator {
|
||||
manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime(
|
||||
time::OffsetDateTime::now_utc(),
|
||||
),
|
||||
manifest_sia_locations_der: vec![vec![
|
||||
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05,
|
||||
b'r', b's', b'y', b'n', b'c',
|
||||
]],
|
||||
manifest_sia_locations_der: vec![
|
||||
crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
|
||||
access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
|
||||
access_location: manifest_uri,
|
||||
})
|
||||
.expect("encode signedObject"),
|
||||
],
|
||||
subordinate_skis: vec![vec![0x33; 20]],
|
||||
};
|
||||
accumulator
|
||||
@@ -1697,6 +1946,7 @@ fn run_report_task_and_stage_timing_work() {
|
||||
enable_child_certificate_validation_cache: false,
|
||||
publication_point_cache_observe_only: false,
|
||||
enable_publication_point_validation_cache: false,
|
||||
crypto_signature_cache_observe: None,
|
||||
enable_transport_request_prefetch: false,
|
||||
report_build_ms: report_output.report_build_ms,
|
||||
report_write_ms: report_output.report_write_ms,
|
||||
@@ -1803,6 +2053,7 @@ fn stage_timing_serializes_memory_telemetry() {
|
||||
enable_child_certificate_validation_cache: true,
|
||||
publication_point_cache_observe_only: false,
|
||||
enable_publication_point_validation_cache: false,
|
||||
crypto_signature_cache_observe: None,
|
||||
enable_transport_request_prefetch: true,
|
||||
report_build_ms: 2,
|
||||
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::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::storage::{RepositoryViewEntry, RepositoryViewState};
|
||||
|
||||
@@ -25,7 +25,12 @@ pub struct CurrentRepoIndex {
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
@@ -33,7 +38,7 @@ impl CurrentRepoIndex {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -205,7 +210,7 @@ mod tests {
|
||||
#[test]
|
||||
fn current_repo_index_snapshot_objects_and_counts_are_sorted() {
|
||||
let handle = CurrentRepoIndex::shared();
|
||||
let mut index = handle.lock().expect("lock index");
|
||||
let mut index = handle.write().expect("write-lock index");
|
||||
index
|
||||
.apply_repository_view_entries(&[
|
||||
present(
|
||||
|
||||
@@ -285,8 +285,16 @@ impl RpkixCrl {
|
||||
if !rem.is_empty() {
|
||||
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
|
||||
}
|
||||
crl.verify_signature(issuer_spki)
|
||||
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
|
||||
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)
|
||||
.map_err(|e| CrlVerifyError::InvalidSignature(e.to_string()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify the cryptographic signature on this CRL using a DER-encoded SubjectPublicKeyInfo.
|
||||
|
||||
@@ -330,6 +330,16 @@ impl RpkiSignedObject {
|
||||
parse_signed_object_content_info(der, der, CmsParseMode::DerStrict)
|
||||
}
|
||||
|
||||
/// Return the strict-DER CMS parse error for an object that was otherwise
|
||||
/// accepted through the normal BER-compatible CMS parser.
|
||||
///
|
||||
/// Callers must only surface this as a compatibility warning after normal
|
||||
/// decoding and validation have succeeded; a strict parse failure alone
|
||||
/// does not prove that an arbitrary byte string is an RPKI signed object.
|
||||
pub fn strict_cms_der_error(der: &[u8]) -> Option<SignedObjectParseError> {
|
||||
Self::parse_der_strict_cms(der).err()
|
||||
}
|
||||
|
||||
/// Decode a DER-encoded RPKI Signed Object (CMS ContentInfo wrapping SignedData) and enforce
|
||||
/// the profile constraints from RFC 6488 §2-§3 and RFC 9589 §4.
|
||||
pub fn decode_der(der: &[u8]) -> Result<Self, SignedObjectDecodeError> {
|
||||
@@ -358,7 +368,19 @@ impl RpkiSignedObject {
|
||||
/// Verify the CMS signature using the embedded EE certificate public key.
|
||||
pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> {
|
||||
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.
|
||||
@@ -394,7 +416,14 @@ impl RpkiSignedObject {
|
||||
_ => 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(
|
||||
@@ -1522,6 +1551,10 @@ mod tests {
|
||||
let err = RpkiSignedObject::parse_der_strict_cms(&mutated)
|
||||
.expect_err("DER strict rejects constructed OCTET STRING");
|
||||
assert!(err.to_string().contains("DER"), "{err}");
|
||||
let compatibility_error = RpkiSignedObject::strict_cms_der_error(&mutated)
|
||||
.expect("BER-compatible fixture must report strict-DER incompatibility");
|
||||
assert_eq!(compatibility_error.to_string(), err.to_string());
|
||||
assert!(RpkiSignedObject::strict_cms_der_error(&der).is_none());
|
||||
}
|
||||
|
||||
fn replace_first_subslice(input: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
|
||||
|
||||
234
src/fetch/current_repository.rs
Normal file
234
src/fetch/current_repository.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
|
||||
use crate::fetch::rsync_system::{
|
||||
RsyncScopePolicy, scoped_rsync_failure_dedup_key, scoped_rsync_fetch_uri,
|
||||
};
|
||||
use crate::storage::{RepositoryViewState, RocksStore};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurrentRepositoryViewRsyncFetcher {
|
||||
store: Arc<RocksStore>,
|
||||
scope_policy: RsyncScopePolicy,
|
||||
}
|
||||
|
||||
impl CurrentRepositoryViewRsyncFetcher {
|
||||
pub fn new(store: Arc<RocksStore>, scope_policy: RsyncScopePolicy) -> Self {
|
||||
Self {
|
||||
store,
|
||||
scope_policy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
|
||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
||||
// 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
|
||||
.store
|
||||
.list_repository_view_entries_with_prefix(&base)
|
||||
.map_err(|error| {
|
||||
RsyncFetchError::Fetch(format!(
|
||||
"list frozen repository view failed for {base}: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut objects = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
if !matches!(
|
||||
entry.state,
|
||||
RepositoryViewState::Present | RepositoryViewState::Replaced
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let bytes = self
|
||||
.store
|
||||
.load_current_object_bytes_by_uri(&entry.rsync_uri)
|
||||
.map_err(|error| {
|
||||
RsyncFetchError::Fetch(format!(
|
||||
"load frozen repository object failed for {}: {error}",
|
||||
entry.rsync_uri
|
||||
))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
RsyncFetchError::Fetch(format!(
|
||||
"frozen repository object missing for {}",
|
||||
entry.rsync_uri
|
||||
))
|
||||
})?;
|
||||
objects.push((entry.rsync_uri, bytes));
|
||||
}
|
||||
objects.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if objects.is_empty() {
|
||||
return Err(RsyncFetchError::Fetch(format!(
|
||||
"frozen repository view contains no current objects under {base}"
|
||||
)));
|
||||
}
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
fn fetch_object(&self, rsync_uri: &str) -> RsyncFetchResult<Vec<u8>> {
|
||||
self.store
|
||||
.load_current_object_bytes_by_uri(rsync_uri)
|
||||
.map_err(|error| {
|
||||
RsyncFetchError::Fetch(format!(
|
||||
"load frozen repository object failed for {rsync_uri}: {error}"
|
||||
))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
RsyncFetchError::Fetch(format!("frozen repository object not found: {rsync_uri}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn dedup_key(&self, rsync_base_uri: &str) -> String {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::{RepositoryViewEntry, RocksStore};
|
||||
|
||||
#[test]
|
||||
fn current_repository_fetcher_returns_sorted_present_objects() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
|
||||
let entries = [
|
||||
("rsync://example.test/repo/b.roa", b"b".as_slice()),
|
||||
("rsync://example.test/repo/a.mft", b"a".as_slice()),
|
||||
];
|
||||
for (uri, bytes) in entries {
|
||||
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("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
current_hash: Some(hash),
|
||||
})
|
||||
.expect("put view");
|
||||
}
|
||||
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
|
||||
let objects = fetcher
|
||||
.fetch_objects("rsync://example.test/repo/")
|
||||
.expect("fetch objects");
|
||||
assert_eq!(
|
||||
objects
|
||||
.iter()
|
||||
.map(|(uri, _)| uri.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"rsync://example.test/repo/a.mft",
|
||||
"rsync://example.test/repo/b.roa"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
fetcher
|
||||
.fetch_object("rsync://example.test/repo/a.mft")
|
||||
.expect("fetch one"),
|
||||
b"a"
|
||||
);
|
||||
assert!(
|
||||
fetcher
|
||||
.fetch_object("rsync://example.test/repo/missing.roa")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
fetcher.dedup_key("rsync://example.test/repo"),
|
||||
"rsync://example.test/repo/"
|
||||
);
|
||||
assert!(
|
||||
fetcher
|
||||
.fetch_objects("rsync://empty.example/repo/")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no current objects")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_repository_fetcher_ignores_withdrawn_and_reports_missing_blob() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = Arc::new(RocksStore::open(dir.path()).expect("open store"));
|
||||
store
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/withdrawn.roa".to_string(),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Withdrawn,
|
||||
current_hash: None,
|
||||
})
|
||||
.expect("put withdrawn view");
|
||||
let missing_hash = "ab".repeat(32);
|
||||
store
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
current_hash: Some(missing_hash),
|
||||
})
|
||||
.expect("put missing view");
|
||||
let fetcher = CurrentRepositoryViewRsyncFetcher::new(store, RsyncScopePolicy::default());
|
||||
let error = fetcher
|
||||
.fetch_objects("rsync://example.test/repo/")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
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)]
|
||||
pub struct HttpFetcherConfig {
|
||||
/// Connection-establishment timeout for HTTP requests.
|
||||
@@ -39,7 +61,7 @@ impl Default for HttpFetcherConfig {
|
||||
connect_timeout: Duration::from_secs(15),
|
||||
timeout: Duration::from_secs(30),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -84,7 +106,7 @@ impl BlockingHttpFetcher {
|
||||
&config,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(1),
|
||||
"rpki-dev/0.1 (stage2)".to_string(),
|
||||
config.user_agent.clone(),
|
||||
)?
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -532,4 +554,99 @@ mod tests {
|
||||
assert_eq!(profile_large, "large_body");
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod current_repository;
|
||||
pub mod http;
|
||||
pub mod rsync;
|
||||
pub mod rsync_system;
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::fetch::rsync::{
|
||||
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 {
|
||||
Host,
|
||||
PublicationPoint,
|
||||
@@ -297,13 +298,7 @@ impl SystemRsyncFetcher {
|
||||
}
|
||||
|
||||
fn scope_fetch_uri(&self, rsync_base_uri: &str) -> String {
|
||||
match self.config.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)),
|
||||
}
|
||||
scoped_rsync_fetch_uri(self.config.scope_policy, rsync_base_uri)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,10 +377,35 @@ impl RsyncFetcher for SystemRsyncFetcher {
|
||||
}
|
||||
|
||||
fn failure_dedup_key(&self, rsync_base_uri: &str) -> Option<String> {
|
||||
match self.config.scope_policy {
|
||||
RsyncScopePolicy::Host => rsync_host_scope_uri(rsync_base_uri),
|
||||
RsyncScopePolicy::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
|
||||
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::PublicationPoint | RsyncScopePolicy::ModuleRoot => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ccr;
|
||||
pub mod cir;
|
||||
pub mod crypto_sig_cache;
|
||||
pub mod data_model;
|
||||
|
||||
#[cfg(feature = "full")]
|
||||
@@ -39,6 +40,10 @@ pub mod storage;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod sync;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod ta_constraints;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod tools;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod validation;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod verification_only;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklistConfig;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ParallelPhase1Config {
|
||||
pub max_repo_sync_workers_global: usize,
|
||||
pub max_inflight_snapshot_bytes_global: 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)]
|
||||
@@ -15,6 +20,9 @@ pub struct ParallelPhase2Config {
|
||||
pub publication_point_finalize_batch_size: usize,
|
||||
pub publication_point_finalize_wall_time_budget_ms: u64,
|
||||
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 {
|
||||
@@ -28,6 +36,7 @@ impl Default for ParallelPhase2Config {
|
||||
publication_point_finalize_batch_size: 256,
|
||||
publication_point_finalize_wall_time_budget_ms: 100,
|
||||
publication_point_finalize_queue_capacity: 32768,
|
||||
stage_workers: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +47,7 @@ impl Default for ParallelPhase1Config {
|
||||
max_repo_sync_workers_global: 4,
|
||||
max_inflight_snapshot_bytes_global: 512 * 1024 * 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 dead_repo_blacklist;
|
||||
pub mod object_worker;
|
||||
pub mod phase2_scheduler;
|
||||
pub mod repo_runtime;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::thread::{self, JoinHandle, Scope};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -22,7 +26,7 @@ pub struct ObjectWorkerPool<T, R, E>
|
||||
where
|
||||
T: Send + 'static,
|
||||
R: Send + 'static,
|
||||
E: ObjectTaskExecutor<T, R>,
|
||||
E: ObjectTaskExecutor<T, R> + 'static,
|
||||
{
|
||||
task_txs: Vec<SyncSender<ObjectWorkerMessage<T>>>,
|
||||
result_rx: Receiver<R>,
|
||||
@@ -35,7 +39,7 @@ impl<T, R, E> ObjectWorkerPool<T, R, E>
|
||||
where
|
||||
T: 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> {
|
||||
if worker_count == 0 {
|
||||
@@ -140,7 +144,7 @@ impl<T, R, E> Drop for ObjectWorkerPool<T, R, E>
|
||||
where
|
||||
T: Send + 'static,
|
||||
R: Send + 'static,
|
||||
E: ObjectTaskExecutor<T, R>,
|
||||
E: ObjectTaskExecutor<T, R> + 'static,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_inner();
|
||||
@@ -153,8 +157,8 @@ fn object_worker_loop<T, R, E>(
|
||||
result_tx: mpsc::Sender<R>,
|
||||
executor: Arc<E>,
|
||||
) where
|
||||
T: Send + 'static,
|
||||
R: Send + 'static,
|
||||
T: Send,
|
||||
R: Send,
|
||||
E: ObjectTaskExecutor<T, R>,
|
||||
{
|
||||
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)]
|
||||
mod tests {
|
||||
use super::{ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError};
|
||||
@@ -284,4 +402,84 @@ mod tests {
|
||||
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>;
|
||||
|
||||
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> {
|
||||
@@ -634,6 +645,18 @@ impl<E: RepoTransportExecutor> RepoSyncRuntime for Phase1RepoSyncRuntime<E> {
|
||||
})
|
||||
.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(
|
||||
@@ -666,7 +689,9 @@ fn outcome_from_transport_result(
|
||||
}
|
||||
}
|
||||
(
|
||||
RepoTransportResultKind::Failed { detail, warnings },
|
||||
RepoTransportResultKind::Failed {
|
||||
detail, warnings, ..
|
||||
},
|
||||
RepoRuntimeState::FailedTerminal,
|
||||
) => RepoSyncRuntimeOutcome {
|
||||
repo_sync_ok: false,
|
||||
@@ -798,6 +823,7 @@ mod tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rrdp failed".to_string(),
|
||||
warnings: vec![Warning::new("rrdp failed")],
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -842,6 +868,7 @@ mod tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rrdp failed".to_string(),
|
||||
warnings: vec![Warning::new("rrdp failed")],
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -858,6 +885,7 @@ mod tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rsync failed".to_string(),
|
||||
warnings: vec![Warning::new("rsync failed")],
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
|
||||
use crate::parallel::types::{
|
||||
InFlightRepoEntry, RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoRuntimeState,
|
||||
RepoSyncResultEnvelope, RepoSyncResultKind, RepoSyncResultRef, RepoSyncTask, RepoTaskState,
|
||||
RepoTransportMode, RepoTransportResultEnvelope, RepoTransportResultKind, RepoTransportTask,
|
||||
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
|
||||
RepoTransportResultKind, RepoTransportTask,
|
||||
};
|
||||
use crate::policy::SyncPreference;
|
||||
use crate::report::Warning;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RepoRequestAction {
|
||||
@@ -74,6 +77,9 @@ pub struct TransportStateTables {
|
||||
rsync_failure_probe_inflight: HashMap<String, String>,
|
||||
rsync_failure_scope_reachable: HashSet<String>,
|
||||
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 {
|
||||
@@ -81,6 +87,44 @@ impl TransportStateTables {
|
||||
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> {
|
||||
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 let Some(notification_uri) = identity.notification_uri.clone() {
|
||||
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_probe_inflight: &mut HashMap<String, String>,
|
||||
rsync_failure_scope_reachable: &HashSet<String>,
|
||||
dead_repo_blacklist: Option<&DeadRepoBlacklist>,
|
||||
follow_up_tasks: &mut Vec<RepoTransportTask>,
|
||||
) {
|
||||
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
|
||||
.requesters
|
||||
.first()
|
||||
@@ -666,6 +815,7 @@ impl TransportStateTables {
|
||||
&self.rsync_failure_by_scope,
|
||||
&mut self.rsync_failure_probe_inflight,
|
||||
&self.rsync_failure_scope_reachable,
|
||||
self.dead_repo_blacklist.as_ref(),
|
||||
&mut follow_up_tasks,
|
||||
);
|
||||
}
|
||||
@@ -718,6 +868,7 @@ impl TransportStateTables {
|
||||
&self.rsync_failure_by_scope,
|
||||
&mut self.rsync_failure_probe_inflight,
|
||||
&self.rsync_failure_scope_reachable,
|
||||
self.dead_repo_blacklist.as_ref(),
|
||||
&mut follow_up_tasks,
|
||||
);
|
||||
}
|
||||
@@ -783,6 +934,7 @@ impl TransportStateTables {
|
||||
&self.rsync_failure_by_scope,
|
||||
&mut self.rsync_failure_probe_inflight,
|
||||
&self.rsync_failure_scope_reachable,
|
||||
self.dead_repo_blacklist.as_ref(),
|
||||
&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();
|
||||
lower.contains("timeout waiting for daemon connection")
|
||||
|| lower.contains("failed to connect")
|
||||
@@ -1462,6 +1614,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rrdp timeout".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -1567,6 +1720,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rrdp timeout".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -1597,6 +1751,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rsync error: timeout waiting for daemon connection".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -1659,6 +1814,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rsync timeout".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -1920,6 +2076,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rsync file digest mismatch after download".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -1972,6 +2129,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "rsync error: failed to connect to daemon".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -2031,6 +2189,7 @@ mod transport_tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "temporary failure in name resolution".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
},
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
|
||||
@@ -115,19 +115,24 @@ impl<H: Fetcher + 'static> RepoTransportExecutor for LiveRrdpTransportExecutor<H
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
},
|
||||
Err(err) => RepoTransportResultEnvelope {
|
||||
dedup_key: task.dedup_key,
|
||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||
repo_identity: task.repo_identity,
|
||||
mode: RepoTransportMode::Rrdp,
|
||||
tal_id: task.tal_id,
|
||||
rir_id: task.rir_id,
|
||||
timing_ms: started.elapsed().as_millis() as u64,
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: err.to_string(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
},
|
||||
Err(err) => {
|
||||
let error_class =
|
||||
crate::parallel::dead_repo_blacklist::classify_rrdp_sync_error(&err);
|
||||
RepoTransportResultEnvelope {
|
||||
dedup_key: task.dedup_key,
|
||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||
repo_identity: task.repo_identity,
|
||||
mode: RepoTransportMode::Rrdp,
|
||||
tal_id: task.tal_id,
|
||||
rir_id: task.rir_id,
|
||||
timing_ms: started.elapsed().as_millis() as u64,
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: err.to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,19 +211,24 @@ impl<R: RsyncFetcher + 'static> RepoTransportExecutor for LiveRsyncTransportExec
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
},
|
||||
Err(err) => RepoTransportResultEnvelope {
|
||||
dedup_key: task.dedup_key,
|
||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||
repo_identity: task.repo_identity,
|
||||
mode: RepoTransportMode::Rsync,
|
||||
tal_id: task.tal_id,
|
||||
rir_id: task.rir_id,
|
||||
timing_ms: started.elapsed().as_millis() as u64,
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: err.to_string(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
},
|
||||
Err(err) => {
|
||||
let error_class =
|
||||
crate::parallel::dead_repo_blacklist::classify_rsync_repo_sync_error(&err);
|
||||
RepoTransportResultEnvelope {
|
||||
dedup_key: task.dedup_key,
|
||||
rsync_failure_scope_uri: task.rsync_failure_scope_uri,
|
||||
repo_identity: task.repo_identity,
|
||||
mode: RepoTransportMode::Rsync,
|
||||
tal_id: task.tal_id,
|
||||
rir_id: task.rir_id,
|
||||
timing_ms: started.elapsed().as_millis() as u64,
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: err.to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1160,7 +1170,7 @@ mod tests {
|
||||
matches!(result.result, RepoTransportResultKind::Success { .. }),
|
||||
"{result:?}"
|
||||
);
|
||||
let index = current_repo_index.lock().expect("index lock");
|
||||
let index = current_repo_index.read().expect("index read lock");
|
||||
assert!(
|
||||
index
|
||||
.get_by_uri("rsync://example.test/repo/a.roa")
|
||||
@@ -1217,7 +1227,7 @@ mod tests {
|
||||
result.result,
|
||||
RepoTransportResultKind::Success { .. }
|
||||
));
|
||||
let index = current_repo_index.lock().expect("index lock");
|
||||
let index = current_repo_index.read().expect("index read lock");
|
||||
assert!(
|
||||
index
|
||||
.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::parallel::config::ParallelPhase1Config;
|
||||
use crate::parallel::dead_repo_blacklist::{
|
||||
DeadRepoBlacklist, DeadRepoFailureOutcome, DeadRepoSuccessOutcome,
|
||||
};
|
||||
use crate::parallel::repo_scheduler::{
|
||||
InFlightRepoTable, RepoCompletion, RepoRequestAction, TransportCompletion,
|
||||
TransportRequestAction, TransportStateTables,
|
||||
@@ -9,7 +12,8 @@ use crate::parallel::repo_scheduler::{
|
||||
use crate::parallel::stats::ParallelRunStats;
|
||||
use crate::parallel::types::{
|
||||
RepoDedupKey, RepoIdentity, RepoKey, RepoRequester, RepoSyncResultEnvelope, RepoSyncTask,
|
||||
RepoTransportResultEnvelope, RepoTransportTask, TalInputSpec,
|
||||
RepoTransportErrorClass, RepoTransportMode, RepoTransportResultEnvelope,
|
||||
RepoTransportResultKind, RepoTransportTask, TalInputSpec,
|
||||
};
|
||||
use crate::policy::SyncPreference;
|
||||
|
||||
@@ -22,19 +26,42 @@ pub struct GlobalRunCoordinator {
|
||||
pub pending_repo_tasks: VecDeque<RepoSyncTask>,
|
||||
pub pending_transport_tasks: VecDeque<RepoTransportTask>,
|
||||
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 {
|
||||
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 {
|
||||
config,
|
||||
tal_inputs,
|
||||
current_repo_index: CurrentRepoIndex::shared(),
|
||||
in_flight_repos: InFlightRepoTable::new(),
|
||||
transport_tables: TransportStateTables::new(),
|
||||
transport_tables,
|
||||
pending_repo_tasks: VecDeque::new(),
|
||||
pending_transport_tasks: VecDeque::new(),
|
||||
stats: ParallelRunStats::default(),
|
||||
dead_repo_blacklist,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,9 +220,92 @@ impl GlobalRunCoordinator {
|
||||
{
|
||||
self.stats.repo_tasks_failed += 1;
|
||||
}
|
||||
self.update_dead_repo_blacklist(&result, finished_at);
|
||||
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(
|
||||
&self,
|
||||
identity: &RepoIdentity,
|
||||
@@ -225,7 +335,7 @@ impl GlobalRunCoordinator {
|
||||
self.pending_repo_tasks.clear();
|
||||
self.pending_transport_tasks.clear();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -322,7 +432,7 @@ mod tests {
|
||||
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
|
||||
.apply_repository_view_entries(&[RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||
@@ -343,8 +453,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.current_repo_index
|
||||
.lock()
|
||||
.expect("index lock")
|
||||
.read()
|
||||
.expect("index read lock")
|
||||
.active_uri_count(),
|
||||
0
|
||||
);
|
||||
@@ -465,4 +575,288 @@ mod tests {
|
||||
assert_eq!(coordinator.stats.repo_tasks_total, 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 {
|
||||
detail: "timeout".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
});
|
||||
recorder.record_result(&crate::parallel::types::RepoTransportResultEnvelope {
|
||||
@@ -499,6 +500,7 @@ mod tests {
|
||||
result: crate::parallel::types::RepoTransportResultKind::Failed {
|
||||
detail: "timeout".to_string(),
|
||||
warnings: Vec::new(),
|
||||
error_class: crate::parallel::types::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::policy::SyncPreference;
|
||||
use crate::report::Warning;
|
||||
|
||||
@@ -96,7 +98,8 @@ pub enum RepoDedupKey {
|
||||
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 {
|
||||
Rrdp,
|
||||
Rsync,
|
||||
@@ -116,6 +119,26 @@ pub struct RepoTransportTask {
|
||||
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)]
|
||||
pub enum RepoTransportResultKind {
|
||||
Success {
|
||||
@@ -125,6 +148,7 @@ pub enum RepoTransportResultKind {
|
||||
Failed {
|
||||
detail: String,
|
||||
warnings: Vec<Warning>,
|
||||
error_class: RepoTransportErrorClass,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -479,6 +503,7 @@ mod tests {
|
||||
result: RepoTransportResultKind::Failed {
|
||||
detail: "timeout".to_string(),
|
||||
warnings: vec![Warning::new("timeout")],
|
||||
error_class: super::RepoTransportErrorClass::Unknown,
|
||||
},
|
||||
};
|
||||
assert!(matches!(ok.result, RepoTransportResultKind::Success { .. }));
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::ta_constraints::TaConstraintsByTal;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -127,6 +130,15 @@ pub struct Policy {
|
||||
pub signed_object_failure_policy: SignedObjectFailurePolicy,
|
||||
pub resource_validation_mode: ResourceValidationMode,
|
||||
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>,
|
||||
/// Locally configured, per-TAL EE certificate resource constraints. They
|
||||
/// are intentionally CLI/runtime-only rather than policy-file input.
|
||||
#[serde(skip)]
|
||||
pub ta_constraints: TaConstraintsByTal,
|
||||
}
|
||||
|
||||
impl Default for Policy {
|
||||
@@ -137,6 +149,8 @@ impl Default for Policy {
|
||||
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
|
||||
resource_validation_mode: ResourceValidationMode::default(),
|
||||
strict: StrictPolicy::default(),
|
||||
verification_forced_vcir_reuse_manifest_uris: BTreeSet::new(),
|
||||
ta_constraints: TaConstraintsByTal::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod artifact_manifest;
|
||||
pub mod object_resolver;
|
||||
pub mod report_stream;
|
||||
pub mod vrp;
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::value::RawValue;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::query::vrp::VrpRecord;
|
||||
use crate::query_db::{
|
||||
ObjectInstanceRecord, PublicationPointRecord, QUERY_DB_SCHEMA_VERSION, QueryDbError,
|
||||
QueryDbResult, QueryPage, RepositoryRecord, StatsRecord,
|
||||
@@ -23,6 +24,7 @@ pub struct ReportSummary {
|
||||
pub query_audit: Option<Value>,
|
||||
pub download_stats: Option<Value>,
|
||||
pub vrps_count: u64,
|
||||
pub vrps: Vec<VrpRecord>,
|
||||
pub aspas_count: u64,
|
||||
pub warnings_count: u64,
|
||||
pub objects_count: u64,
|
||||
@@ -50,6 +52,68 @@ pub enum ObjectLookup {
|
||||
Sha256(String),
|
||||
}
|
||||
|
||||
/// Optional predicates applied while streaming report objects. `reason` and
|
||||
/// `query` must be pre-lowercased by the caller; they are matched as
|
||||
/// case-insensitive substrings. The other fields are exact matches.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ObjectFilter {
|
||||
pub object_type: Option<String>,
|
||||
pub result: Option<String>,
|
||||
pub rejected: Option<bool>,
|
||||
pub reason: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub repo_id: Option<String>,
|
||||
pub pp_id: Option<String>,
|
||||
pub issues_only: bool,
|
||||
}
|
||||
|
||||
impl ObjectFilter {
|
||||
pub fn matches(&self, object: &ObjectInstanceRecord) -> bool {
|
||||
if self.issues_only && !(object.rejected || object.result == "error") {
|
||||
return false;
|
||||
}
|
||||
if let Some(rejected) = self.rejected
|
||||
&& object.rejected != rejected
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(result) = self.result.as_deref()
|
||||
&& !object.result.eq_ignore_ascii_case(result)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(object_type) = self.object_type.as_deref()
|
||||
&& !object.object_type.eq_ignore_ascii_case(object_type)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(repo_id) = self.repo_id.as_deref()
|
||||
&& object.repo_id != repo_id
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(pp_id) = self.pp_id.as_deref()
|
||||
&& object.pp_id != pp_id
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(reason) = self.reason.as_deref() {
|
||||
let Some(reject_reason) = object.reject_reason.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if !reject_reason.to_lowercase().contains(reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(query) = self.query.as_deref()
|
||||
&& !object.uri.to_lowercase().contains(query)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ObjectResolutionMeta {
|
||||
@@ -81,16 +145,45 @@ pub fn list_report_objects(
|
||||
scope: ObjectScope,
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||
list_report_objects_filtered(
|
||||
report_path,
|
||||
run_id,
|
||||
scope,
|
||||
&ObjectFilter::default(),
|
||||
limit,
|
||||
cursor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_report_objects_filtered(
|
||||
report_path: &Path,
|
||||
run_id: &str,
|
||||
scope: ObjectScope,
|
||||
filter: &ObjectFilter,
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||
let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?;
|
||||
let file = File::open(report_path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut deserializer = serde_json::Deserializer::from_reader(reader);
|
||||
let mut state =
|
||||
ObjectScanState::new_list(run_id, scope, limit, start_pp_index, start_object_index);
|
||||
ObjectScanSeed { state: &mut state }
|
||||
.deserialize(&mut deserializer)
|
||||
.map_err(QueryDbError::from)?;
|
||||
let mut state = ObjectScanState::new_list(
|
||||
run_id,
|
||||
scope,
|
||||
filter.clone(),
|
||||
limit,
|
||||
start_pp_index,
|
||||
start_object_index,
|
||||
);
|
||||
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
|
||||
match scan {
|
||||
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 {
|
||||
data: state.data,
|
||||
next_cursor: state.next_cursor,
|
||||
@@ -127,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String {
|
||||
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)> {
|
||||
let Some(cursor) = cursor else {
|
||||
return Ok((0, 0));
|
||||
@@ -201,7 +304,13 @@ impl<'de> Visitor<'de> for ReportSummaryVisitor<'_> {
|
||||
out.repos = pps.repos;
|
||||
out.stats = pps.stats;
|
||||
}
|
||||
"vrps" => out.vrps_count = map.next_value::<CountSeq>()?.0,
|
||||
"vrps" => {
|
||||
let vrps = map.next_value_seed(VrpSequenceSeed {
|
||||
run_id: self.run_id,
|
||||
})?;
|
||||
out.vrps_count = vrps.count;
|
||||
out.vrps = vrps.records;
|
||||
}
|
||||
"aspas" => out.aspas_count = map.next_value::<CountSeq>()?.0,
|
||||
"download_stats" => out.download_stats = Some(map.next_value()?),
|
||||
"queryAudit" => out.query_audit = Some(map.next_value()?),
|
||||
@@ -239,6 +348,62 @@ struct PublicationPointsSummary {
|
||||
objects_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawVrpRecord {
|
||||
asn: u32,
|
||||
prefix: String,
|
||||
max_length: u8,
|
||||
}
|
||||
|
||||
struct VrpSequenceSeed<'a> {
|
||||
run_id: &'a str,
|
||||
}
|
||||
|
||||
struct VrpSequence {
|
||||
count: u64,
|
||||
records: Vec<VrpRecord>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeSeed<'de> for VrpSequenceSeed<'_> {
|
||||
type Value = VrpSequence;
|
||||
|
||||
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_seq(VrpSequenceVisitor {
|
||||
run_id: self.run_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct VrpSequenceVisitor<'a> {
|
||||
run_id: &'a str,
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for VrpSequenceVisitor<'_> {
|
||||
type Value = VrpSequence;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("VRP array")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
let mut count = 0u64;
|
||||
let mut records = Vec::new();
|
||||
while let Some(raw) = seq.next_element::<RawVrpRecord>()? {
|
||||
let record = VrpRecord::from_report(self.run_id, raw.asn, &raw.prefix, raw.max_length)
|
||||
.map_err(de::Error::custom)?;
|
||||
count += 1;
|
||||
records.push(record);
|
||||
}
|
||||
Ok(VrpSequence { count, records })
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicationPointsSummaryVisitor<'a> {
|
||||
run_id: &'a str,
|
||||
}
|
||||
@@ -448,15 +613,15 @@ impl<'de> Deserialize<'de> for ObjectStatsSeq {
|
||||
stats.count += 1;
|
||||
if object.result == "error" {
|
||||
stats.rejected += 1;
|
||||
if let Some(detail) = object.detail.filter(|value| !value.is_empty()) {
|
||||
bump_limited_count(&mut stats.reason_counts, &detail, 512);
|
||||
}
|
||||
}
|
||||
*stats
|
||||
.object_type_counts
|
||||
.entry(object.object_type)
|
||||
.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))
|
||||
}
|
||||
@@ -530,6 +695,7 @@ struct ObjectScanState<'a> {
|
||||
run_id: &'a str,
|
||||
scope: ObjectScope,
|
||||
mode: ObjectScanMode,
|
||||
filter: ObjectFilter,
|
||||
limit: usize,
|
||||
start_pp_index: u64,
|
||||
start_object_index: u64,
|
||||
@@ -544,6 +710,7 @@ impl<'a> ObjectScanState<'a> {
|
||||
fn new_list(
|
||||
run_id: &'a str,
|
||||
scope: ObjectScope,
|
||||
filter: ObjectFilter,
|
||||
limit: usize,
|
||||
start_pp_index: u64,
|
||||
start_object_index: u64,
|
||||
@@ -552,6 +719,7 @@ impl<'a> ObjectScanState<'a> {
|
||||
run_id,
|
||||
scope,
|
||||
mode: ObjectScanMode::List,
|
||||
filter,
|
||||
limit,
|
||||
start_pp_index,
|
||||
start_object_index,
|
||||
@@ -568,6 +736,7 @@ impl<'a> ObjectScanState<'a> {
|
||||
run_id,
|
||||
scope,
|
||||
mode: ObjectScanMode::Lookup(lookup),
|
||||
filter: ObjectFilter::default(),
|
||||
limit: 1,
|
||||
start_pp_index: 0,
|
||||
start_object_index: 0,
|
||||
@@ -578,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
|
||||
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)]
|
||||
@@ -653,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
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())
|
||||
.map_err(de::Error::custom)?;
|
||||
pp_index += 1;
|
||||
@@ -668,6 +852,20 @@ fn process_raw_publication_point(
|
||||
raw_pp: &str,
|
||||
) -> QueryDbResult<()> {
|
||||
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 ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
||||
if !state.scope.matches(&ctx) {
|
||||
@@ -682,7 +880,7 @@ fn process_raw_publication_point(
|
||||
let after_cursor = pp_index > state.start_pp_index
|
||||
|| (pp_index == state.start_pp_index
|
||||
&& object_index >= state.start_object_index);
|
||||
if after_cursor && state.data.len() < state.limit {
|
||||
if after_cursor && state.data.len() < state.limit && state.filter.matches(&record) {
|
||||
state.data.push(record);
|
||||
if state.data.len() == state.limit {
|
||||
state.next_cursor = Some(object_cursor(pp_index, object_index + 1));
|
||||
@@ -755,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)]
|
||||
struct ReportObject {
|
||||
uri: String,
|
||||
@@ -1153,11 +1375,14 @@ mod tests {
|
||||
"repo_terminal_state":"fresh",
|
||||
"warnings":[],
|
||||
"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"}
|
||||
]
|
||||
}],
|
||||
"vrps":[{},{}],
|
||||
"vrps":[
|
||||
{"asn":64496,"prefix":"192.0.2.0/24","max_length":24},
|
||||
{"asn":64497,"prefix":"2001:db8::/32","max_length":48}
|
||||
],
|
||||
"aspas":[{}],
|
||||
"download_stats":{"eventsTotal":0},
|
||||
"queryAudit":{"status":"complete","eventsPath":"validation-events.jsonl"}
|
||||
@@ -1176,6 +1401,13 @@ mod tests {
|
||||
assert_eq!(summary.aspas_count, 1);
|
||||
assert_eq!(summary.stats.object_type_counts["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 =
|
||||
list_report_objects(&report_path, "run_0001", ObjectScope::All, 1, None).expect("page");
|
||||
@@ -1203,4 +1435,195 @@ mod tests {
|
||||
assert_eq!(found.object.result, "error");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
275
src/query/vrp.rs
Normal file
275
src/query/vrp.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VrpRecord {
|
||||
pub run_id: String,
|
||||
pub asn: u32,
|
||||
pub prefix: String,
|
||||
pub max_length: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IpPrefix {
|
||||
addr: IpAddr,
|
||||
prefix_length: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VrpLookup {
|
||||
Ip(IpAddr),
|
||||
Prefix(IpPrefix),
|
||||
}
|
||||
|
||||
impl VrpRecord {
|
||||
pub fn from_report(
|
||||
run_id: impl Into<String>,
|
||||
asn: u32,
|
||||
prefix: &str,
|
||||
max_length: u8,
|
||||
) -> Result<Self, String> {
|
||||
let network = IpPrefix::parse(prefix)?;
|
||||
if max_length < network.prefix_length {
|
||||
return Err(format!(
|
||||
"VRP max_length {max_length} is shorter than prefix length {}",
|
||||
network.prefix_length
|
||||
));
|
||||
}
|
||||
if max_length > network.max_prefix_length() {
|
||||
return Err(format!(
|
||||
"VRP max_length {max_length} exceeds address family limit {}",
|
||||
network.max_prefix_length()
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
run_id: run_id.into(),
|
||||
asn,
|
||||
prefix: network.to_cidr(),
|
||||
max_length,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prefix_data(&self) -> Result<IpPrefix, String> {
|
||||
IpPrefix::parse(&self.prefix)
|
||||
}
|
||||
|
||||
pub fn index_key(&self) -> Result<String, String> {
|
||||
let prefix = self.prefix_data()?;
|
||||
Ok(format!(
|
||||
"vrp/{}/{}/{:03}/{}/{:010}/{:03}",
|
||||
self.run_id,
|
||||
prefix.family(),
|
||||
prefix.prefix_length,
|
||||
prefix.network_hex(),
|
||||
self.asn,
|
||||
self.max_length
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl IpPrefix {
|
||||
pub fn parse(value: &str) -> Result<Self, String> {
|
||||
let (addr, raw_length) = value
|
||||
.rsplit_once('/')
|
||||
.ok_or_else(|| format!("invalid CIDR prefix: {value}"))?;
|
||||
let ip = addr
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| format!("invalid IP address in prefix: {value}"))?;
|
||||
let prefix_length = raw_length
|
||||
.parse::<u8>()
|
||||
.map_err(|_| format!("invalid prefix length in: {value}"))?;
|
||||
Self::new(ip, prefix_length)
|
||||
}
|
||||
|
||||
pub fn new(addr: IpAddr, prefix_length: u8) -> Result<Self, String> {
|
||||
if prefix_length > max_prefix_length(addr) {
|
||||
return Err(format!(
|
||||
"prefix length {prefix_length} exceeds address family limit {}",
|
||||
max_prefix_length(addr)
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
addr: network_address(addr, prefix_length),
|
||||
prefix_length,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prefix_length(&self) -> u8 {
|
||||
self.prefix_length
|
||||
}
|
||||
|
||||
pub fn max_prefix_length(&self) -> u8 {
|
||||
max_prefix_length(self.addr)
|
||||
}
|
||||
|
||||
pub fn family(&self) -> &'static str {
|
||||
match self.addr {
|
||||
IpAddr::V4(_) => "v4",
|
||||
IpAddr::V6(_) => "v6",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn network_hex(&self) -> String {
|
||||
match self.addr {
|
||||
IpAddr::V4(addr) => hex::encode(addr.octets()),
|
||||
IpAddr::V6(addr) => hex::encode(addr.octets()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_cidr(&self) -> String {
|
||||
format!("{}/{}", self.addr, self.prefix_length)
|
||||
}
|
||||
|
||||
pub fn contains_ip(&self, ip: IpAddr) -> bool {
|
||||
if std::mem::discriminant(&self.addr) != std::mem::discriminant(&ip) {
|
||||
return false;
|
||||
}
|
||||
network_address(ip, self.prefix_length) == self.addr
|
||||
}
|
||||
|
||||
pub fn contains_prefix(&self, other: &Self) -> bool {
|
||||
self.family() == other.family()
|
||||
&& self.prefix_length <= other.prefix_length
|
||||
&& self.contains_ip(other.addr)
|
||||
}
|
||||
|
||||
pub fn ancestor_prefixes(&self) -> Vec<Self> {
|
||||
(0..=self.prefix_length)
|
||||
.filter_map(|length| Self::new(self.addr, length).ok())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl VrpLookup {
|
||||
pub fn parse_ip(value: &str) -> Result<Self, String> {
|
||||
value
|
||||
.parse::<IpAddr>()
|
||||
.map(VrpLookup::Ip)
|
||||
.map_err(|_| format!("invalid IP address: {value}"))
|
||||
}
|
||||
|
||||
pub fn parse_prefix(value: &str) -> Result<Self, String> {
|
||||
IpPrefix::parse(value).map(VrpLookup::Prefix)
|
||||
}
|
||||
|
||||
pub fn candidate_prefixes(&self) -> Vec<IpPrefix> {
|
||||
match self {
|
||||
VrpLookup::Ip(ip) => (0..=max_prefix_length(*ip))
|
||||
.filter_map(|length| IpPrefix::new(*ip, length).ok())
|
||||
.collect(),
|
||||
VrpLookup::Prefix(prefix) => prefix.ancestor_prefixes(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, record: &VrpRecord) -> Result<bool, String> {
|
||||
let record_prefix = record.prefix_data()?;
|
||||
Ok(match self {
|
||||
VrpLookup::Ip(ip) => record_prefix.contains_ip(*ip),
|
||||
VrpLookup::Prefix(prefix) => {
|
||||
record_prefix.contains_prefix(prefix) && record.max_length >= prefix.prefix_length
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vrp_key_prefix(run_id: &str, prefix: &IpPrefix) -> String {
|
||||
format!(
|
||||
"vrp/{}/{}/{:03}/{}/",
|
||||
run_id,
|
||||
prefix.family(),
|
||||
prefix.prefix_length,
|
||||
prefix.network_hex()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_cursor(cursor: Option<&str>) -> Result<usize, String> {
|
||||
let Some(cursor) = cursor else {
|
||||
return Ok(0);
|
||||
};
|
||||
let offset = cursor
|
||||
.strip_prefix("vrp:")
|
||||
.ok_or_else(|| format!("invalid VRP cursor: {cursor}"))?
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid VRP cursor: {cursor}"))?;
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
pub fn next_cursor(offset: usize, limit: usize, total: usize) -> Option<String> {
|
||||
let next = offset.saturating_add(limit);
|
||||
(next < total).then(|| format!("vrp:{next}"))
|
||||
}
|
||||
|
||||
fn max_prefix_length(addr: IpAddr) -> u8 {
|
||||
match addr {
|
||||
IpAddr::V4(_) => 32,
|
||||
IpAddr::V6(_) => 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn network_address(addr: IpAddr, prefix_length: u8) -> IpAddr {
|
||||
match addr {
|
||||
IpAddr::V4(addr) => {
|
||||
let value = u32::from(addr);
|
||||
let mask = if prefix_length == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix_length)
|
||||
};
|
||||
IpAddr::V4(Ipv4Addr::from(value & mask))
|
||||
}
|
||||
IpAddr::V6(addr) => {
|
||||
let mut octets = addr.octets();
|
||||
let full_bytes = usize::from(prefix_length / 8);
|
||||
let remaining_bits = prefix_length % 8;
|
||||
if full_bytes < octets.len() {
|
||||
if remaining_bits > 0 {
|
||||
octets[full_bytes] &= 0xff << (8 - remaining_bits);
|
||||
}
|
||||
let first_zero = full_bytes + usize::from(remaining_bits > 0);
|
||||
for byte in &mut octets[first_zero..] {
|
||||
*byte = 0;
|
||||
}
|
||||
}
|
||||
IpAddr::V6(Ipv6Addr::from(octets))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_ipv4_and_ipv6_networks() {
|
||||
assert_eq!(
|
||||
IpPrefix::parse("192.0.2.129/24").unwrap().to_cidr(),
|
||||
"192.0.2.0/24"
|
||||
);
|
||||
assert_eq!(
|
||||
IpPrefix::parse("2001:db8:1::1234/48").unwrap().to_cidr(),
|
||||
"2001:db8:1::/48"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_lookup_uses_covering_and_max_length_semantics() {
|
||||
let lookup = VrpLookup::parse_prefix("192.0.2.0/24").unwrap();
|
||||
let covering = VrpRecord::from_report("run_1", 64496, "192.0.0.0/16", 24).unwrap();
|
||||
let too_short = VrpRecord::from_report("run_1", 64497, "192.0.0.0/16", 23).unwrap();
|
||||
let intersecting = VrpRecord::from_report("run_1", 64498, "192.0.2.128/25", 25).unwrap();
|
||||
assert!(lookup.matches(&covering).unwrap());
|
||||
assert!(!lookup.matches(&too_short).unwrap());
|
||||
assert!(!lookup.matches(&intersecting).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_is_scoped_and_bounded() {
|
||||
assert_eq!(parse_cursor(None).unwrap(), 0);
|
||||
assert_eq!(parse_cursor(Some("vrp:12")).unwrap(), 12);
|
||||
assert!(parse_cursor(Some("12")).is_err());
|
||||
assert_eq!(next_cursor(0, 10, 12).as_deref(), Some("vrp:10"));
|
||||
assert_eq!(next_cursor(10, 10, 12), None);
|
||||
}
|
||||
}
|
||||
591
src/query_db.rs
591
src/query_db.rs
@@ -9,10 +9,11 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::query::artifact_manifest::build_artifact_manifest;
|
||||
use crate::query::report_stream::{self, ObjectLookup, ObjectScope, ReportSummary};
|
||||
use crate::query::vrp::{self, VrpLookup, VrpRecord};
|
||||
|
||||
use crate::blob_store::ExternalRepoBytesDb;
|
||||
|
||||
pub const QUERY_DB_SCHEMA_VERSION: u32 = 1;
|
||||
pub const QUERY_DB_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
pub const CF_META: &str = "meta";
|
||||
pub const CF_RUNS: &str = "runs";
|
||||
@@ -26,6 +27,7 @@ pub const CF_VALIDATION_EXPLAIN_CACHE: &str = "validation_explain_cache";
|
||||
pub const CF_EXPORT_JOBS: &str = "export_jobs";
|
||||
pub const CF_STATS: &str = "stats";
|
||||
pub const CF_REASON_INDEX: &str = "reason_index";
|
||||
pub const CF_VRPS: &str = "vrps";
|
||||
|
||||
pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
|
||||
CF_META,
|
||||
@@ -40,6 +42,7 @@ pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
|
||||
CF_EXPORT_JOBS,
|
||||
CF_STATS,
|
||||
CF_REASON_INDEX,
|
||||
CF_VRPS,
|
||||
];
|
||||
|
||||
const KEY_SCHEMA_VERSION: &[u8] = b"schema_version";
|
||||
@@ -57,6 +60,8 @@ pub enum QueryDbError {
|
||||
MissingColumnFamily(&'static str),
|
||||
#[error("invalid run artifact: {0}")]
|
||||
InvalidArtifact(String),
|
||||
#[error("report data for run {0} is no longer available (retention cleanup)")]
|
||||
RunDataGone(String),
|
||||
#[error("CIR decode error: {0}")]
|
||||
CirDecode(String),
|
||||
}
|
||||
@@ -92,6 +97,7 @@ pub struct QueryIndexSummary {
|
||||
pub object_instances_indexed: u64,
|
||||
pub object_projections_indexed: u64,
|
||||
pub stats_indexed: u64,
|
||||
pub vrps_indexed: u64,
|
||||
pub latest_ready_run: Option<String>,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
@@ -156,6 +162,24 @@ pub struct RepositoryRecord {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicationPointRecord {
|
||||
@@ -285,6 +309,7 @@ impl QueryDb {
|
||||
opts.create_if_missing(true);
|
||||
opts.create_missing_column_families(true);
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||
.iter()
|
||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||
@@ -306,6 +331,7 @@ impl QueryDb {
|
||||
opts.create_if_missing(false);
|
||||
opts.create_missing_column_families(false);
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||
.iter()
|
||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||
@@ -347,8 +373,10 @@ impl QueryDb {
|
||||
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
||||
if run_id == "latest" || run_id == "latest_run" {
|
||||
self.latest_ready_run()
|
||||
} else {
|
||||
} else if self.get_run(run_id)?.is_some() {
|
||||
Ok(Some(run_id.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +385,38 @@ impl QueryDb {
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> 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(
|
||||
@@ -369,6 +428,104 @@ impl QueryDb {
|
||||
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(
|
||||
&self,
|
||||
run_id: &str,
|
||||
lookup: &VrpLookup,
|
||||
asn: Option<u32>,
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> QueryDbResult<QueryPage<VrpRecord>> {
|
||||
let cf = self.cf(CF_VRPS)?;
|
||||
let mut records = BTreeMap::<String, VrpRecord>::new();
|
||||
for candidate in lookup.candidate_prefixes() {
|
||||
let key_prefix = vrp::vrp_key_prefix(run_id, &candidate);
|
||||
let mode = IteratorMode::From(key_prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item?;
|
||||
if !key.starts_with(key_prefix.as_bytes()) {
|
||||
break;
|
||||
}
|
||||
let key_string = String::from_utf8_lossy(&key).to_string();
|
||||
let record: VrpRecord = serde_json::from_slice(&value)?;
|
||||
if asn.is_some_and(|expected| expected != record.asn)
|
||||
|| !lookup
|
||||
.matches(&record)
|
||||
.map_err(QueryDbError::InvalidArtifact)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
records.insert(key_string, record);
|
||||
}
|
||||
}
|
||||
|
||||
let offset = vrp::parse_cursor(cursor).map_err(QueryDbError::InvalidArtifact)?;
|
||||
let bounded_limit = limit.clamp(1, 1000);
|
||||
let total = records.len();
|
||||
let data = records
|
||||
.into_values()
|
||||
.skip(offset)
|
||||
.take(bounded_limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(QueryPage {
|
||||
data,
|
||||
next_cursor: vrp::next_cursor(offset, bounded_limit, total),
|
||||
limit: bounded_limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_repo(&self, run_id: &str, repo_id: &str) -> QueryDbResult<Option<RepositoryRecord>> {
|
||||
self.get_json_cf(CF_REPOS, repo_key(run_id, repo_id).as_bytes())
|
||||
}
|
||||
@@ -473,6 +630,31 @@ impl QueryDb {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_objects_filtered(
|
||||
&self,
|
||||
run_id: &str,
|
||||
scope: ObjectScope,
|
||||
filter: &report_stream::ObjectFilter,
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||
let Some(report_path) = self.report_path_for_run(run_id)? else {
|
||||
return Ok(QueryPage {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
limit: limit.clamp(1, 1000),
|
||||
});
|
||||
};
|
||||
report_stream::list_report_objects_filtered(
|
||||
&report_path,
|
||||
run_id,
|
||||
scope,
|
||||
filter,
|
||||
limit,
|
||||
cursor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_object_by_instance_id(
|
||||
&self,
|
||||
run_id: &str,
|
||||
@@ -616,9 +798,17 @@ impl QueryDb {
|
||||
}
|
||||
|
||||
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
||||
Ok(self
|
||||
.get_run(run_id)?
|
||||
.map(|run| Path::new(&run.run_dir).join("report.json")))
|
||||
let Some(run) = self.get_run(run_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
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(
|
||||
@@ -628,6 +818,118 @@ impl QueryDb {
|
||||
self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes())
|
||||
}
|
||||
|
||||
pub fn search_object_projections_by_hash_prefix(
|
||||
&self,
|
||||
sha256_prefix: &str,
|
||||
limit: usize,
|
||||
) -> QueryDbResult<Vec<crate::object_projection::ObjectProjectionRecord>> {
|
||||
let cf = self.cf(CF_OBJECTS_BY_HASH)?;
|
||||
let prefix = format!("objhash/{sha256_prefix}");
|
||||
let mut out = Vec::new();
|
||||
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item?;
|
||||
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||
break;
|
||||
}
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
out.push(serde_json::from_slice(&value)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get_cached_object_by_sha256(
|
||||
&self,
|
||||
run_id: &str,
|
||||
sha256: &str,
|
||||
) -> QueryDbResult<Option<ObjectInstanceRecord>> {
|
||||
let mut cursor = None;
|
||||
loop {
|
||||
let page: QueryPage<ObjectInstanceRecord> = self.list_json_by_prefix(
|
||||
CF_OBJECT_INSTANCES,
|
||||
&format!("objinst/{run_id}/"),
|
||||
1000,
|
||||
cursor.as_deref(),
|
||||
)?;
|
||||
if let Some(found) = page
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|item| item.sha256.eq_ignore_ascii_case(sha256))
|
||||
{
|
||||
return Ok(Some(found));
|
||||
}
|
||||
let Some(next_cursor) = page.next_cursor else {
|
||||
return Ok(None);
|
||||
};
|
||||
cursor = Some(next_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_repos(
|
||||
&self,
|
||||
run_id: &str,
|
||||
query_lower: &str,
|
||||
limit: usize,
|
||||
) -> QueryDbResult<Vec<RepositoryRecord>> {
|
||||
let cf = self.cf(CF_REPOS)?;
|
||||
let prefix = format!("repo/{run_id}/");
|
||||
let mut out = Vec::new();
|
||||
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item?;
|
||||
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||
break;
|
||||
}
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let repo: RepositoryRecord = serde_json::from_slice(&value)?;
|
||||
if repo.host.to_lowercase().contains(query_lower)
|
||||
|| repo.uri.to_lowercase().contains(query_lower)
|
||||
{
|
||||
out.push(repo);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn search_publication_points(
|
||||
&self,
|
||||
run_id: &str,
|
||||
query_lower: &str,
|
||||
limit: usize,
|
||||
) -> QueryDbResult<Vec<PublicationPointRecord>> {
|
||||
let cf = self.cf(CF_PUBLICATION_POINTS)?;
|
||||
let prefix = format!("pp/{run_id}/");
|
||||
let mut out = Vec::new();
|
||||
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item?;
|
||||
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||
break;
|
||||
}
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let pp: PublicationPointRecord = serde_json::from_slice(&value)?;
|
||||
let uri_matches = [
|
||||
pp.manifest_rsync_uri.as_deref(),
|
||||
pp.publication_point_rsync_uri.as_deref(),
|
||||
pp.rrdp_notification_uri.as_deref(),
|
||||
pp.rsync_base_uri.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|uri| uri.to_lowercase().contains(query_lower));
|
||||
if uri_matches {
|
||||
out.push(pp);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get_stat(
|
||||
&self,
|
||||
run_id: &str,
|
||||
@@ -762,12 +1064,20 @@ impl QueryDb {
|
||||
(CF_EXPORT_JOBS, format!("export/{}/", run.run_id)),
|
||||
(CF_STATS, format!("stats/{}/", run.run_id)),
|
||||
(CF_REASON_INDEX, format!("reason/{}/", run.run_id)),
|
||||
(CF_VRPS, format!("vrp/{}/", run.run_id)),
|
||||
] {
|
||||
self.delete_prefix_range(&mut batch, cf_name, &prefix)?;
|
||||
}
|
||||
self.write_batch(batch)
|
||||
}
|
||||
|
||||
fn clear_vrp_index(&self, run_id: &str) -> QueryDbResult<()> {
|
||||
let mut batch = WriteBatch::default();
|
||||
let prefix = format!("vrp/{run_id}/");
|
||||
self.delete_prefix_range(&mut batch, CF_VRPS, &prefix)?;
|
||||
self.write_batch(batch)
|
||||
}
|
||||
|
||||
fn cf(&self, name: &'static str) -> QueryDbResult<&rocksdb::ColumnFamily> {
|
||||
self.db
|
||||
.cf_handle(name)
|
||||
@@ -899,6 +1209,7 @@ impl QueryDb {
|
||||
fn cf_options() -> Options {
|
||||
let mut opts = Options::default();
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
opts
|
||||
}
|
||||
|
||||
@@ -943,6 +1254,7 @@ pub fn index_artifacts_with_open_db(
|
||||
summary.object_instances_indexed += run_summary.object_instances_indexed;
|
||||
summary.object_projections_indexed += run_summary.object_projections_indexed;
|
||||
summary.stats_indexed += run_summary.stats_indexed;
|
||||
summary.vrps_indexed += run_summary.vrps_indexed;
|
||||
summary.latest_ready_run = run_summary.latest_ready_run;
|
||||
}
|
||||
Err(err) => summary.errors.push(format!("{}: {err}", run_dir.display())),
|
||||
@@ -1015,6 +1327,7 @@ struct SingleRunIndexSummary {
|
||||
object_instances_indexed: u64,
|
||||
object_projections_indexed: u64,
|
||||
stats_indexed: u64,
|
||||
vrps_indexed: u64,
|
||||
latest_ready_run: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1056,6 +1369,7 @@ fn index_run_dir(
|
||||
run_record.index_status = "building".to_string();
|
||||
run_record.index_error = None;
|
||||
db.put_json_cf(CF_RUNS, run_key(&run_id).as_bytes(), &run_record)?;
|
||||
db.clear_vrp_index(&run_id)?;
|
||||
|
||||
let indexed =
|
||||
match write_summary_index_records(db, &run_id, &report_summary, &artifact_manifest) {
|
||||
@@ -1099,6 +1413,7 @@ fn index_run_dir(
|
||||
object_instances_indexed: indexed.object_instances_indexed,
|
||||
object_projections_indexed: indexed.object_projections_indexed,
|
||||
stats_indexed: indexed.stats_indexed,
|
||||
vrps_indexed: indexed.vrps_indexed,
|
||||
latest_ready_run: if should_update_latest {
|
||||
Some(run_id)
|
||||
} else {
|
||||
@@ -1138,6 +1453,7 @@ struct IndexWriteSummary {
|
||||
object_instances_indexed: u64,
|
||||
object_projections_indexed: u64,
|
||||
stats_indexed: u64,
|
||||
vrps_indexed: u64,
|
||||
}
|
||||
|
||||
fn write_summary_index_records(
|
||||
@@ -1150,6 +1466,7 @@ fn write_summary_index_records(
|
||||
let mut batch = WriteBatch::default();
|
||||
let mut pending = 0usize;
|
||||
let mut summary = IndexWriteSummary::default();
|
||||
let mut seen_vrp_keys = BTreeSet::new();
|
||||
|
||||
macro_rules! flush_if_needed {
|
||||
() => {
|
||||
@@ -1187,6 +1504,18 @@ fn write_summary_index_records(
|
||||
flush_if_needed!();
|
||||
}
|
||||
|
||||
for vrp_record in &report_summary.vrps {
|
||||
let key = vrp_record
|
||||
.index_key()
|
||||
.map_err(QueryDbError::InvalidArtifact)?;
|
||||
if seen_vrp_keys.insert(key.clone()) {
|
||||
put_json_batch(&mut batch, db, CF_VRPS, key.as_bytes(), vrp_record)?;
|
||||
pending += 1;
|
||||
summary.vrps_indexed += 1;
|
||||
flush_if_needed!();
|
||||
}
|
||||
}
|
||||
|
||||
let artifacts_value = serde_json::to_value(artifact_manifest)?;
|
||||
let stats = report_stream::stats_records_from_summary(run_id, report_summary, artifacts_value);
|
||||
for record in &stats {
|
||||
@@ -1447,6 +1776,56 @@ mod tests {
|
||||
assert_eq!(db.count_cf(CF_OBJECT_INSTANCES).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vrp_index_supports_ip_prefix_asn_and_pagination_queries() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let run_dir = temp.path().join("runs/run_0001");
|
||||
fs::create_dir_all(&run_dir).expect("run dir");
|
||||
write_sample_run(&run_dir, "run_0001", 1);
|
||||
let mut report = read_json_file(&run_dir.join("report.json")).expect("read report");
|
||||
let duplicate = report["vrps"][0].clone();
|
||||
report["vrps"].as_array_mut().unwrap().push(duplicate);
|
||||
fs::write(
|
||||
run_dir.join("report.json"),
|
||||
serde_json::to_vec(&report).unwrap(),
|
||||
)
|
||||
.expect("write duplicate VRP report");
|
||||
|
||||
let query_db_path = temp.path().join("query-db");
|
||||
let summary = 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");
|
||||
assert_eq!(summary.vrps_indexed, 1);
|
||||
|
||||
let db = QueryDb::open(&query_db_path).expect("query db");
|
||||
assert_eq!(db.count_cf(CF_VRPS).unwrap(), 1);
|
||||
let ip = VrpLookup::parse_ip("192.0.2.1").expect("ip lookup");
|
||||
let page = db
|
||||
.lookup_vrps("run_0001", &ip, None, 1, None)
|
||||
.expect("ip query");
|
||||
assert_eq!(page.data.len(), 1);
|
||||
assert_eq!(page.data[0].asn, 64496);
|
||||
assert_eq!(page.data[0].prefix, "192.0.2.0/24");
|
||||
assert!(page.next_cursor.is_none());
|
||||
|
||||
let prefix = VrpLookup::parse_prefix("192.0.2.0/24").expect("prefix lookup");
|
||||
let filtered = db
|
||||
.lookup_vrps("run_0001", &prefix, Some(64497), 100, None)
|
||||
.expect("asn query");
|
||||
assert!(filtered.data.is_empty());
|
||||
let latest = db
|
||||
.lookup_vrps("run_0001", &prefix, Some(64496), 100, None)
|
||||
.expect("filtered prefix query");
|
||||
assert_eq!(latest.data.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_run_does_not_replace_previous_latest() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1688,15 +2067,17 @@ mod tests {
|
||||
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
||||
Some("run_0001")
|
||||
);
|
||||
assert_eq!(db.resolve_run_id("run_9999").unwrap(), None);
|
||||
|
||||
let first_page = db.list_runs(1, None).expect("runs");
|
||||
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
|
||||
.list_runs(1, first_page.next_cursor.as_deref())
|
||||
.expect("second runs");
|
||||
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");
|
||||
assert_eq!(repos.data.len(), 1);
|
||||
@@ -1842,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]
|
||||
fn repeated_indexing_does_not_move_latest_backwards() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1938,6 +2356,163 @@ mod tests {
|
||||
.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) {
|
||||
let report = json!({
|
||||
"format_version": 2,
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RfcRef(pub &'static str);
|
||||
|
||||
/// Stable categories for warnings written to audit reports and exported metrics.
|
||||
///
|
||||
/// New warning sites should use a specific category when one is available. The
|
||||
/// default preserves the behaviour and schema of existing callers while making
|
||||
/// their metric label explicit.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum WarningCategory {
|
||||
#[default]
|
||||
Unclassified,
|
||||
BerCompatibleCmsEncoding,
|
||||
}
|
||||
|
||||
impl WarningCategory {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unclassified => "unclassified",
|
||||
Self::BerCompatibleCmsEncoding => "ber_compatible_cms_encoding",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Warning {
|
||||
pub message: String,
|
||||
pub category: WarningCategory,
|
||||
pub rfc_refs: Vec<RfcRef>,
|
||||
pub context: Option<String>,
|
||||
}
|
||||
@@ -12,11 +34,17 @@ impl Warning {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
category: WarningCategory::Unclassified,
|
||||
rfc_refs: Vec::new(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: WarningCategory) -> Self {
|
||||
self.category = category;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rfc_refs(mut self, refs: &[RfcRef]) -> Self {
|
||||
self.rfc_refs.extend_from_slice(refs);
|
||||
self
|
||||
|
||||
198
src/storage.rs
198
src/storage.rs
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine;
|
||||
use rocksdb::checkpoint::Checkpoint;
|
||||
use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -53,6 +54,13 @@ pub enum StorageError {
|
||||
|
||||
pub type StorageResult<T> = Result<T, StorageError>;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct RepositoryBlobVerificationSummary {
|
||||
pub current_objects: u64,
|
||||
pub bytes_verified: u64,
|
||||
pub batches: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ChildCertificateCacheMmapLookup {
|
||||
pub projections: Vec<Option<ChildCertificateCacheProjection>>,
|
||||
@@ -595,7 +603,7 @@ pub struct VcirFailedFetchReuseIdentity {
|
||||
pub ta_context_digest: [u8; 32],
|
||||
#[serde(rename = "p")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "f")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub policy_fingerprint: [u8; 32],
|
||||
@@ -605,6 +613,12 @@ pub struct VcirFailedFetchReuseIdentity {
|
||||
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 {
|
||||
pub fn validate_internal(&self) -> StorageResult<()> {
|
||||
let effective_not_before = parse_time(
|
||||
@@ -1117,7 +1131,7 @@ pub struct RoaCacheObjectMeta {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RoaCacheProjectionContext {
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
pub policy_fingerprint: [u8; 32],
|
||||
pub object_meta: Vec<RoaCacheObjectMeta>,
|
||||
}
|
||||
@@ -1258,7 +1272,7 @@ pub struct RoaCacheProjection {
|
||||
pub issuer_ca_sha256_hex: Option<String>,
|
||||
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "serde_optional_bytes_32")]
|
||||
pub parent_context_digest: Option<[u8; 32]>,
|
||||
pub ca_validation_context_digest: Option<[u8; 32]>,
|
||||
#[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "serde_optional_bytes_32")]
|
||||
pub policy_fingerprint: Option<[u8; 32]>,
|
||||
@@ -1400,7 +1414,7 @@ pub struct ChildCertificateCacheProjection {
|
||||
pub issuer_crl_sha256_hex: String,
|
||||
#[serde(rename = "pc")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "pf")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub validation_policy_fingerprint: [u8; 32],
|
||||
@@ -1652,6 +1666,9 @@ pub struct PublicationPointCacheObject {
|
||||
pub object_type: Option<String>,
|
||||
#[serde(rename = "s")]
|
||||
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 {
|
||||
@@ -1663,6 +1680,7 @@ impl PublicationPointCacheObject {
|
||||
sha256: artifact.sha256.clone(),
|
||||
object_type: artifact.object_type.clone(),
|
||||
validation_status: artifact.validation_status,
|
||||
reject_reason: artifact.reject_reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1674,6 +1692,7 @@ impl PublicationPointCacheObject {
|
||||
sha256: self.sha256.clone(),
|
||||
object_type: self.object_type.clone(),
|
||||
validation_status: self.validation_status,
|
||||
reject_reason: self.reject_reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,7 +1726,7 @@ pub struct PublicationPointCacheProjection {
|
||||
pub ta_context_digest: [u8; 32],
|
||||
#[serde(rename = "pc")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "pf")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub validation_policy_fingerprint: [u8; 32],
|
||||
@@ -1743,7 +1762,7 @@ impl PublicationPointCacheProjection {
|
||||
ca_cert_sha256: [u8; 32],
|
||||
manifest_sha256: [u8; 32],
|
||||
ta_context_digest: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
validation_policy_fingerprint: [u8; 32],
|
||||
) -> StorageResult<Self> {
|
||||
let projection = Self {
|
||||
@@ -1756,7 +1775,7 @@ impl PublicationPointCacheProjection {
|
||||
manifest_sha256,
|
||||
tal_id: vcir.tal_id.clone(),
|
||||
ta_context_digest,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
validation_policy_fingerprint,
|
||||
instance_effective_not_before: vcir.last_successful_validation_time.clone(),
|
||||
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
|
||||
@@ -1811,7 +1830,7 @@ impl PublicationPointCacheProjection {
|
||||
tal_id: self.tal_id.clone(),
|
||||
ca_subject_name: String::from("publication-point-cache"),
|
||||
ca_ski: hex::encode(self.ca_cert_sha256),
|
||||
issuer_ski: hex::encode(self.parent_context_digest),
|
||||
issuer_ski: hex::encode(self.ca_validation_context_digest),
|
||||
last_successful_validation_time: validation_time.clone(),
|
||||
current_manifest_rsync_uri: self.manifest_rsync_uri.clone(),
|
||||
current_crl_rsync_uri: self
|
||||
@@ -2042,7 +2061,7 @@ impl RoaCacheProjection {
|
||||
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest: Some(context.parent_context_digest),
|
||||
ca_validation_context_digest: Some(context.ca_validation_context_digest),
|
||||
policy_fingerprint: Some(context.policy_fingerprint),
|
||||
crl_sha256_by_uri,
|
||||
entries,
|
||||
@@ -2063,10 +2082,10 @@ impl RoaCacheProjection {
|
||||
if let Some(hash) = &self.issuer_ca_sha256_hex {
|
||||
validate_sha256_hex("roa_cache_projection.issuer_ca_sha256_hex", hash)?;
|
||||
}
|
||||
if self.parent_context_digest.is_some() != self.policy_fingerprint.is_some() {
|
||||
if self.ca_validation_context_digest.is_some() != self.policy_fingerprint.is_some() {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.context",
|
||||
detail: "parent_context_digest and policy_fingerprint must be both present or both absent"
|
||||
detail: "ca_validation_context_digest and policy_fingerprint must be both present or both absent"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
@@ -2149,6 +2168,11 @@ pub struct VcirRelatedArtifact {
|
||||
pub object_type: Option<String>,
|
||||
#[serde(rename = "s")]
|
||||
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 {
|
||||
@@ -2893,6 +2917,38 @@ fn write_publication_point_cache_projection_to_batch(
|
||||
}
|
||||
|
||||
impl RocksStore {
|
||||
pub fn create_read_only_checkpoint(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
if destination.exists() {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "work_db_checkpoint",
|
||||
detail: format!(
|
||||
"checkpoint destination already exists: {}",
|
||||
destination.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
}
|
||||
let mut options = Options::default();
|
||||
let blob_mode = work_db_blob_mode_from_env();
|
||||
let memory_profile = work_db_memory_profile_from_env();
|
||||
configure_work_db_options(&mut options, blob_mode, memory_profile);
|
||||
let db = DB::open_cf_descriptors_read_only(
|
||||
&options,
|
||||
source,
|
||||
column_family_descriptors_for_blob_mode(blob_mode),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
let checkpoint =
|
||||
Checkpoint::new(&db).map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
checkpoint
|
||||
.create_checkpoint(destination)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn open(path: &Path) -> StorageResult<Self> {
|
||||
let mut base_opts = Options::default();
|
||||
base_opts.create_if_missing(true);
|
||||
@@ -2931,6 +2987,15 @@ impl RocksStore {
|
||||
Self::open_with_external_stores(path, None, Some(repo_bytes_path))
|
||||
}
|
||||
|
||||
pub fn open_with_external_repo_bytes_read_only(
|
||||
path: &Path,
|
||||
repo_bytes_path: &Path,
|
||||
) -> StorageResult<Self> {
|
||||
let mut store = Self::open(path)?;
|
||||
store.external_repo_bytes = Some(ExternalRepoBytesDb::open_read_only(repo_bytes_path)?);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn open_with_external_stores(
|
||||
path: &Path,
|
||||
raw_store_path: Option<&Path>,
|
||||
@@ -3147,6 +3212,53 @@ impl RocksStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn verify_current_repository_blobs(
|
||||
&self,
|
||||
batch_size: usize,
|
||||
) -> StorageResult<RepositoryBlobVerificationSummary> {
|
||||
if batch_size == 0 {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: "batch_size must be greater than zero".to_string(),
|
||||
});
|
||||
}
|
||||
let cf = self.cf(CF_REPOSITORY_VIEW)?;
|
||||
let prefix = repository_view_prefix("");
|
||||
let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
|
||||
let mut summary = RepositoryBlobVerificationSummary::default();
|
||||
let mut batch = Vec::with_capacity(batch_size);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
if !key.starts_with(prefix.as_bytes()) {
|
||||
break;
|
||||
}
|
||||
let entry = decode_cbor::<RepositoryViewEntry>(&value, "repository_view")?;
|
||||
entry.validate_internal()?;
|
||||
if !matches!(
|
||||
entry.state,
|
||||
RepositoryViewState::Present | RepositoryViewState::Replaced
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let hash = entry
|
||||
.current_hash
|
||||
.clone()
|
||||
.ok_or(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!("current hash missing for {}", entry.rsync_uri),
|
||||
})?;
|
||||
batch.push((entry.rsync_uri, hash));
|
||||
if batch.len() >= batch_size {
|
||||
verify_repository_blob_batch(self, &batch, &mut summary)?;
|
||||
batch.clear();
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
verify_repository_blob_batch(self, &batch, &mut summary)?;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
|
||||
entry.validate_internal()?;
|
||||
if let Some(raw_store) = self.external_raw_store.as_ref() {
|
||||
@@ -3206,6 +3318,9 @@ impl RocksStore {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() {
|
||||
if repo_bytes.is_read_only() {
|
||||
return repo_bytes.require_existing_blob_bytes_batch(blobs);
|
||||
}
|
||||
return repo_bytes.put_blob_bytes_batch(blobs);
|
||||
}
|
||||
if let Some(raw_store) = self.external_raw_store.as_ref() {
|
||||
@@ -4424,6 +4539,36 @@ impl RocksStore {
|
||||
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> {
|
||||
let cf = self.cf(CF_VCIR)?;
|
||||
let mode = IteratorMode::Start;
|
||||
@@ -4682,6 +4827,37 @@ impl RocksStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_repository_blob_batch(
|
||||
store: &RocksStore,
|
||||
batch: &[(String, String)],
|
||||
summary: &mut RepositoryBlobVerificationSummary,
|
||||
) -> StorageResult<()> {
|
||||
let hashes = batch
|
||||
.iter()
|
||||
.map(|(_, hash)| hash.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let blobs = store.get_blob_bytes_batch(&hashes)?;
|
||||
for ((uri, expected_hash), blob) in batch.iter().zip(blobs.into_iter()) {
|
||||
let bytes = blob.as_ref().ok_or(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!("blob missing for URI {uri} (hash={expected_hash})"),
|
||||
})?;
|
||||
let actual_hash = hex::encode(compute_sha256_32(bytes));
|
||||
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!(
|
||||
"blob hash mismatch for URI {uri}: expected={expected_hash}, actual={actual_hash}"
|
||||
),
|
||||
});
|
||||
}
|
||||
summary.current_objects += 1;
|
||||
summary.bytes_verified += bytes.len() as u64;
|
||||
}
|
||||
summary.batches += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "storage/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -29,7 +29,7 @@ fn sample_child_certificate_cache_projection(
|
||||
issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"),
|
||||
issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(),
|
||||
issuer_crl_sha256_hex: sha256_hex(b"issuer-crl"),
|
||||
parent_context_digest: sha256_32(b"parent-context"),
|
||||
ca_validation_context_digest: sha256_32(b"parent-context"),
|
||||
validation_policy_fingerprint: sha256_32(b"policy"),
|
||||
effective_not_before: pack_time(0),
|
||||
effective_until: pack_time(24),
|
||||
@@ -257,6 +257,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
|
||||
sha256: sha256_hex(b"manifest-object"),
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
VcirRelatedArtifact {
|
||||
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||
@@ -265,6 +266,7 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
|
||||
sha256: sha256_hex(b"current-crl"),
|
||||
object_type: Some("crl".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
],
|
||||
summary: VcirSummary {
|
||||
@@ -294,7 +296,7 @@ fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCachePro
|
||||
})
|
||||
.expect("sample VCIR has a ROA output");
|
||||
RoaCacheProjectionContext {
|
||||
parent_context_digest: [0x31; 32],
|
||||
ca_validation_context_digest: [0x31; 32],
|
||||
policy_fingerprint: [0x32; 32],
|
||||
object_meta: vec![RoaCacheObjectMeta {
|
||||
source_object_uri: roa_output.source_object_uri.clone(),
|
||||
@@ -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]
|
||||
fn transport_prefetch_snapshot_roundtrips() {
|
||||
use crate::parallel::transport_prefetch::{
|
||||
@@ -2276,3 +2383,153 @@ fn pack_file_can_lazy_load_bytes_from_external_repo_bytes_store() {
|
||||
assert_eq!(file.bytes_cloned().expect("cloned repo bytes"), bytes);
|
||||
assert_eq!(file.compute_sha256().expect("compute sha256"), file.sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_checkpoint_isolated_from_source_work_db() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let source_path = td.path().join("source-work-db");
|
||||
let checkpoint_path = td.path().join("checkpoint-work-db");
|
||||
let uri = "rsync://example.test/repo/a.roa";
|
||||
let bytes = b"checkpoint-object";
|
||||
let hash = sha256_hex(bytes);
|
||||
{
|
||||
let source = RocksStore::open(&source_path).expect("open source");
|
||||
source
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
|
||||
.expect("put blob");
|
||||
source
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: uri.to_string(),
|
||||
current_hash: Some(hash),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
})
|
||||
.expect("put view");
|
||||
}
|
||||
|
||||
RocksStore::create_read_only_checkpoint(&source_path, &checkpoint_path)
|
||||
.expect("create checkpoint");
|
||||
let checkpoint = RocksStore::open(&checkpoint_path).expect("open checkpoint");
|
||||
checkpoint
|
||||
.delete_repository_view_entry(uri)
|
||||
.expect("delete checkpoint entry");
|
||||
drop(checkpoint);
|
||||
|
||||
let source = RocksStore::open(&source_path).expect("reopen source");
|
||||
assert!(
|
||||
source
|
||||
.get_repository_view_entry(uri)
|
||||
.expect("get source entry")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_external_repo_bytes_rejects_writes() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let path = td.path().join("repo-bytes.db");
|
||||
let hash = sha256_hex(b"repo-object");
|
||||
{
|
||||
let writable = ExternalRepoBytesDb::open(&path).expect("open writable repo bytes");
|
||||
writable
|
||||
.put_blob_bytes_batch(&[(hash.clone(), b"repo-object".to_vec())])
|
||||
.expect("seed repo bytes");
|
||||
}
|
||||
let read_only = ExternalRepoBytesDb::open_read_only(&path).expect("open read-only repo bytes");
|
||||
assert_eq!(
|
||||
read_only
|
||||
.get_blob_bytes(&hash)
|
||||
.expect("read repo bytes")
|
||||
.expect("blob"),
|
||||
b"repo-object"
|
||||
);
|
||||
assert!(
|
||||
read_only
|
||||
.put_blob_bytes_batch(&[(hash, b"repo-object".to_vec())])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_store_accepts_only_idempotent_existing_blob_apply() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let work_db_path = td.path().join("work-db");
|
||||
let repo_bytes_path = td.path().join("repo-bytes.db");
|
||||
let bytes = b"repo-object".to_vec();
|
||||
let hash = sha256_hex(&bytes);
|
||||
{
|
||||
let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("open repo bytes");
|
||||
repo_bytes
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
|
||||
.expect("seed repo bytes");
|
||||
}
|
||||
let store =
|
||||
RocksStore::open_with_external_repo_bytes_read_only(&work_db_path, &repo_bytes_path)
|
||||
.expect("open read-only store");
|
||||
store
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes)])
|
||||
.expect("idempotent apply");
|
||||
assert!(
|
||||
store
|
||||
.put_blob_bytes_batch(&[(hash, b"different".to_vec())])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_blob_verification_detects_missing_external_blob() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let repo_bytes_path = td.path().join("repo-bytes.db");
|
||||
ExternalRepoBytesDb::open(&repo_bytes_path).expect("create repo bytes");
|
||||
let store = RocksStore::open_with_external_repo_bytes_read_only(
|
||||
&td.path().join("work-db"),
|
||||
&repo_bytes_path,
|
||||
)
|
||||
.expect("open work db");
|
||||
store
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
|
||||
current_hash: Some(sha256_hex(b"missing")),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
})
|
||||
.expect("put view");
|
||||
let error = store
|
||||
.verify_current_repository_blobs(16)
|
||||
.expect_err("missing blob must fail");
|
||||
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()))?;
|
||||
if let Some(index) = current_repo_index {
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| RepoSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||
.apply_repository_view_entries(&repository_view_entries)
|
||||
.map_err(RepoSyncError::Storage)?;
|
||||
|
||||
@@ -1029,7 +1029,7 @@ fn hydrate_current_repo_index_from_rrdp_members(
|
||||
}
|
||||
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||
.apply_repository_view_entries(&entries)
|
||||
.map_err(RrdpSyncError::Storage)?;
|
||||
@@ -1184,7 +1184,7 @@ fn apply_delta(
|
||||
¤t_hash,
|
||||
);
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| {
|
||||
RrdpSyncError::Storage("current repo index lock poisoned".to_string())
|
||||
})?
|
||||
@@ -1240,7 +1240,7 @@ fn apply_delta(
|
||||
Some(previous_hash.clone()),
|
||||
);
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| {
|
||||
RrdpSyncError::Storage(
|
||||
"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()))?;
|
||||
if let Some(index) = current_repo_index {
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||
.apply_repository_view_entries(&repository_view_entries)
|
||||
.map_err(RrdpSyncError::Storage)?;
|
||||
@@ -365,7 +365,7 @@ fn flush_snapshot_publish_batch(
|
||||
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
|
||||
if let Some(index) = current_repo_index {
|
||||
index
|
||||
.lock()
|
||||
.write()
|
||||
.map_err(|_| RrdpSyncError::Storage("current repo index lock poisoned".to_string()))?
|
||||
.apply_repository_view_entries(&repository_view_entries)
|
||||
.map_err(RrdpSyncError::Storage)?;
|
||||
|
||||
@@ -1025,7 +1025,7 @@ fn sync_from_notification_same_serial_hydrates_current_repo_index() {
|
||||
.expect("same serial no-op");
|
||||
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!(index.get_by_uri(uri_a).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");
|
||||
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!(
|
||||
index.get_by_uri(uri_a).is_some(),
|
||||
|
||||
1139
src/ta_constraints.rs
Normal file
1139
src/ta_constraints.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -26,10 +27,13 @@ struct Args {
|
||||
once: bool,
|
||||
out_metrics: Option<PathBuf>,
|
||||
out_status: Option<PathBuf>,
|
||||
ccr_check_bin: Option<PathBuf>,
|
||||
ccr_check_timeout_secs: u64,
|
||||
ccr_check_max_per_scan: u64,
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>]"
|
||||
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>] [--ccr-check-bin <path>] [--ccr-check-timeout-secs <n>] [--ccr-check-max-per-scan <n>]"
|
||||
}
|
||||
|
||||
pub fn main_entry() -> Result<(), String> {
|
||||
@@ -38,8 +42,16 @@ pub fn main_entry() -> Result<(), String> {
|
||||
|
||||
fn real_main() -> Result<(), String> {
|
||||
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
|
||||
let ccr_check_config = CcrCheckConfig::from_args(&args);
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
if args.once {
|
||||
let snapshot = scan_run_root(&args.run_root, &args.instance)?;
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
&args.run_root,
|
||||
&args.instance,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
)?;
|
||||
let metrics = render_metrics(&snapshot);
|
||||
let status = render_status_json(&snapshot)?;
|
||||
if let Some(path) = args.out_metrics.as_ref() {
|
||||
@@ -53,7 +65,13 @@ fn real_main() -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let shared = Arc::new(RwLock::new(scan_run_root(&args.run_root, &args.instance)?));
|
||||
let shared = Arc::new(RwLock::new(scan_run_root_incremental_with_rtr_dir(
|
||||
&args.run_root,
|
||||
&args.instance,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
)?));
|
||||
let scanner = Arc::clone(&shared);
|
||||
let run_root = args.run_root.clone();
|
||||
let instance = args.instance.clone();
|
||||
@@ -62,20 +80,25 @@ fn real_main() -> Result<(), String> {
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(poll_secs));
|
||||
let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone();
|
||||
let next =
|
||||
match scan_run_root_incremental(&run_root, &instance, Some(&previous_snapshot)) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(err) => {
|
||||
let mut previous = scanner.write().expect("metrics lock poisoned");
|
||||
previous
|
||||
.service
|
||||
.parse_errors
|
||||
.push(format!("scan failed: {err}"));
|
||||
previous.service.last_reload_success = false;
|
||||
previous.service.last_scan_timestamp_seconds = unix_now_seconds();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let next = match scan_run_root_incremental_with_rtr_dir(
|
||||
&run_root,
|
||||
&instance,
|
||||
Some(&previous_snapshot),
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(err) => {
|
||||
let mut previous = scanner.write().expect("metrics lock poisoned");
|
||||
previous
|
||||
.service
|
||||
.parse_errors
|
||||
.push(format!("scan failed: {err}"));
|
||||
previous.service.last_reload_success = false;
|
||||
previous.service.last_scan_timestamp_seconds = unix_now_seconds();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
*scanner.write().expect("metrics lock poisoned") = next;
|
||||
}
|
||||
});
|
||||
@@ -91,6 +114,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
let mut once = false;
|
||||
let mut out_metrics = None;
|
||||
let mut out_status = None;
|
||||
let mut ccr_check_bin = None;
|
||||
let mut ccr_check_timeout_secs = 120u64;
|
||||
let mut ccr_check_max_per_scan = 2u64;
|
||||
let mut index = 1usize;
|
||||
while index < argv.len() {
|
||||
match argv[index].as_str() {
|
||||
@@ -122,6 +148,24 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
index += 1;
|
||||
out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?));
|
||||
}
|
||||
"--ccr-check-bin" => {
|
||||
index += 1;
|
||||
ccr_check_bin = Some(PathBuf::from(value_at(argv, index, "--ccr-check-bin")?));
|
||||
}
|
||||
"--ccr-check-timeout-secs" => {
|
||||
index += 1;
|
||||
let value = value_at(argv, index, "--ccr-check-timeout-secs")?;
|
||||
ccr_check_timeout_secs = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("invalid --ccr-check-timeout-secs: {value}"))?;
|
||||
}
|
||||
"--ccr-check-max-per-scan" => {
|
||||
index += 1;
|
||||
let value = value_at(argv, index, "--ccr-check-max-per-scan")?;
|
||||
ccr_check_max_per_scan = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("invalid --ccr-check-max-per-scan: {value}"))?;
|
||||
}
|
||||
"-h" | "--help" => return Err(usage().to_string()),
|
||||
other => return Err(format!("unknown argument: {other}\n{}", usage())),
|
||||
}
|
||||
@@ -135,6 +179,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
once,
|
||||
out_metrics,
|
||||
out_status,
|
||||
ccr_check_bin,
|
||||
ccr_check_timeout_secs,
|
||||
ccr_check_max_per_scan,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,9 +208,67 @@ struct MetricsSnapshot {
|
||||
top_pp_by_sync_duration: Vec<TopPublicationPoint>,
|
||||
cir: Option<CirMetrics>,
|
||||
ccr: Option<CcrMetrics>,
|
||||
ccr_check: CcrCheckMetrics,
|
||||
rtr: RtrMetrics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CcrCheckMetrics {
|
||||
enabled: bool,
|
||||
up: bool,
|
||||
bin: Option<String>,
|
||||
pass_total: u64,
|
||||
ccr_parse_error_total: u64,
|
||||
tool_error_total: u64,
|
||||
consecutive_failures: u64,
|
||||
last: Option<CcrCheckLastResult>,
|
||||
checked_run_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CcrCheckLastResult {
|
||||
run_dir: String,
|
||||
run_seq: Option<u64>,
|
||||
outcome: String,
|
||||
duration_seconds: f64,
|
||||
checked_at_seconds: f64,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CcrCheckConfig {
|
||||
bin: Option<PathBuf>,
|
||||
timeout_secs: u64,
|
||||
max_per_scan: u64,
|
||||
}
|
||||
|
||||
impl CcrCheckConfig {
|
||||
fn disabled() -> Self {
|
||||
Self {
|
||||
bin: None,
|
||||
timeout_secs: 120,
|
||||
max_per_scan: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_args(args: &Args) -> Self {
|
||||
let bin = args.ccr_check_bin.clone().or_else(|| {
|
||||
std::env::var("RPKI_METRICS_CCR_CHECK_BIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
});
|
||||
Self {
|
||||
bin,
|
||||
timeout_secs: args.ccr_check_timeout_secs,
|
||||
max_per_scan: args.ccr_check_max_per_scan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServiceMetrics {
|
||||
@@ -319,6 +424,7 @@ struct LatestRunMetrics {
|
||||
vaps: u64,
|
||||
publication_points: u64,
|
||||
warnings: u64,
|
||||
warning_categories: BTreeMap<String, u64>,
|
||||
tree_instances_processed: Option<u64>,
|
||||
tree_instances_failed: Option<u64>,
|
||||
stage_seconds: BTreeMap<String, f64>,
|
||||
@@ -328,6 +434,8 @@ struct LatestRunMetrics {
|
||||
download_bytes: Option<u64>,
|
||||
artifact_sizes: BTreeMap<String, u64>,
|
||||
state_path_sizes: BTreeMap<String, PathSize>,
|
||||
dead_repo_blacklist_entries: Option<u64>,
|
||||
dead_repo_blacklist_blacklisted: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -493,21 +601,13 @@ struct RunRecord {
|
||||
}
|
||||
|
||||
fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> {
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
scan_run_root_incremental_with_rtr_dir(input_root, instance, None, rtr_report_dir.as_deref())
|
||||
}
|
||||
|
||||
fn scan_run_root_incremental(
|
||||
input_root: &Path,
|
||||
instance: &str,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
) -> Result<MetricsSnapshot, String> {
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
scan_run_root_incremental_with_rtr_dir(
|
||||
input_root,
|
||||
instance,
|
||||
previous,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,6 +616,7 @@ fn scan_run_root_incremental_with_rtr_dir(
|
||||
instance: &str,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
rtr_report_dir: Option<&Path>,
|
||||
ccr_check_config: &CcrCheckConfig,
|
||||
) -> Result<MetricsSnapshot, String> {
|
||||
let started = Instant::now();
|
||||
let runs_root = resolve_runs_root(input_root);
|
||||
@@ -570,6 +671,7 @@ fn scan_run_root_incremental_with_rtr_dir(
|
||||
}
|
||||
|
||||
snapshot.rtr = scan_rtr_reports(rtr_report_dir, previous.map(|snapshot| &snapshot.rtr));
|
||||
run_pending_ccr_checks(&records, previous, ccr_check_config, &mut snapshot);
|
||||
snapshot.service.last_scan_timestamp_seconds = unix_now_seconds();
|
||||
snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
|
||||
snapshot.service.last_reload_success =
|
||||
@@ -975,6 +1077,218 @@ fn consecutive_failures(records: &[RunRecord]) -> u64 {
|
||||
count
|
||||
}
|
||||
|
||||
const MAX_CHECKED_RUN_DIRS: usize = 512;
|
||||
|
||||
fn run_pending_ccr_checks(
|
||||
records: &[RunRecord],
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
config: &CcrCheckConfig,
|
||||
snapshot: &mut MetricsSnapshot,
|
||||
) {
|
||||
let mut check = previous
|
||||
.map(|prev| prev.ccr_check.clone())
|
||||
.unwrap_or_default();
|
||||
check.enabled = config.bin.is_some();
|
||||
check.bin = config.bin.as_ref().map(|bin| bin.display().to_string());
|
||||
check.up = false;
|
||||
|
||||
let Some(bin) = config.bin.as_ref() else {
|
||||
snapshot.ccr_check = check;
|
||||
return;
|
||||
};
|
||||
if !bin.is_file() {
|
||||
snapshot
|
||||
.service
|
||||
.parse_errors
|
||||
.push(format!("ccr check bin is not a file: {}", bin.display()));
|
||||
snapshot.ccr_check = check;
|
||||
return;
|
||||
}
|
||||
check.up = true;
|
||||
|
||||
let mut pending: Vec<&RunRecord> = records
|
||||
.iter()
|
||||
.filter(|record| record.status == "success")
|
||||
.filter(|record| record.path.join("result.ccr").is_file())
|
||||
.filter(|record| {
|
||||
let dir = record.path.display().to_string();
|
||||
!check.checked_run_dirs.iter().any(|seen| seen == &dir)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if previous.is_none() {
|
||||
// On service start only the latest existing run is checked; older
|
||||
// history is marked as seen without executing checks.
|
||||
let latest_path = pending.last().map(|record| record.path.clone());
|
||||
for record in &pending {
|
||||
if Some(&record.path) != latest_path.as_ref() {
|
||||
check
|
||||
.checked_run_dirs
|
||||
.push(record.path.display().to_string());
|
||||
}
|
||||
}
|
||||
pending.retain(|record| Some(&record.path) == latest_path.as_ref());
|
||||
}
|
||||
|
||||
for record in pending
|
||||
.into_iter()
|
||||
.take(config.max_per_scan.max(1) as usize)
|
||||
{
|
||||
let ccr_path = record.path.join("result.ccr");
|
||||
let outcome = execute_ccr_check(bin, &ccr_path, Duration::from_secs(config.timeout_secs));
|
||||
let run_seq = record
|
||||
.summary
|
||||
.as_ref()
|
||||
.and_then(|v| json_u64(v, &["runSeq"]))
|
||||
.or_else(|| {
|
||||
record
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|v| json_u64(v, &["run_index"]))
|
||||
});
|
||||
let passed = outcome.outcome == "pass";
|
||||
match outcome.outcome.as_str() {
|
||||
"pass" => check.pass_total += 1,
|
||||
"ccr_parse_error" => check.ccr_parse_error_total += 1,
|
||||
_ => check.tool_error_total += 1,
|
||||
}
|
||||
check.consecutive_failures = if passed {
|
||||
0
|
||||
} else {
|
||||
check.consecutive_failures + 1
|
||||
};
|
||||
check.last = Some(CcrCheckLastResult {
|
||||
run_dir: record.path.display().to_string(),
|
||||
run_seq,
|
||||
outcome: outcome.outcome,
|
||||
duration_seconds: outcome.duration_seconds,
|
||||
checked_at_seconds: unix_now_seconds(),
|
||||
detail: outcome.detail,
|
||||
});
|
||||
check
|
||||
.checked_run_dirs
|
||||
.push(record.path.display().to_string());
|
||||
}
|
||||
if check.checked_run_dirs.len() > MAX_CHECKED_RUN_DIRS {
|
||||
let drain = check.checked_run_dirs.len() - MAX_CHECKED_RUN_DIRS;
|
||||
check.checked_run_dirs.drain(..drain);
|
||||
}
|
||||
snapshot.ccr_check = check;
|
||||
}
|
||||
|
||||
struct CcrCheckOutcome {
|
||||
outcome: String,
|
||||
duration_seconds: f64,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
fn execute_ccr_check(bin: &Path, ccr_path: &Path, timeout: Duration) -> CcrCheckOutcome {
|
||||
let started = Instant::now();
|
||||
let log_path = std::env::temp_dir().join(format!(
|
||||
"rpki-artifact-metrics-ccr-check-{}-{}.log",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let (outcome, detail) = execute_ccr_check_logged(bin, ccr_path, timeout, &log_path);
|
||||
let _ = fs::remove_file(&log_path);
|
||||
CcrCheckOutcome {
|
||||
outcome,
|
||||
duration_seconds: started.elapsed().as_secs_f64(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_ccr_check_logged(
|
||||
bin: &Path,
|
||||
ccr_path: &Path,
|
||||
timeout: Duration,
|
||||
log_path: &Path,
|
||||
) -> (String, Option<String>) {
|
||||
let log_file = match fs::File::create(log_path) {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("create ccr check log failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let log_err = match log_file.try_clone() {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("clone ccr check log handle failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut child = match Command::new(bin)
|
||||
.arg("-f")
|
||||
.arg(ccr_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(log_file))
|
||||
.stderr(Stdio::from(log_err))
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("spawn ccr check failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let started = Instant::now();
|
||||
let exit_code = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status.code(),
|
||||
Ok(None) => {
|
||||
if started.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("ccr check timed out after {}s", timeout.as_secs())),
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("wait ccr check failed: {err}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = fs::read_to_string(log_path).unwrap_or_default();
|
||||
classify_ccr_check_output(exit_code, ccr_path, &output)
|
||||
}
|
||||
|
||||
fn classify_ccr_check_output(
|
||||
exit_code: Option<i32>,
|
||||
ccr_path: &Path,
|
||||
output: &str,
|
||||
) -> (String, Option<String>) {
|
||||
if exit_code != Some(0) {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("rpki-client exited with code {exit_code:?}")),
|
||||
);
|
||||
}
|
||||
// rpki-client exits 0 even when the CCR file itself fails to parse, so
|
||||
// the file-level error line is the authoritative failure signal. This
|
||||
// mirrors scripts/ccr/check_rpki_client_ccr.sh.
|
||||
let marker = format!("rpki-client: {}:", ccr_path.display());
|
||||
if let Some(line) = output.lines().find(|line| line.contains(&marker)) {
|
||||
return ("ccr_parse_error".to_string(), Some(line.trim().to_string()));
|
||||
}
|
||||
("pass".to_string(), None)
|
||||
}
|
||||
|
||||
fn read_json_optional(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
@@ -1066,6 +1380,10 @@ fn build_latest_metrics(record: &RunRecord, snapshot: &mut MetricsSnapshot) {
|
||||
latest.download_bytes = json_u64(summary, &["stageTiming", "download_bytes_total"]);
|
||||
latest.artifact_sizes = extract_artifact_sizes(summary.get("artifacts"));
|
||||
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);
|
||||
@@ -1160,13 +1478,9 @@ fn parse_report(path: &Path, snapshot: &mut MetricsSnapshot, latest: &mut Latest
|
||||
.map(|a| a.len() as u64)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
latest.warnings = latest.warnings.max(
|
||||
report
|
||||
.pointer("/tree/warnings")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len() as u64)
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let (reported_warning_count, warning_categories) = collect_warning_categories(&report);
|
||||
latest.warnings = latest.warnings.max(reported_warning_count);
|
||||
latest.warning_categories = warning_categories;
|
||||
if let Some(processed) = json_u64(&report, &["tree", "instances_processed"]) {
|
||||
latest.tree_instances_processed = Some(processed);
|
||||
}
|
||||
@@ -1187,6 +1501,47 @@ fn parse_report(path: &Path, snapshot: &mut MetricsSnapshot, latest: &mut Latest
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_warning_categories(report: &Value) -> (u64, BTreeMap<String, u64>) {
|
||||
let mut total = 0u64;
|
||||
let mut categories = BTreeMap::new();
|
||||
total += count_warning_categories(
|
||||
report.pointer("/tree/warnings").and_then(Value::as_array),
|
||||
&mut categories,
|
||||
);
|
||||
if let Some(publication_points) = report.get("publication_points").and_then(Value::as_array) {
|
||||
for publication_point in publication_points {
|
||||
total += count_warning_categories(
|
||||
publication_point.get("warnings").and_then(Value::as_array),
|
||||
&mut categories,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Export the baseline and BER-compatibility series even when their count is
|
||||
// zero, so dashboards can distinguish a clean run from a missing metric.
|
||||
for category in ["unclassified", "ber_compatible_cms_encoding"] {
|
||||
categories.entry(category.to_string()).or_insert(0);
|
||||
}
|
||||
(total, categories)
|
||||
}
|
||||
|
||||
fn count_warning_categories(
|
||||
warnings: Option<&Vec<Value>>,
|
||||
categories: &mut BTreeMap<String, u64>,
|
||||
) -> u64 {
|
||||
let Some(warnings) = warnings else {
|
||||
return 0;
|
||||
};
|
||||
for warning in warnings {
|
||||
let category = warning
|
||||
.get("category")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unclassified");
|
||||
*categories.entry(category.to_string()).or_default() += 1;
|
||||
}
|
||||
warnings.len() as u64
|
||||
}
|
||||
|
||||
fn extract_publication_point_metrics(
|
||||
pps: &[Value],
|
||||
downloads: Option<&Value>,
|
||||
@@ -1634,10 +1989,60 @@ fn render_metrics(snapshot: &MetricsSnapshot) -> String {
|
||||
if let Some(ccr) = snapshot.ccr.as_ref() {
|
||||
render_ccr_metrics(&mut writer, instance, ccr);
|
||||
}
|
||||
render_ccr_check_metrics(&mut writer, instance, &snapshot.ccr_check);
|
||||
render_rtr_metrics(&mut writer, instance, snapshot);
|
||||
out
|
||||
}
|
||||
|
||||
fn render_ccr_check_metrics(writer: &mut PromWriter<'_>, instance: &str, check: &CcrCheckMetrics) {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_up",
|
||||
"Whether the CCR rpki-client format checker binary is configured and present",
|
||||
&[label("instance", instance)],
|
||||
bool_value(check.up),
|
||||
);
|
||||
for (result, value) in [
|
||||
("pass", check.pass_total),
|
||||
("ccr_parse_error", check.ccr_parse_error_total),
|
||||
("tool_error", check.tool_error_total),
|
||||
] {
|
||||
writer.counter(
|
||||
"ours_rp_ccr_format_check_total",
|
||||
"CCR rpki-client format check results by classification",
|
||||
&[label("instance", instance), label("result", result)],
|
||||
value as f64,
|
||||
);
|
||||
}
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_consecutive_failures",
|
||||
"Consecutive failed CCR rpki-client format checks",
|
||||
&[label("instance", instance)],
|
||||
check.consecutive_failures as f64,
|
||||
);
|
||||
if let Some(last) = check.last.as_ref() {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_success",
|
||||
"Whether the latest CCR rpki-client format check passed",
|
||||
&[label("instance", instance)],
|
||||
bool_value(last.outcome == "pass"),
|
||||
);
|
||||
if let Some(run_seq) = last.run_seq {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_run_sequence",
|
||||
"Run sequence of the latest checked CCR artifact",
|
||||
&[label("instance", instance)],
|
||||
run_seq as f64,
|
||||
);
|
||||
}
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_duration_seconds",
|
||||
"Duration of the latest CCR rpki-client format check",
|
||||
&[label("instance", instance)],
|
||||
last.duration_seconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
|
||||
let rtr = &snapshot.rtr;
|
||||
let scan_now = snapshot.service.last_scan_timestamp_seconds;
|
||||
@@ -2042,6 +2447,17 @@ fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &L
|
||||
&[label("instance", instance)],
|
||||
latest.warnings as f64,
|
||||
);
|
||||
for (category, count) in &latest.warning_categories {
|
||||
writer.gauge(
|
||||
"ours_rp_warnings_by_category",
|
||||
"Latest run warning count by category",
|
||||
&[
|
||||
label("instance", instance),
|
||||
label("warning_category", category),
|
||||
],
|
||||
*count as f64,
|
||||
);
|
||||
}
|
||||
if let Some(value) = latest.tree_instances_processed {
|
||||
writer.gauge(
|
||||
"ours_rp_tree_instances",
|
||||
@@ -2058,6 +2474,22 @@ fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &L
|
||||
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 {
|
||||
writer.gauge(
|
||||
"ours_rp_run_stage_duration_seconds",
|
||||
@@ -2525,6 +2957,7 @@ fn render_status_json(snapshot: &MetricsSnapshot) -> Result<String, String> {
|
||||
"latestRun": snapshot.latest_run,
|
||||
"cir": snapshot.cir,
|
||||
"ccr": snapshot.ccr,
|
||||
"ccrCheck": snapshot.ccr_check,
|
||||
"rtr": snapshot.rtr,
|
||||
"topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
|
||||
"topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
|
||||
@@ -2980,6 +3413,48 @@ mod tests {
|
||||
assert_eq!(args.out_metrics.as_deref(), Some(Path::new("metrics.prom")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_categories_cover_tree_and_publication_point_warnings() {
|
||||
let report = json!({
|
||||
"tree": {
|
||||
"warnings": [
|
||||
{"category": "ber_compatible_cms_encoding"},
|
||||
{"category": "ber_compatible_cms_encoding"}
|
||||
]
|
||||
},
|
||||
"publication_points": [
|
||||
{"warnings": [{"category": "repository_sync"}]},
|
||||
{"warnings": [{"message": "legacy warning without category"}]}
|
||||
]
|
||||
});
|
||||
|
||||
let (total, categories) = collect_warning_categories(&report);
|
||||
assert_eq!(total, 4);
|
||||
assert_eq!(categories["ber_compatible_cms_encoding"], 2);
|
||||
assert_eq!(categories["repository_sync"], 1);
|
||||
assert_eq!(categories["unclassified"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_warning_category_metrics() {
|
||||
let mut warning_categories = BTreeMap::new();
|
||||
warning_categories.insert("ber_compatible_cms_encoding".to_string(), 2);
|
||||
let snapshot = MetricsSnapshot {
|
||||
instance: "test".to_string(),
|
||||
latest_run: Some(LatestRunMetrics {
|
||||
warnings: 2,
|
||||
warning_categories,
|
||||
..LatestRunMetrics::default()
|
||||
}),
|
||||
..MetricsSnapshot::default()
|
||||
};
|
||||
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(
|
||||
r#"ours_rp_warnings_by_category{instance="test",warning_category="ber_compatible_cms_encoding"} 2"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_fixture_exports_repo_pp_cir_and_ccr_metrics() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
@@ -3162,8 +3637,14 @@ mod tests {
|
||||
)
|
||||
.expect("clients");
|
||||
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
|
||||
.expect("scan");
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
td.path(),
|
||||
"test",
|
||||
None,
|
||||
Some(&rtr),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
.expect("scan");
|
||||
assert!(snapshot.rtr.enabled);
|
||||
assert_eq!(snapshot.rtr.files["source"].seen, 1);
|
||||
assert_eq!(
|
||||
@@ -3233,8 +3714,14 @@ mod tests {
|
||||
)
|
||||
.expect("clients");
|
||||
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
|
||||
.expect("scan");
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
td.path(),
|
||||
"test",
|
||||
None,
|
||||
Some(&rtr),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
.expect("scan");
|
||||
assert!(snapshot.rtr.enabled);
|
||||
assert_eq!(snapshot.rtr.files["source"].seen, 1);
|
||||
assert!(snapshot.rtr.source.is_none());
|
||||
@@ -3247,6 +3734,223 @@ mod tests {
|
||||
assert!(metrics.contains(r#"ours_rp_rtr_metrics_enabled{instance="test"} 1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_accepts_ccr_check_options() {
|
||||
let args = parse_args(&[
|
||||
"rpki_artifact_metrics".to_string(),
|
||||
"--run-root".to_string(),
|
||||
"root".to_string(),
|
||||
"--ccr-check-bin".to_string(),
|
||||
"/opt/ours-rp/bin/rpki-client".to_string(),
|
||||
"--ccr-check-timeout-secs".to_string(),
|
||||
"60".to_string(),
|
||||
"--ccr-check-max-per-scan".to_string(),
|
||||
"3".to_string(),
|
||||
])
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
args.ccr_check_bin.as_deref(),
|
||||
Some(Path::new("/opt/ours-rp/bin/rpki-client"))
|
||||
);
|
||||
assert_eq!(args.ccr_check_timeout_secs, 60);
|
||||
assert_eq!(args.ccr_check_max_per_scan, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_disabled_without_bin() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let snapshot = scan_run_root(td.path(), "test").expect("scan");
|
||||
assert!(!snapshot.ccr_check.enabled);
|
||||
assert!(!snapshot.ccr_check.up);
|
||||
assert!(snapshot.ccr_check.last.is_none());
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
|
||||
assert!(!metrics.contains("ours_rp_ccr_format_check_last_success"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_pass_records_metrics() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert!(snapshot.ccr_check.up);
|
||||
assert_eq!(snapshot.ccr_check.pass_total, 1);
|
||||
assert_eq!(snapshot.ccr_check.consecutive_failures, 0);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "pass");
|
||||
assert_eq!(last.run_seq, Some(1));
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(
|
||||
metrics.contains(r#"ours_rp_ccr_format_check_total{instance="test",result="pass"} 1"#)
|
||||
);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 1"#));
|
||||
assert!(
|
||||
metrics.contains(r#"ours_rp_ccr_format_check_last_run_sequence{instance="test"} 1"#)
|
||||
);
|
||||
let status = render_status_json(&snapshot).expect("status");
|
||||
assert!(status.contains(r#""ccrCheck""#));
|
||||
assert!(status.contains(r#""passTotal": 1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_parse_error_detected_from_output() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(
|
||||
td.path(),
|
||||
"#!/bin/sh\necho \"rpki-client: $2: unexpected number of locations\" >&2\nexit 0\n",
|
||||
);
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(snapshot.ccr_check.ccr_parse_error_total, 1);
|
||||
assert_eq!(snapshot.ccr_check.consecutive_failures, 1);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "ccr_parse_error");
|
||||
assert!(
|
||||
last.detail
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("unexpected number of locations")
|
||||
);
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(
|
||||
r#"ours_rp_ccr_format_check_total{instance="test",result="ccr_parse_error"} 1"#
|
||||
));
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 0"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_nonzero_exit_is_tool_error() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 3\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(snapshot.ccr_check.tool_error_total, 1);
|
||||
assert_eq!(
|
||||
snapshot.ccr_check.last.as_ref().expect("last").outcome,
|
||||
"tool_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_timeout_is_tool_error() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nsleep 30\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 1);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "tool_error");
|
||||
assert!(
|
||||
last.detail
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("timed out")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_startup_only_checks_latest_and_new_runs_once() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
write_success_run(td.path(), "run_0002", 2);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
|
||||
|
||||
// First scan at service start: only the latest existing run is
|
||||
// checked, older history is marked as seen without executing checks.
|
||||
let first = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(first.ccr_check.pass_total, 1);
|
||||
assert!(
|
||||
first
|
||||
.ccr_check
|
||||
.last
|
||||
.as_ref()
|
||||
.expect("last")
|
||||
.run_dir
|
||||
.contains("run_0002")
|
||||
);
|
||||
|
||||
// Second scan: pre-existing history must not be backfilled.
|
||||
let second = scan_with_ccr_check(td.path(), Some(&first), &bin, 30);
|
||||
assert_eq!(second.ccr_check.pass_total, 1);
|
||||
|
||||
// A newly completed run is checked exactly once.
|
||||
write_success_run(td.path(), "run_0003", 3);
|
||||
let third = scan_with_ccr_check(td.path(), Some(&second), &bin, 30);
|
||||
assert_eq!(third.ccr_check.pass_total, 2);
|
||||
assert_eq!(
|
||||
third.ccr_check.last.as_ref().expect("last").run_seq,
|
||||
Some(3)
|
||||
);
|
||||
let fourth = scan_with_ccr_check(td.path(), Some(&third), &bin, 30);
|
||||
assert_eq!(fourth.ccr_check.pass_total, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_missing_bin_marks_not_up() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let missing = td.path().join("no-such-rpki-client");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &missing, 30);
|
||||
assert!(snapshot.ccr_check.enabled);
|
||||
assert!(!snapshot.ccr_check.up);
|
||||
assert!(snapshot.ccr_check.last.is_none());
|
||||
assert!(
|
||||
snapshot
|
||||
.service
|
||||
.parse_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("ccr check bin is not a file"))
|
||||
);
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
|
||||
}
|
||||
|
||||
fn write_success_run(root: &Path, name: &str, seq: u64) {
|
||||
let run = root.join("runs").join(name);
|
||||
fs::create_dir_all(&run).expect("create run");
|
||||
fs::write(
|
||||
run.join("run-meta.json"),
|
||||
format!(
|
||||
r#"{{"status":"success","run_index":{seq},"run_id":"{name}","sync_mode":"snapshot","snapshot_reason":"first_run","started_at_rfc3339_utc":"2026-07-21T00:00:00Z","completed_at_rfc3339_utc":"2026-07-21T00:00:10Z"}}"#
|
||||
),
|
||||
)
|
||||
.expect("meta");
|
||||
fs::write(
|
||||
run.join("run-summary.json"),
|
||||
format!(
|
||||
r#"{{"runSeq":{seq},"runId":"{name}","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0}}"#
|
||||
),
|
||||
)
|
||||
.expect("summary");
|
||||
fs::write(run.join("result.ccr"), sample_ccr()).expect("ccr");
|
||||
}
|
||||
|
||||
fn write_fake_rpki_client(root: &Path, body: &str) -> PathBuf {
|
||||
let path = root.join("fake-rpki-client.sh");
|
||||
fs::write(&path, body).expect("write fake rpki-client");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn scan_with_ccr_check(
|
||||
root: &Path,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
bin: &Path,
|
||||
timeout_secs: u64,
|
||||
) -> MetricsSnapshot {
|
||||
let config = CcrCheckConfig {
|
||||
bin: Some(bin.to_path_buf()),
|
||||
timeout_secs,
|
||||
max_per_scan: 2,
|
||||
};
|
||||
scan_run_root_incremental_with_rtr_dir(root, "test", previous, None, &config).expect("scan")
|
||||
}
|
||||
|
||||
fn sample_cir() -> Vec<u8> {
|
||||
let rejected = vec![CirRejectedObject {
|
||||
object_uri: "rsync://repo.example/a/bad.roa".to_string(),
|
||||
@@ -3302,4 +4006,37 @@ mod tests {
|
||||
});
|
||||
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::time::Duration;
|
||||
|
||||
use crate::parallel::dead_repo_blacklist::DeadRepoBlacklist;
|
||||
use crate::parallel::types::RepoTransportMode;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Args {
|
||||
state_root: PathBuf,
|
||||
@@ -21,6 +24,12 @@ struct Args {
|
||||
db_stats_bin: Option<PathBuf>,
|
||||
db_stats_exact_every: Option<u64>,
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -52,6 +61,23 @@ struct DaemonStatus {
|
||||
current_run_seq: Option<u64>,
|
||||
current_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)]
|
||||
@@ -145,6 +171,16 @@ struct RunSummary {
|
||||
db_stats: Vec<DbStatsSummary>,
|
||||
retention_deleted_runs: Vec<String>,
|
||||
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 {
|
||||
@@ -170,6 +206,17 @@ Options:
|
||||
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)
|
||||
--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
|
||||
|
||||
Child argument placeholders:
|
||||
@@ -211,12 +258,16 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
let mut db_stats_exact_every = None;
|
||||
let mut time_bin = default_time_bin();
|
||||
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;
|
||||
while i < argv.len() {
|
||||
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 =
|
||||
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"));
|
||||
@@ -225,6 +276,23 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
if no_time_wrapper {
|
||||
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 {
|
||||
state_root,
|
||||
rpki_bin: rpki_bin
|
||||
@@ -240,6 +308,10 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
db_stats_bin,
|
||||
db_stats_exact_every,
|
||||
time_bin,
|
||||
dead_repo_blacklist,
|
||||
dead_repo_blacklist_fail_threshold,
|
||||
dead_repo_health_check_interval_secs,
|
||||
dead_repo_probe_rsync_bin,
|
||||
child_args,
|
||||
};
|
||||
return validate_args(args);
|
||||
@@ -315,6 +387,33 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
"--no-time-wrapper" => {
|
||||
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())),
|
||||
}
|
||||
i += 1;
|
||||
@@ -330,6 +429,11 @@ fn validate_args(args: Args) -> Result<Args, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -387,10 +491,19 @@ fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<(), String>
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create parent dir failed: {}: {e}", parent.display()))?;
|
||||
}
|
||||
let file =
|
||||
File::create(path).map_err(|e| format!("create json failed: {}: {e}", path.display()))?;
|
||||
serde_json::to_writer_pretty(file, value)
|
||||
.map_err(|e| format!("write json failed: {}: {e}", path.display()))
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|e| format!("serialize json failed: {}: {e}", path.display()))?;
|
||||
// Atomic write (tmp + rename) so concurrent readers never see a torn file.
|
||||
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> {
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
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(
|
||||
args: &Args,
|
||||
state: DaemonState,
|
||||
runs_completed: u64,
|
||||
current: Option<&RunContext>,
|
||||
last_run_id: Option<String>,
|
||||
health: &DeadRepoHealthRuntime,
|
||||
) -> Result<(), String> {
|
||||
let updated_at_rfc3339_utc = format_rfc3339(utc_now())?;
|
||||
let status = DaemonStatus {
|
||||
@@ -425,10 +556,131 @@ fn write_status(
|
||||
current_run_seq: current.map(|ctx| ctx.seq),
|
||||
current_run_id: current.map(|ctx| ctx.run_id.clone()),
|
||||
last_run_id,
|
||||
dead_repo_blacklist: dead_repo_blacklist_status(args, health),
|
||||
};
|
||||
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> {
|
||||
args.iter()
|
||||
.map(|arg| {
|
||||
@@ -634,6 +886,7 @@ fn run_child_once(args: &Args, ctx: &RunContext) -> Result<RunSummary, String> {
|
||||
db_stats: Vec::new(),
|
||||
retention_deleted_runs: Vec::new(),
|
||||
artifacts,
|
||||
dead_repo_blacklist: None,
|
||||
};
|
||||
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.db_stats = collect_db_stats(args, ctx);
|
||||
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> {
|
||||
@@ -1050,13 +1311,17 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
||||
let mut runs_completed = 0u64;
|
||||
let mut next_seq = 1u64;
|
||||
let mut last_run_id = None;
|
||||
let mut health = DeadRepoHealthRuntime::default();
|
||||
write_status(
|
||||
args,
|
||||
DaemonState::Starting,
|
||||
runs_completed,
|
||||
None,
|
||||
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 {
|
||||
if args.max_runs.is_some_and(|max| runs_completed >= max) {
|
||||
@@ -1069,6 +1334,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
||||
runs_completed,
|
||||
None,
|
||||
last_run_id.clone(),
|
||||
&health,
|
||||
)?;
|
||||
let ctx = make_run_context(args, next_seq, utc_now());
|
||||
write_status(
|
||||
@@ -1077,6 +1343,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
||||
runs_completed,
|
||||
Some(&ctx),
|
||||
last_run_id.clone(),
|
||||
&health,
|
||||
)?;
|
||||
let mut summary = run_child_once(args, &ctx)?;
|
||||
write_status(
|
||||
@@ -1085,6 +1352,7 @@ fn run_daemon(args: &Args) -> Result<(), String> {
|
||||
runs_completed,
|
||||
Some(&ctx),
|
||||
last_run_id.clone(),
|
||||
&health,
|
||||
)?;
|
||||
collect_post_run_metrics(args, &ctx, &mut summary);
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
// Health probes run only while no child is active (single-writer).
|
||||
maybe_run_dead_repo_health_check(args, &mut health);
|
||||
write_status(
|
||||
args,
|
||||
DaemonState::Sleeping,
|
||||
runs_completed,
|
||||
None,
|
||||
last_run_id.clone(),
|
||||
&health,
|
||||
)?;
|
||||
if args.interval_secs > 0 {
|
||||
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 {
|
||||
@@ -1154,6 +1432,10 @@ mod tests {
|
||||
db_stats_bin: None,
|
||||
db_stats_exact_every: 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()],
|
||||
}
|
||||
}
|
||||
@@ -1330,6 +1612,10 @@ mod tests {
|
||||
db_stats_bin: None,
|
||||
db_stats_exact_every: 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![
|
||||
"{state_root}/state/work-db".to_string(),
|
||||
"{run_out}/result.ccr".to_string(),
|
||||
@@ -1652,6 +1938,10 @@ mod tests {
|
||||
db_stats_bin: None,
|
||||
db_stats_exact_every: 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![
|
||||
"-c".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_exact_every: Some(1),
|
||||
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![
|
||||
"-c".to_string(),
|
||||
"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)
|
||||
);
|
||||
}
|
||||
|
||||
#[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<'_>,
|
||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||
) -> Result<(), CaPathError> {
|
||||
child
|
||||
.verify_signature(Some(issuer_spki))
|
||||
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
|
||||
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
|
||||
.verify_signature(Some(issuer_spki))
|
||||
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() {
|
||||
use crate::data_model::rc::{AsIdOrRange, IpPrefix};
|
||||
|
||||
@@ -385,8 +385,16 @@ fn verify_ee_cert_signature(
|
||||
ee: &X509Certificate<'_>,
|
||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||
) -> Result<(), CertPathError> {
|
||||
ee.verify_signature(Some(issuer_spki))
|
||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||
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))
|
||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_ee_cert_signature_fast(
|
||||
@@ -394,12 +402,20 @@ fn verify_ee_cert_signature_fast(
|
||||
ee_signature_bytes: &[u8],
|
||||
issuer_spki: &SubjectPublicKeyInfo<'_>,
|
||||
) -> Result<(), CertPathError> {
|
||||
let key = signature::UnparsedPublicKey::new(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
&issuer_spki.subject_public_key.data,
|
||||
);
|
||||
key.verify(ee_tbs_der, ee_signature_bytes)
|
||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||
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(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
&issuer_spki.subject_public_key.data,
|
||||
);
|
||||
key.verify(ee_tbs_der, ee_signature_bytes)
|
||||
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
.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)]
|
||||
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)")]
|
||||
RepoSyncFailed { detail: String },
|
||||
|
||||
@@ -222,7 +227,8 @@ impl ManifestFreshError {
|
||||
pub(crate) fn should_warn_when_current_instance_reused(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
ManifestFreshError::RepoSyncFailed { .. }
|
||||
ManifestFreshError::VerificationOnlyForcedCurrentInstanceVcir { .. }
|
||||
| ManifestFreshError::RepoSyncFailed { .. }
|
||||
| ManifestFreshError::MissingManifest { .. }
|
||||
| ManifestFreshError::MissingFile { .. }
|
||||
)
|
||||
@@ -633,7 +639,7 @@ pub(crate) fn try_build_fresh_publication_point_with_timing(
|
||||
> {
|
||||
let mut timing = FreshPublicationPointTimingBreakdown::default();
|
||||
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;
|
||||
|
||||
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(),
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
VcirRelatedArtifact {
|
||||
artifact_role: VcirArtifactRole::SignedObject,
|
||||
@@ -1207,6 +1214,7 @@ mod tests {
|
||||
sha256: locked_object_sha256.to_string(),
|
||||
object_type: Some("roa".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
],
|
||||
summary: VcirSummary {
|
||||
@@ -1437,8 +1445,8 @@ mod tests {
|
||||
}
|
||||
|
||||
current_index
|
||||
.lock()
|
||||
.expect("index lock")
|
||||
.write()
|
||||
.expect("index write lock")
|
||||
.apply_repository_view_entries(&entries)
|
||||
.expect("apply current index");
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ use crate::data_model::rc::{
|
||||
ResourceCertificate,
|
||||
};
|
||||
use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError};
|
||||
use crate::data_model::signed_object::SignedObjectVerifyError;
|
||||
use crate::data_model::signed_object::{RpkiSignedObject, SignedObjectVerifyError};
|
||||
use crate::parallel::config::ParallelPhase2Config;
|
||||
use crate::parallel::object_worker::{
|
||||
ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
|
||||
};
|
||||
use crate::policy::{Policy, ResourceValidationMode, SignedObjectFailurePolicy};
|
||||
use crate::report::{RfcRef, Warning};
|
||||
use crate::report::{RfcRef, Warning, WarningCategory};
|
||||
use crate::storage::{
|
||||
PackFile, PackTime, RoaCacheObjectMeta, RoaCacheProjection, VcirLocalOutput,
|
||||
VcirLocalOutputPayload, VcirOutputType, VcirSourceObjectType,
|
||||
@@ -34,6 +34,22 @@ const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")];
|
||||
const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] =
|
||||
&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")];
|
||||
|
||||
fn ber_compatible_cms_warning(der: &[u8], rsync_uri: &str, object_kind: &str) -> Option<Warning> {
|
||||
let strict_error = RpkiSignedObject::strict_cms_der_error(der)?;
|
||||
Some(
|
||||
Warning::new(format!(
|
||||
"accepted BER-compatible CMS encoding for {object_kind}: {rsync_uri}: {strict_error}"
|
||||
))
|
||||
.with_category(WarningCategory::BerCompatibleCmsEncoding)
|
||||
.with_rfc_refs(&[RfcRef("X.690 §10"), RfcRef("RFC 6488 §2")])
|
||||
.with_context(rsync_uri),
|
||||
)
|
||||
}
|
||||
|
||||
fn ber_compatible_cms_warning_for_file(file: &PackFile, object_kind: &str) -> Option<Warning> {
|
||||
ber_compatible_cms_warning(file.bytes().ok()?, &file.rsync_uri, object_kind)
|
||||
}
|
||||
|
||||
fn sha256_hex_to_32(hex_value: &str) -> [u8; 32] {
|
||||
let bytes = hex::decode(hex_value).expect("internal sha256 hex should decode");
|
||||
let mut out = [0u8; 32];
|
||||
@@ -180,7 +196,7 @@ pub struct RoaValidationCacheStats {
|
||||
pub struct RoaValidationCacheInput<'a> {
|
||||
enabled: bool,
|
||||
view: Option<&'a RoaValidationCacheView>,
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
@@ -189,7 +205,7 @@ impl<'a> RoaValidationCacheInput<'a> {
|
||||
Self {
|
||||
enabled: false,
|
||||
view: None,
|
||||
parent_context_digest: None,
|
||||
ca_validation_context_digest: None,
|
||||
policy_fingerprint: None,
|
||||
}
|
||||
}
|
||||
@@ -198,20 +214,20 @@ impl<'a> RoaValidationCacheInput<'a> {
|
||||
Self {
|
||||
enabled: true,
|
||||
view,
|
||||
parent_context_digest: None,
|
||||
ca_validation_context_digest: None,
|
||||
policy_fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled_with_context(
|
||||
view: Option<&'a RoaValidationCacheView>,
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
) -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
view,
|
||||
parent_context_digest: Some(parent_context_digest),
|
||||
ca_validation_context_digest: Some(ca_validation_context_digest),
|
||||
policy_fingerprint: Some(policy_fingerprint),
|
||||
}
|
||||
}
|
||||
@@ -495,7 +511,7 @@ fn evaluate_roa_cache_crl_gate(
|
||||
pub struct RoaValidationCacheView {
|
||||
entries_by_uri: HashMap<String, CachedRoaValidationResult>,
|
||||
issuer_ca_sha256_hex: Option<String>,
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
crl_sha256_by_uri: HashMap<String, String>,
|
||||
blocked: bool,
|
||||
@@ -519,7 +535,7 @@ impl RoaValidationCacheView {
|
||||
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
|
||||
HashMap::with_capacity(projection.entries.len());
|
||||
let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone();
|
||||
let parent_context_digest = projection.parent_context_digest;
|
||||
let ca_validation_context_digest = projection.ca_validation_context_digest;
|
||||
let policy_fingerprint = projection.policy_fingerprint;
|
||||
let crl_sha256_by_uri = projection
|
||||
.crl_sha256_by_uri
|
||||
@@ -536,7 +552,7 @@ impl RoaValidationCacheView {
|
||||
return Self {
|
||||
entries_by_uri,
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
policy_fingerprint,
|
||||
crl_sha256_by_uri,
|
||||
blocked,
|
||||
@@ -580,7 +596,7 @@ impl RoaValidationCacheView {
|
||||
Self {
|
||||
entries_by_uri,
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
policy_fingerprint,
|
||||
crl_sha256_by_uri,
|
||||
blocked,
|
||||
@@ -590,7 +606,7 @@ impl RoaValidationCacheView {
|
||||
fn matches_current_context(
|
||||
&self,
|
||||
issuer_ca_der: &[u8],
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
) -> bool {
|
||||
if self.blocked {
|
||||
@@ -604,8 +620,8 @@ impl RoaValidationCacheView {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(expected_parent_context) = self.parent_context_digest {
|
||||
if Some(expected_parent_context) != parent_context_digest {
|
||||
if let Some(expected_ca_validation_context) = self.ca_validation_context_digest {
|
||||
if Some(expected_ca_validation_context) != ca_validation_context_digest {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
@@ -797,7 +813,7 @@ fn active_roa_cache_view<'a>(
|
||||
let gate_started = Instant::now();
|
||||
if view.matches_current_context(
|
||||
issuer_ca_der,
|
||||
roa_cache.parent_context_digest,
|
||||
roa_cache.ca_validation_context_digest,
|
||||
roa_cache.policy_fingerprint,
|
||||
) {
|
||||
stats.context_gate_nanos = stats
|
||||
@@ -945,6 +961,38 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
timing: Option<&TimingHandle>,
|
||||
collect_vcir_local_outputs: bool,
|
||||
roa_cache: RoaValidationCacheInput<'_>,
|
||||
) -> ObjectsOutput {
|
||||
process_publication_point_for_issuer_with_cache_options_and_ta_constraints(
|
||||
publication_point,
|
||||
policy,
|
||||
issuer_ca_der,
|
||||
issuer_ca_rsync_uri,
|
||||
issuer_effective_ip,
|
||||
issuer_effective_as,
|
||||
validation_time,
|
||||
timing,
|
||||
collect_vcir_local_outputs,
|
||||
roa_cache,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Serial signed-object processing with an optional, locally configured
|
||||
/// constraint set for the TA that owns this publication-point tree.
|
||||
pub fn process_publication_point_for_issuer_with_cache_options_and_ta_constraints<
|
||||
P: PublicationPointData,
|
||||
>(
|
||||
publication_point: &P,
|
||||
policy: &Policy,
|
||||
issuer_ca_der: &[u8],
|
||||
issuer_ca_rsync_uri: Option<&str>,
|
||||
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
|
||||
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
|
||||
validation_time: time::OffsetDateTime,
|
||||
timing: Option<&TimingHandle>,
|
||||
collect_vcir_local_outputs: bool,
|
||||
roa_cache: RoaValidationCacheInput<'_>,
|
||||
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
|
||||
) -> ObjectsOutput {
|
||||
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
|
||||
let manifest_bytes = publication_point.manifest_bytes();
|
||||
@@ -1015,6 +1063,12 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(warning) =
|
||||
ber_compatible_cms_warning(manifest_bytes, manifest_rsync_uri, "manifest")
|
||||
{
|
||||
warnings.push(warning);
|
||||
}
|
||||
|
||||
// Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates.
|
||||
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
|
||||
Ok(v) => v,
|
||||
@@ -1249,6 +1303,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
policy.strict.cms_der,
|
||||
policy.strict.name,
|
||||
policy.resource_validation_mode,
|
||||
ta_constraints,
|
||||
)
|
||||
}
|
||||
blocked @ (RoaCacheLookupResult::HashBlocked
|
||||
@@ -1275,6 +1330,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
policy.strict.cms_der,
|
||||
policy.strict.name,
|
||||
policy.resource_validation_mode,
|
||||
ta_constraints,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1298,6 +1354,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
policy.strict.cms_der,
|
||||
policy.strict.name,
|
||||
policy.resource_validation_mode,
|
||||
ta_constraints,
|
||||
)
|
||||
};
|
||||
match result.outcome {
|
||||
@@ -1317,6 +1374,9 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
result: AuditObjectResult::Ok,
|
||||
detail: None,
|
||||
});
|
||||
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
Err(e) => match policy.signed_object_failure_policy {
|
||||
SignedObjectFailurePolicy::DropObject => {
|
||||
@@ -1412,6 +1472,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
policy.strict.cms_der,
|
||||
policy.strict.name,
|
||||
policy.resource_validation_mode,
|
||||
ta_constraints,
|
||||
) {
|
||||
Ok((att, local_output)) => {
|
||||
stats.aspa_ok += 1;
|
||||
@@ -1426,6 +1487,9 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
|
||||
result: AuditObjectResult::Ok,
|
||||
detail: None,
|
||||
});
|
||||
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
Err(e) => match policy.signed_object_failure_policy {
|
||||
SignedObjectFailurePolicy::DropObject => {
|
||||
@@ -1762,6 +1826,10 @@ pub(crate) struct RoaTaskShared {
|
||||
issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>,
|
||||
issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>,
|
||||
resource_validation_mode: ResourceValidationMode,
|
||||
/// Immutable constraints snapshot selected by the owning TAL. Every
|
||||
/// ROA task for a publication point shares this Arc, so workers do not
|
||||
/// need a mutable/global policy lookup or a copy of the interval rules.
|
||||
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1883,6 +1951,7 @@ fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskRe
|
||||
task.strict_cms_der,
|
||||
task.strict_name,
|
||||
task.resource_validation_mode,
|
||||
shared.ta_constraints.as_deref(),
|
||||
)
|
||||
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
|
||||
vrps,
|
||||
@@ -1981,6 +2050,40 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
|
||||
validation_time: time::OffsetDateTime,
|
||||
collect_vcir_local_outputs: bool,
|
||||
roa_cache: RoaValidationCacheInput<'_>,
|
||||
) -> ParallelObjectsPrepare {
|
||||
prepare_publication_point_for_parallel_roa_with_cache_and_ta_constraints(
|
||||
publication_point_id,
|
||||
publication_point,
|
||||
policy,
|
||||
issuer_ca_der,
|
||||
issuer_ca_rsync_uri,
|
||||
issuer_effective_ip,
|
||||
issuer_effective_as,
|
||||
validation_time,
|
||||
collect_vcir_local_outputs,
|
||||
roa_cache,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Prepare a publication point for parallel ROA validation with the
|
||||
/// immutable constraints snapshot belonging to its TAL. The snapshot is
|
||||
/// moved into the stage-owned shared payload and therefore remains available
|
||||
/// to detached ROA workers after the scoped phase-2 stage worker returns.
|
||||
pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache_and_ta_constraints<
|
||||
P: PublicationPointData,
|
||||
>(
|
||||
publication_point_id: u64,
|
||||
publication_point: &P,
|
||||
policy: &Policy,
|
||||
issuer_ca_der: &[u8],
|
||||
issuer_ca_rsync_uri: Option<&str>,
|
||||
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
|
||||
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
|
||||
validation_time: time::OffsetDateTime,
|
||||
collect_vcir_local_outputs: bool,
|
||||
roa_cache: RoaValidationCacheInput<'_>,
|
||||
ta_constraints: Option<Arc<crate::ta_constraints::TaConstraints>>,
|
||||
) -> ParallelObjectsPrepare {
|
||||
let manifest_rsync_uri = publication_point.manifest_rsync_uri();
|
||||
let manifest_bytes = publication_point.manifest_bytes();
|
||||
@@ -2050,6 +2153,12 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(warning) =
|
||||
ber_compatible_cms_warning(manifest_bytes, manifest_rsync_uri, "manifest")
|
||||
{
|
||||
warnings.push(warning);
|
||||
}
|
||||
|
||||
let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
@@ -2289,6 +2398,7 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
|
||||
issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
|
||||
issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
|
||||
resource_validation_mode: policy.resource_validation_mode,
|
||||
ta_constraints,
|
||||
}),
|
||||
validation_time,
|
||||
collect_vcir_local_outputs,
|
||||
@@ -2367,6 +2477,9 @@ pub(crate) fn reduce_parallel_roa_stage(
|
||||
result: AuditObjectResult::Ok,
|
||||
detail: None,
|
||||
});
|
||||
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ROA") {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
audit.push(ObjectAuditEntry {
|
||||
@@ -2404,6 +2517,7 @@ pub(crate) fn reduce_parallel_roa_stage(
|
||||
strict_cms_der,
|
||||
strict_name,
|
||||
shared.resource_validation_mode,
|
||||
shared.ta_constraints.as_deref(),
|
||||
) {
|
||||
Ok((att, local_output)) => {
|
||||
stats.aspa_ok += 1;
|
||||
@@ -2418,6 +2532,9 @@ pub(crate) fn reduce_parallel_roa_stage(
|
||||
result: AuditObjectResult::Ok,
|
||||
detail: None,
|
||||
});
|
||||
if let Some(warning) = ber_compatible_cms_warning_for_file(file, "ASPA") {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
audit.push(ObjectAuditEntry {
|
||||
@@ -2620,6 +2737,9 @@ pub(crate) enum ObjectValidateError {
|
||||
"EE certificate resources are not a subset of issuer effective resources (RFC 6487 §7.2; RFC 3779)"
|
||||
)]
|
||||
EeResourcesNotSubset,
|
||||
|
||||
#[error("EE certificate violates locally configured TA constraints: {0}")]
|
||||
TaConstraints(#[from] crate::ta_constraints::TaConstraintsViolation),
|
||||
}
|
||||
|
||||
pub(crate) fn validate_roa_task_serial(
|
||||
@@ -2639,6 +2759,7 @@ pub(crate) fn validate_roa_task_serial(
|
||||
strict_cms_der: bool,
|
||||
strict_name: bool,
|
||||
resource_validation_mode: ResourceValidationMode,
|
||||
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
|
||||
) -> RoaTaskResult {
|
||||
let outcome = process_roa_with_issuer(
|
||||
task.file,
|
||||
@@ -2657,6 +2778,7 @@ pub(crate) fn validate_roa_task_serial(
|
||||
strict_cms_der,
|
||||
strict_name,
|
||||
resource_validation_mode,
|
||||
ta_constraints,
|
||||
)
|
||||
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
|
||||
vrps,
|
||||
@@ -2692,6 +2814,7 @@ fn process_roa_with_issuer(
|
||||
strict_cms_der: bool,
|
||||
strict_name: bool,
|
||||
resource_validation_mode: ResourceValidationMode,
|
||||
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
|
||||
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
|
||||
let _decode = timing
|
||||
.as_ref()
|
||||
@@ -2752,6 +2875,10 @@ fn process_roa_with_issuer(
|
||||
)?;
|
||||
drop(_subset);
|
||||
|
||||
if let Some(ta_constraints) = ta_constraints {
|
||||
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
|
||||
}
|
||||
|
||||
let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
|
||||
let cache_object_meta = RoaCacheObjectMeta {
|
||||
source_object_uri: file.rsync_uri.clone(),
|
||||
@@ -2821,6 +2948,7 @@ fn process_roa_with_issuer_parallel_cached(
|
||||
strict_cms_der: bool,
|
||||
strict_name: bool,
|
||||
resource_validation_mode: ResourceValidationMode,
|
||||
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
|
||||
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
|
||||
let _decode = timing
|
||||
.as_ref()
|
||||
@@ -2887,6 +3015,10 @@ fn process_roa_with_issuer_parallel_cached(
|
||||
)?;
|
||||
drop(_subset);
|
||||
|
||||
if let Some(ta_constraints) = ta_constraints {
|
||||
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
|
||||
}
|
||||
|
||||
let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
|
||||
let cache_object_meta = RoaCacheObjectMeta {
|
||||
source_object_uri: file.rsync_uri.clone(),
|
||||
@@ -2956,6 +3088,7 @@ fn process_aspa_with_issuer(
|
||||
strict_cms_der: bool,
|
||||
strict_name: bool,
|
||||
resource_validation_mode: ResourceValidationMode,
|
||||
ta_constraints: Option<&crate::ta_constraints::TaConstraints>,
|
||||
) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> {
|
||||
let _decode = timing
|
||||
.as_ref()
|
||||
@@ -3016,6 +3149,10 @@ fn process_aspa_with_issuer(
|
||||
)?;
|
||||
drop(_subset);
|
||||
|
||||
if let Some(ta_constraints) = ta_constraints {
|
||||
ta_constraints.validate_ee_certificate(&ee.resource_cert)?;
|
||||
}
|
||||
|
||||
validate_aspa_customer_in_vrs(&aspa, ee_vrs.asn.as_ref())?;
|
||||
|
||||
let attestation = AspaAttestation {
|
||||
@@ -3914,7 +4051,7 @@ mod tests {
|
||||
OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time")
|
||||
}
|
||||
|
||||
const TEST_PARENT_CONTEXT: [u8; 32] = [0x70; 32];
|
||||
const TEST_CA_VALIDATION_CONTEXT: [u8; 32] = [0x70; 32];
|
||||
const TEST_POLICY_FINGERPRINT: [u8; 32] = [0x71; 32];
|
||||
const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl";
|
||||
const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa";
|
||||
@@ -3933,7 +4070,7 @@ mod tests {
|
||||
RoaCacheProjection::from_vcir_with_context(
|
||||
vcir,
|
||||
Some(&RoaCacheProjectionContext {
|
||||
parent_context_digest: TEST_PARENT_CONTEXT,
|
||||
ca_validation_context_digest: TEST_CA_VALIDATION_CONTEXT,
|
||||
policy_fingerprint: TEST_POLICY_FINGERPRINT,
|
||||
object_meta: vec![RoaCacheObjectMeta {
|
||||
source_object_uri: TEST_ROA_URI.to_string(),
|
||||
@@ -4023,6 +4160,7 @@ mod tests {
|
||||
sha256: sha256_hex(issuer_der),
|
||||
object_type: Some("cer".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
VcirRelatedArtifact {
|
||||
artifact_role: VcirArtifactRole::CurrentCrl,
|
||||
@@ -4031,6 +4169,7 @@ mod tests {
|
||||
sha256: sha256_hex_from_32(&crl_hash),
|
||||
object_type: Some("crl".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
},
|
||||
],
|
||||
summary: VcirSummary {
|
||||
@@ -4071,7 +4210,7 @@ mod tests {
|
||||
|
||||
assert!(view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
|
||||
@@ -4202,7 +4341,10 @@ mod tests {
|
||||
fixed_time("2026-06-08T00:00:00Z"),
|
||||
);
|
||||
let projection = sample_roa_cache_projection(&vcir, roa_hash);
|
||||
assert_eq!(projection.parent_context_digest, Some(TEST_PARENT_CONTEXT));
|
||||
assert_eq!(
|
||||
projection.ca_validation_context_digest,
|
||||
Some(TEST_CA_VALIDATION_CONTEXT)
|
||||
);
|
||||
assert_eq!(projection.policy_fingerprint, Some(TEST_POLICY_FINGERPRINT));
|
||||
assert_eq!(
|
||||
projection.entries[0].outputs_effective_until_unix,
|
||||
@@ -4245,7 +4387,7 @@ mod tests {
|
||||
let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
|
||||
assert!(!issuer_changed.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
@@ -4258,7 +4400,7 @@ mod tests {
|
||||
));
|
||||
assert!(!parent_changed.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some([0x98; 32])
|
||||
));
|
||||
|
||||
@@ -4283,7 +4425,7 @@ mod tests {
|
||||
RoaValidationCacheView::from_projection(&expired_projection, validation_time);
|
||||
assert!(!expired_view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
|
||||
@@ -4540,6 +4682,7 @@ mod tests {
|
||||
sha256: "00".repeat(32),
|
||||
object_type: Some("cer".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Rejected,
|
||||
reject_reason: None,
|
||||
},
|
||||
);
|
||||
vcir.local_outputs.push(VcirLocalOutput {
|
||||
@@ -4563,7 +4706,7 @@ mod tests {
|
||||
|
||||
assert!(view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -4595,43 +4738,43 @@ mod tests {
|
||||
let mut view = RoaValidationCacheView {
|
||||
entries_by_uri: HashMap::new(),
|
||||
issuer_ca_sha256_hex: None,
|
||||
parent_context_digest: Some(TEST_PARENT_CONTEXT),
|
||||
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
|
||||
crl_sha256_by_uri: HashMap::new(),
|
||||
blocked: false,
|
||||
};
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
view.issuer_ca_sha256_hex = Some("00".repeat(32));
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
|
||||
assert!(view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
assert!(!view.matches_current_context(b"issuer-ca", None, Some(TEST_POLICY_FINGERPRINT)));
|
||||
|
||||
view.parent_context_digest = None;
|
||||
view.ca_validation_context_digest = None;
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
view.parent_context_digest = Some(TEST_PARENT_CONTEXT);
|
||||
view.ca_validation_context_digest = Some(TEST_CA_VALIDATION_CONTEXT);
|
||||
view.policy_fingerprint = None;
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
}
|
||||
@@ -4661,7 +4804,7 @@ mod tests {
|
||||
let mut view = RoaValidationCacheView {
|
||||
entries_by_uri: HashMap::new(),
|
||||
issuer_ca_sha256_hex: Some(sha256_hex(issuer_der)),
|
||||
parent_context_digest: Some(TEST_PARENT_CONTEXT),
|
||||
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
|
||||
crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]),
|
||||
blocked: false,
|
||||
@@ -4849,7 +4992,7 @@ mod tests {
|
||||
false,
|
||||
RoaValidationCacheInput::enabled_with_context(
|
||||
Some(&view),
|
||||
TEST_PARENT_CONTEXT,
|
||||
TEST_CA_VALIDATION_CONTEXT,
|
||||
TEST_POLICY_FINGERPRINT,
|
||||
),
|
||||
) {
|
||||
@@ -5459,6 +5602,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parallel_stage_roa_tasks_share_stage_owned_payloads() {
|
||||
let ta_constraints = Arc::new(
|
||||
crate::ta_constraints::TaConstraints::from_file(
|
||||
&std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/ta_constraints/afrinic-current-ipv4-deny.constraints"),
|
||||
)
|
||||
.expect("load constraints fixture"),
|
||||
);
|
||||
let stage = ParallelObjectsStage {
|
||||
publication_point_id: 7,
|
||||
shared: Arc::new(RoaTaskShared {
|
||||
@@ -5489,6 +5639,7 @@ mod tests {
|
||||
issuer_effective_ip: None,
|
||||
issuer_effective_as: None,
|
||||
resource_validation_mode: ResourceValidationMode::default(),
|
||||
ta_constraints: Some(Arc::clone(&ta_constraints)),
|
||||
}),
|
||||
validation_time: OffsetDateTime::now_utc(),
|
||||
collect_vcir_local_outputs: false,
|
||||
@@ -5508,6 +5659,14 @@ mod tests {
|
||||
let tasks = stage.build_roa_tasks();
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert!(Arc::ptr_eq(&tasks[0].shared, &tasks[1].shared));
|
||||
assert!(Arc::ptr_eq(
|
||||
tasks[0]
|
||||
.shared
|
||||
.ta_constraints
|
||||
.as_ref()
|
||||
.expect("task constraints snapshot"),
|
||||
&ta_constraints
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -119,7 +119,7 @@ fn snapshot_current_repo_objects(
|
||||
return Vec::new();
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -328,20 +328,73 @@ fn persist_transport_request_prefetch_snapshot(
|
||||
}
|
||||
let snapshot = runtime.transport_prefetch_snapshot();
|
||||
let recorded = snapshot.requests.len() as u64;
|
||||
let persist_started = std::time::Instant::now();
|
||||
store
|
||||
.put_transport_prefetch_snapshot(&snapshot)
|
||||
.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);
|
||||
crate::progress_log::emit(
|
||||
"phase1_repo_prefetch_persisted",
|
||||
serde_json::json!({
|
||||
"recorded_requests": recorded,
|
||||
"schema_version": snapshot.schema_version,
|
||||
"persist_ms": persist_ms,
|
||||
}),
|
||||
);
|
||||
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(
|
||||
tal_input: &TalInputSpec,
|
||||
http_fetcher: &dyn Fetcher,
|
||||
@@ -810,6 +863,7 @@ where
|
||||
run_tree_serial_audit(root, &runner, config)?
|
||||
};
|
||||
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 download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
||||
Ok(RunTreeFromTalAuditOutput {
|
||||
@@ -847,6 +901,10 @@ where
|
||||
H: Fetcher + Clone + 'static,
|
||||
R: crate::fetch::rsync::RsyncFetcher + Clone + 'static,
|
||||
{
|
||||
// Constraints are an immutable per-run policy snapshot. The phase-2
|
||||
// ready-stage binds the snapshot to each CA's TAL and moves an Arc into
|
||||
// ROA/ASPA worker state, so constrained multi-TAL runs retain the same
|
||||
// parallel scheduler as unconstrained runs.
|
||||
let phase2_enabled = phase2_config.is_some();
|
||||
if tal_inputs.is_empty() {
|
||||
return Err(RunTreeFromTalError::Replay(
|
||||
@@ -933,6 +991,7 @@ where
|
||||
run_tree_serial_audit_multi_root(root_handles, &runner, config)?
|
||||
};
|
||||
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 download_stats = DownloadLogHandle::stats_from_events(&downloads);
|
||||
Ok(RunTreeFromTalAuditOutput {
|
||||
@@ -2392,8 +2451,8 @@ mod multi_tal_tests {
|
||||
fn snapshot_current_repo_objects_is_on_demand() {
|
||||
let handle = CurrentRepoIndex::shared();
|
||||
handle
|
||||
.lock()
|
||||
.expect("lock index")
|
||||
.write()
|
||||
.expect("write-lock index")
|
||||
.apply_repository_view_entries(&[RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/a.roa".to_string(),
|
||||
current_hash: Some("11".repeat(32)),
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::validation::publication_point::PublicationPointSnapshot;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const DEFAULT_MAX_CA_DEPTH: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TreeRunConfig {
|
||||
/// Max CA instance depth to process (0 = root only).
|
||||
@@ -37,7 +39,7 @@ pub struct TreeRunConfig {
|
||||
impl Default for TreeRunConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: None,
|
||||
max_depth: Some(DEFAULT_MAX_CA_DEPTH),
|
||||
max_instances: None,
|
||||
compact_audit: false,
|
||||
persist_vcir: true,
|
||||
@@ -51,6 +53,15 @@ impl Default for TreeRunConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ca_depth_is_allowed(config: &TreeRunConfig, depth: usize) -> bool {
|
||||
config.max_depth.is_none_or(|max_depth| depth <= max_depth)
|
||||
}
|
||||
|
||||
pub(crate) fn next_allowed_ca_depth(config: &TreeRunConfig, parent_depth: usize) -> Option<usize> {
|
||||
let child_depth = parent_depth.checked_add(1)?;
|
||||
ca_depth_is_allowed(config, child_depth).then_some(child_depth)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CaCertificateRef {
|
||||
InlineDer(Vec<u8>),
|
||||
@@ -347,10 +358,8 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_depth) = config.max_depth {
|
||||
if ca.depth > max_depth {
|
||||
continue;
|
||||
}
|
||||
if !ca_depth_is_allowed(config, ca.depth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let res = match runner.run_publication_point(ca) {
|
||||
@@ -393,6 +402,9 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
}
|
||||
publication_points.push(audit);
|
||||
|
||||
let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else {
|
||||
continue;
|
||||
};
|
||||
let mut children = res.discovered_children;
|
||||
children.sort_by(|a, b| {
|
||||
a.handle
|
||||
@@ -413,7 +425,7 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
for child in children {
|
||||
queue.push_back(QueuedCaInstance {
|
||||
id: next_id,
|
||||
handle: child.handle.with_depth(ca.depth + 1),
|
||||
handle: child.handle.with_depth(child_depth),
|
||||
parent_id: Some(node.id),
|
||||
discovered_from: Some(child.discovered_from),
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
mod vcir_der;
|
||||
|
||||
use crate::analysis::timing::TimingHandle;
|
||||
use crate::audit::{
|
||||
AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit,
|
||||
@@ -55,6 +53,7 @@ use crate::validation::objects::{
|
||||
AspaAttestation, ParallelRoaWorkerPool, RoaValidationCacheInput, RoaValidationCacheView,
|
||||
RouterKeyPayload, Vrp, process_publication_point_for_issuer_parallel_roa_with_cache_options,
|
||||
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options,
|
||||
process_publication_point_for_issuer_with_cache_options_and_ta_constraints,
|
||||
};
|
||||
use crate::validation::publication_point::PublicationPointSnapshot;
|
||||
use crate::validation::tree::{
|
||||
@@ -69,7 +68,7 @@ use base64::Engine as _;
|
||||
use x509_parser::prelude::FromDer;
|
||||
use x509_parser::x509::SubjectPublicKeyInfo;
|
||||
|
||||
use vcir_der::encode_access_description_der_for_vcir_ccr_projection;
|
||||
use crate::ccr::manifest_location::select_manifest_signed_object_location;
|
||||
|
||||
const PUBLICATION_POINT_CACHE_CHILD_RESTORE_PARALLEL_MIN_CHILDREN: usize = 256;
|
||||
const PUBLICATION_POINT_CACHE_CHILD_RESTORE_MAX_WORKERS: usize = 16;
|
||||
@@ -304,7 +303,8 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
self.finish_publication_point_cache_miss(ca, "ta_context_mismatch", lookup_started);
|
||||
return None;
|
||||
}
|
||||
if projection.parent_context_digest != current_identity.parent_context_digest {
|
||||
if projection.ca_validation_context_digest != current_identity.ca_validation_context_digest
|
||||
{
|
||||
self.finish_publication_point_cache_miss(ca, "parent_context_mismatch", lookup_started);
|
||||
return None;
|
||||
}
|
||||
@@ -505,14 +505,14 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
ca_cert_sha256,
|
||||
manifest_sha256,
|
||||
ta_context_digest: ta_context_digest_for_ca(ca),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
|
||||
})
|
||||
}
|
||||
|
||||
fn current_hash_for_uri(&self, uri: &str) -> Option<[u8; 32]> {
|
||||
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) {
|
||||
return Some(entry.current_hash);
|
||||
}
|
||||
@@ -761,7 +761,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
|
||||
fn current_manifest_hash_hex_for_audit(&self, ca: &CaInstanceHandle) -> Option<String> {
|
||||
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)
|
||||
{
|
||||
return Some(entry.current_hash_hex.clone());
|
||||
@@ -808,6 +808,21 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
repo_sync_ok: bool,
|
||||
repo_sync_err: Option<&str>,
|
||||
) -> 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 issuer_ca_der = ca_certificate_der_for_validation(ca, self.store, self.timing.as_ref())
|
||||
.map_err(|detail| FreshPublicationPointStageError {
|
||||
@@ -938,7 +953,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
Some(ChildCertificateValidationCacheContext {
|
||||
store: self.store,
|
||||
issuer_ca_sha256: sha256_digest_32(issuer_ca_der.as_ref()),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
|
||||
})
|
||||
} else {
|
||||
@@ -1530,19 +1545,37 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
|
||||
let roa_cache = if self.enable_roa_validation_cache && has_roa {
|
||||
RoaValidationCacheInput::enabled_with_context(
|
||||
roa_cache_view.as_ref(),
|
||||
parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest_for_ca(ca),
|
||||
publication_point_cache_policy_fingerprint(self.policy),
|
||||
)
|
||||
} else {
|
||||
RoaValidationCacheInput::disabled()
|
||||
};
|
||||
let objects_processing_started = std::time::Instant::now();
|
||||
let ta_constraints = self.policy.ta_constraints.for_tal(&ca.tal_id);
|
||||
let mut objects = {
|
||||
let _objects_total = self
|
||||
.timing
|
||||
.as_ref()
|
||||
.map(|t| t.span_phase("objects_processing_total"));
|
||||
if let Some(phase2_pool) = self.parallel_roa_worker_pool.as_ref() {
|
||||
if let Some(ta_constraints) = ta_constraints {
|
||||
// This method is the serial/fallback publication-point path. The
|
||||
// phase-2 scheduler uses the stage-specific parallel prepare path,
|
||||
// which carries the same immutable per-TAL snapshot into workers.
|
||||
process_publication_point_for_issuer_with_cache_options_and_ta_constraints(
|
||||
&fresh_point,
|
||||
self.policy,
|
||||
issuer_ca_der.as_ref(),
|
||||
ca.ca_certificate_rsync_uri.as_deref(),
|
||||
ca.effective_ip_resources.as_ref(),
|
||||
ca.effective_as_resources.as_ref(),
|
||||
self.validation_time,
|
||||
self.timing.as_ref(),
|
||||
false,
|
||||
roa_cache,
|
||||
Some(ta_constraints),
|
||||
)
|
||||
} else if let Some(phase2_pool) = self.parallel_roa_worker_pool.as_ref() {
|
||||
process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
|
||||
&fresh_point,
|
||||
self.policy,
|
||||
@@ -1946,7 +1979,7 @@ struct PublicationPointCacheIdentity {
|
||||
ca_cert_sha256: [u8; 32],
|
||||
manifest_sha256: [u8; 32],
|
||||
ta_context_digest: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
}
|
||||
|
||||
@@ -1957,13 +1990,13 @@ fn sha256_digest_32(bytes: impl AsRef<[u8]>) -> [u8; 32] {
|
||||
out
|
||||
}
|
||||
|
||||
fn hash_serialized_parts(parts: &[(&str, Vec<u8>)]) -> [u8; 32] {
|
||||
fn hash_serialized_parts(parts: &[(&str, &[u8])]) -> [u8; 32] {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
for (label, value) in parts {
|
||||
hasher.update((label.len() as u64).to_be_bytes());
|
||||
hasher.update(label.as_bytes());
|
||||
hasher.update((value.len() as u64).to_be_bytes());
|
||||
hasher.update(value);
|
||||
hasher.update(*value);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
@@ -1977,41 +2010,34 @@ fn cbor_or_debug_bytes<T: serde::Serialize + std::fmt::Debug>(value: &T) -> Vec<
|
||||
|
||||
fn ta_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
hash_serialized_parts(&[
|
||||
("version", b"publication-point-cache-ta-v1".to_vec()),
|
||||
("tal_id", ca.tal_id.as_bytes().to_vec()),
|
||||
("version", b"publication-point-cache-ta-v1"),
|
||||
("tal_id", ca.tal_id.as_bytes()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn parent_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
pub(crate) fn ca_validation_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
let parent_manifest = ca
|
||||
.parent_manifest_rsync_uri
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.as_bytes();
|
||||
let effective_ip = cbor_or_debug_bytes(&ca.effective_ip_resources);
|
||||
let effective_as = cbor_or_debug_bytes(&ca.effective_as_resources);
|
||||
hash_serialized_parts(&[
|
||||
(
|
||||
"version",
|
||||
b"publication-point-cache-parent-context-v1".to_vec(),
|
||||
),
|
||||
("tal_id", ca.tal_id.as_bytes().to_vec()),
|
||||
(
|
||||
"parent_manifest",
|
||||
ca.parent_manifest_rsync_uri
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
"effective_ip",
|
||||
cbor_or_debug_bytes(&ca.effective_ip_resources),
|
||||
),
|
||||
(
|
||||
"effective_as",
|
||||
cbor_or_debug_bytes(&ca.effective_as_resources),
|
||||
),
|
||||
("version", b"publication-point-cache-parent-context-v1"),
|
||||
("tal_id", ca.tal_id.as_bytes()),
|
||||
("parent_manifest", parent_manifest),
|
||||
("effective_ip", effective_ip.as_slice()),
|
||||
("effective_as", effective_as.as_slice()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn publication_point_cache_policy_fingerprint(policy: &Policy) -> [u8; 32] {
|
||||
let policy_bytes = cbor_or_debug_bytes(policy);
|
||||
hash_serialized_parts(&[
|
||||
("version", b"publication-point-cache-policy-v1".to_vec()),
|
||||
("policy", cbor_or_debug_bytes(policy)),
|
||||
("version", b"publication-point-cache-policy-v3"),
|
||||
("policy", policy_bytes.as_slice()),
|
||||
("ta_constraints", policy.ta_constraints.fingerprint_bytes()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2058,7 +2084,7 @@ fn pack_time_window_contains(
|
||||
struct ChildCertificateValidationCacheContext<'a> {
|
||||
store: &'a RocksStore,
|
||||
issuer_ca_sha256: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
}
|
||||
|
||||
@@ -2066,16 +2092,17 @@ fn child_certificate_cache_key_sha256_hex(
|
||||
child_cert_uri: &str,
|
||||
child_cert_sha256: &[u8; 32],
|
||||
issuer_ca_sha256: &[u8; 32],
|
||||
parent_context_digest: &[u8; 32],
|
||||
ca_validation_context_digest: &[u8; 32],
|
||||
policy_fingerprint: &[u8; 32],
|
||||
) -> String {
|
||||
let digest = hash_serialized_parts(&[
|
||||
("version", b"child-certificate-cache-key-v1".to_vec()),
|
||||
("child_cert_uri", child_cert_uri.as_bytes().to_vec()),
|
||||
("child_cert_sha256", child_cert_sha256.to_vec()),
|
||||
("issuer_ca_sha256", issuer_ca_sha256.to_vec()),
|
||||
("parent_context_digest", parent_context_digest.to_vec()),
|
||||
("policy_fingerprint", policy_fingerprint.to_vec()),
|
||||
("version", b"child-certificate-cache-key-v1"),
|
||||
("child_cert_uri", child_cert_uri.as_bytes()),
|
||||
("child_cert_sha256", child_cert_sha256),
|
||||
("issuer_ca_sha256", issuer_ca_sha256),
|
||||
// Keep the persisted cache-key label stable; this change only renames Rust identifiers.
|
||||
("parent_context_digest", ca_validation_context_digest),
|
||||
("policy_fingerprint", policy_fingerprint),
|
||||
]);
|
||||
sha256_hex_from_32(&digest)
|
||||
}
|
||||
@@ -2469,7 +2496,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
));
|
||||
}
|
||||
@@ -2968,6 +2995,24 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
|
||||
match router_result {
|
||||
Ok(router) => {
|
||||
if let Some(ta_constraints) = policy.ta_constraints.for_tal(&issuer.tal_id)
|
||||
{
|
||||
if let Err(error) =
|
||||
ta_constraints.validate_ee_certificate(&router.resource_cert)
|
||||
{
|
||||
router_error = router_error.saturating_add(1);
|
||||
audits.push(ObjectAuditEntry {
|
||||
rsync_uri: f.rsync_uri.clone(),
|
||||
sha256_hex: sha256_hex_from_32(&f.sha256),
|
||||
kind: AuditObjectKind::RouterCertificate,
|
||||
result: AuditObjectResult::Error,
|
||||
detail: Some(format!(
|
||||
"router certificate violates TA constraints: {error}"
|
||||
)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let router_asns = match router_asns_for_resource_mode(
|
||||
&router.asns,
|
||||
issuer.effective_as_resources.as_ref(),
|
||||
@@ -3022,7 +3067,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
);
|
||||
let projection = ChildCertificateCacheProjection {
|
||||
@@ -3040,7 +3085,8 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
),
|
||||
issuer_crl_uri: issuer_crl_uri.clone(),
|
||||
issuer_crl_sha256_hex: verified_crl.sha256_hex.clone(),
|
||||
parent_context_digest: cache.parent_context_digest,
|
||||
ca_validation_context_digest: cache
|
||||
.ca_validation_context_digest,
|
||||
validation_policy_fingerprint: cache.policy_fingerprint,
|
||||
effective_not_before,
|
||||
effective_until,
|
||||
@@ -3239,7 +3285,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
);
|
||||
let projection = ChildCertificateCacheProjection {
|
||||
@@ -3255,7 +3301,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
issuer_ca_sha256_hex: sha256_hex_from_32(&cache.issuer_ca_sha256),
|
||||
issuer_crl_uri: issuer_crl_uri.clone(),
|
||||
issuer_crl_sha256_hex: verified_crl.sha256_hex.clone(),
|
||||
parent_context_digest: cache.parent_context_digest,
|
||||
ca_validation_context_digest: cache.ca_validation_context_digest,
|
||||
validation_policy_fingerprint: cache.policy_fingerprint,
|
||||
effective_not_before,
|
||||
effective_until,
|
||||
@@ -3836,6 +3882,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(
|
||||
ca: &CaInstanceHandle,
|
||||
source: PublicationPointSource,
|
||||
@@ -4055,7 +4123,10 @@ fn build_publication_point_audit_from_vcir(
|
||||
sha256_hex: artifact.sha256.clone(),
|
||||
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
||||
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(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4166,7 +4237,10 @@ fn build_publication_point_audit_from_publication_point_cache_projection(
|
||||
sha256_hex: artifact.sha256.clone(),
|
||||
kind: kind_from_vcir_artifact_kind(artifact.artifact_kind),
|
||||
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(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4395,7 +4469,7 @@ fn failed_fetch_reuse_identity_for_fresh_result(
|
||||
let identity = VcirFailedFetchReuseIdentity {
|
||||
current_ca_sha256,
|
||||
ta_context_digest: ta_context_digest_for_ca(ca),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
|
||||
effective_not_before: PackTime::from_utc_offset_datetime(validation_time),
|
||||
effective_until,
|
||||
@@ -4418,7 +4492,7 @@ fn failed_fetch_reuse_identity_matches_current(
|
||||
};
|
||||
cached.current_ca_sha256 == current_ca_sha256
|
||||
&& cached.ta_context_digest == ta_context_digest_for_ca(ca)
|
||||
&& cached.parent_context_digest == parent_context_digest_for_ca(ca)
|
||||
&& cached.ca_validation_context_digest == ca_validation_context_digest_for_ca(ca)
|
||||
&& cached.policy_fingerprint == publication_point_cache_policy_fingerprint(policy)
|
||||
&& cached.effective_until == vcir.instance_gate.instance_effective_until
|
||||
&& cached.contains_validation_time(validation_time)
|
||||
@@ -5347,7 +5421,7 @@ fn persist_vcir_for_fresh_result_with_timing(
|
||||
.replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
|
||||
&vcir,
|
||||
Some(&RoaCacheProjectionContext {
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
|
||||
object_meta: objects.roa_cache_object_meta.clone(),
|
||||
}),
|
||||
@@ -5378,7 +5452,7 @@ fn build_publication_point_cache_projection_from_fresh(
|
||||
ca_cert_sha256,
|
||||
manifest_sha256,
|
||||
ta_context_digest_for_ca(ca),
|
||||
parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest_for_ca(ca),
|
||||
publication_point_cache_policy_fingerprint(policy),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -5671,11 +5745,10 @@ fn build_vcir_ccr_manifest_projection_from_fresh(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())?
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(encode_access_description_der_for_vcir_ccr_projection)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![select_manifest_signed_object_location(
|
||||
&ca.manifest_rsync_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)?],
|
||||
SubjectInfoAccess::Ca(_) => {
|
||||
return Err(
|
||||
"manifest EE certificate Subject Information Access has CA variant".to_string(),
|
||||
@@ -6000,9 +6073,9 @@ fn build_vcir_related_artifacts(
|
||||
objects: &crate::validation::objects::ObjectsOutput,
|
||||
child_audits: &[ObjectAuditEntry],
|
||||
) -> 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()) {
|
||||
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);
|
||||
@@ -6013,6 +6086,7 @@ fn build_vcir_related_artifacts(
|
||||
sha256: sha256_hex(&pack.manifest_bytes),
|
||||
object_type: Some("mft".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
});
|
||||
artifacts.push(VcirRelatedArtifact {
|
||||
artifact_role: if ca.parent_manifest_rsync_uri.is_none() {
|
||||
@@ -6032,13 +6106,20 @@ fn build_vcir_related_artifacts(
|
||||
}),
|
||||
object_type: Some("cer".to_string()),
|
||||
validation_status: VcirArtifactValidationStatus::Accepted,
|
||||
reject_reason: None,
|
||||
});
|
||||
|
||||
for file in &pack.files {
|
||||
let result = audit_by_uri
|
||||
.get(file.rsync_uri.as_str())
|
||||
.cloned()
|
||||
let audit_entry = audit_by_uri.get(file.rsync_uri.as_str()).copied();
|
||||
let result = audit_entry
|
||||
.map(|entry| entry.result.clone())
|
||||
.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);
|
||||
artifacts.push(VcirRelatedArtifact {
|
||||
artifact_role,
|
||||
@@ -6046,7 +6127,8 @@ fn build_vcir_related_artifacts(
|
||||
uri: Some(file.rsync_uri.clone()),
|
||||
sha256: sha256_hex_from_32(&file.sha256),
|
||||
object_type: object_type_from_uri(file.rsync_uri.as_str()),
|
||||
validation_status: audit_result_to_vcir_status(&result),
|
||||
validation_status,
|
||||
reject_reason,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user