Compare commits
46 Commits
de4f40eb2c
...
dev_1.0.0_
| Author | SHA1 | Date | |
|---|---|---|---|
| 05650b23bf | |||
| 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 | |||
| 9f4f4cf069 | |||
| b66b425512 | |||
| 363e69d11d | |||
| da6e2e515d | |||
| 4a64fa7900 | |||
| 1ce897dbff | |||
| 08281fa231 | |||
| 60796299b0 | |||
| 6de0bced1c | |||
| 8e37ef48cb | |||
| 8613cfae60 | |||
| f3934c3bf8 | |||
| 00613afdde | |||
| ea6ac27b8d |
@@ -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__
|
||||
@@ -11,12 +13,12 @@ ALLOW_CROSS_ARCH=0
|
||||
# Compose project name.
|
||||
COMPOSE_PROJECT_NAME=ours-rp-__PACKAGE_ARCH__-installer
|
||||
|
||||
# Runtime image loaded from images/*.tar.gz by install.sh.
|
||||
RPKI_IMAGE=ours-rp-runtime-__PACKAGE_ARCH__:dev
|
||||
# Runtime image rebuilt from the current source commit while packaging.
|
||||
RPKI_IMAGE=__RUNTIME_IMAGE__
|
||||
RPKI_PLATFORM=__PACKAGE_PLATFORM__
|
||||
|
||||
# Metrics image loaded from images/*.tar.gz by install.sh.
|
||||
METRICS_IMAGE=ours-rp-metrics-__PACKAGE_ARCH__:dev
|
||||
# Metrics image rebuilt from the current source commit while packaging.
|
||||
METRICS_IMAGE=__METRICS_IMAGE__
|
||||
METRICS_PLATFORM=__PACKAGE_PLATFORM__
|
||||
|
||||
# Restart policy for the soak container. Production default keeps the daemon alive.
|
||||
@@ -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:
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_cir_trust_anchors",
|
||||
"expr": "max(ours_rp_cir_trust_anchors and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "RIRs",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_run_duration_seconds",
|
||||
"expr": "max(ours_rp_run_duration_seconds and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "wall",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -133,7 +133,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_run_max_rss_bytes",
|
||||
"expr": "max(ours_rp_run_max_rss_bytes and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "rss",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -178,7 +178,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_publication_points",
|
||||
"expr": "max(ours_rp_publication_points and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "publication points",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -223,7 +223,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_run_sequence",
|
||||
"expr": "max(ours_rp_run_sequence)",
|
||||
"legendFormat": "seq",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -287,7 +287,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "100 * sum by (job, instance, exported_instance) (ours_rp_repo_terminal_state_count{terminal_state=\"publication_point_cache\"}) / sum by (job, instance, exported_instance) (ours_rp_publication_points)",
|
||||
"expr": "max((100 * sum by (exported_instance) (ours_rp_repo_terminal_state_count{terminal_state=\"publication_point_cache\"}) / sum by (exported_instance) (ours_rp_publication_points)) and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "PP cache hit ratio",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -332,7 +332,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_vrps{kind=\"total\"}",
|
||||
"expr": "max(ours_rp_vrps{kind=\"total\"} and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "VRPs raw",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -377,7 +377,7 @@
|
||||
"pluginVersion": "11.3.1",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ours_rp_vaps",
|
||||
"expr": "max(ours_rp_vaps and on (exported_instance) topk(1, ours_rp_run_sequence))",
|
||||
"legendFormat": "VAPs",
|
||||
"refId": "A"
|
||||
}
|
||||
@@ -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` 会自动
|
||||
完成该指向。
|
||||
@@ -6,6 +6,8 @@ This package deploys ours RP on Linux servers whose host architecture matches th
|
||||
|
||||
The package includes four architecture-matched images: ours RP runtime, artifact metrics, Prometheus, and Grafana. Deployment does not need to pull application images on the target host. Runtime state, run artifacts, logs, Prometheus data and Grafana data are persisted through host bind mounts.
|
||||
|
||||
Every installer build rebuilds the ours RP runtime and artifact metrics images. The deliverable name is `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`, and both ours RP image tags use the current source commit. `PACKAGE-MANIFEST.env` records the full commit, UTC build time, dirty state, and image archive hashes; `./scripts/status.sh --brief` displays that provenance after installation.
|
||||
|
||||
By default the host architecture must match `PACKAGE_ARCH` from `PACKAGE-MANIFEST.env`. If you intentionally want to run this package on a different host architecture through QEMU/binfmt, set:
|
||||
|
||||
```bash
|
||||
@@ -16,19 +18,19 @@ ALLOW_CROSS_ARCH=1
|
||||
|
||||
`compose/` is the single production Docker Compose template source. The Docker installer build script copies this directory directly. Do not maintain a second copy of the Compose, Prometheus, or Grafana dashboard files elsewhere.
|
||||
|
||||
The legacy direct Compose deployment entry point has been removed. For either amd64 or arm64, build the matching installer package with `scripts/docker/build_docker_installer_package.sh`, then use `install.sh`, `start.sh`, and `status.sh` as described here.
|
||||
The legacy direct Compose deployment entry point has been removed. For either amd64 or arm64, build the matching installer package with `scripts/docker/build_docker_installer_package.sh`, then use `scripts/install.sh`, `scripts/start.sh`, and `scripts/status.sh` as described here.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
tar -xzf ours-rp-docker-installer-__PACKAGE_ARCH__-*.tar.gz
|
||||
cd ours-rp-docker-installer-__PACKAGE_ARCH__-*
|
||||
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
|
||||
cd ours-rp-installer-__PACKAGE_ARCH__-*
|
||||
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
cp .env.example .env # install.sh creates .env automatically if missing
|
||||
vim .env
|
||||
./start.sh
|
||||
./status.sh
|
||||
./scripts/start.sh
|
||||
./scripts/status.sh
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -45,12 +47,18 @@ Defaults:
|
||||
- `PERIODIC_SNAPSHOT_MAX_DELTAS=100`
|
||||
- `HOST_DATA_DIR=__HOST_DATA_DIR__`
|
||||
- `SOAK_RESTART_POLICY=unless-stopped`
|
||||
- `RPKI_IMAGE=ours-rp-runtime-__PACKAGE_ARCH__:dev`
|
||||
- `METRICS_IMAGE=ours-rp-metrics-__PACKAGE_ARCH__:dev`
|
||||
- `RPKI_IMAGE=__RUNTIME_IMAGE__`
|
||||
- `METRICS_IMAGE=__METRICS_IMAGE__`
|
||||
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
|
||||
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
|
||||
- `ALLOW_CROSS_ARCH=0`
|
||||
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; set it to a real host directory such as `/root/rpki/report` to ingest reports from a separately deployed RTR service
|
||||
- `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
|
||||
|
||||
@@ -78,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
|
||||
|
||||
@@ -115,14 +123,19 @@ Change the password and restrict public access for production deployments.
|
||||
|
||||
## Optional RTR report metrics input
|
||||
|
||||
If a separately deployed RTR service on the same host continuously writes `rtr-source-*`, `rtr-runtime-*`, and `rtr-clients-*` JSON reports, configure:
|
||||
If a separately deployed RTR service on the same host continuously writes `rtr-source-*`, `rtr-runtime-*`, and `rtr-clients-*` JSON reports, run:
|
||||
|
||||
```bash
|
||||
RTR_REPORT_DIR=/root/rpki/report
|
||||
RTR_REPORT_CONTAINER_DIR=/var/lib/ours-rp/rtr-report
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
|
||||
```
|
||||
|
||||
The `artifact-metrics` container mounts the host 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.
|
||||
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
|
||||
|
||||
@@ -170,12 +183,12 @@ Check the latest `run-meta.json` for:
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
./status.sh
|
||||
./logs.sh ours-rp-soak --tail 200
|
||||
./restart.sh
|
||||
./stop.sh
|
||||
./cleanup.sh --keep-runs 100 --execute
|
||||
./uninstall.sh
|
||||
./scripts/status.sh
|
||||
./scripts/logs.sh ours-rp-soak --tail 200
|
||||
./scripts/restart.sh
|
||||
./scripts/stop.sh
|
||||
./scripts/cleanup.sh --keep-runs 100 --execute
|
||||
./scripts/uninstall.sh
|
||||
```
|
||||
|
||||
For finite acceptance tests, for example `MAX_RUNS=3`, also set:
|
||||
@@ -189,5 +202,5 @@ Otherwise Compose `unless-stopped` will restart the container after it exits suc
|
||||
`uninstall.sh` keeps data by default. Use the following only when you really want to delete `HOST_DATA_DIR`:
|
||||
|
||||
```bash
|
||||
./uninstall.sh --purge-data
|
||||
./scripts/uninstall.sh --purge-data
|
||||
```
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
安装包内置与包架构一致的四类镜像:ours RP runtime、artifact metrics、Prometheus、Grafana。部署时不需要现场拉取应用镜像。运行产物、状态数据库、日志、Prometheus 和 Grafana 数据均通过宿主机目录挂载保存。
|
||||
|
||||
每次构建安装包都会重新构建 ours RP runtime 与 artifact metrics 镜像。交付包命名为 `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`,两类 ours RP 镜像分别使用当前源码 commit 的 tag。完整 commit、UTC 构建时间、dirty 状态和镜像归档 hash 保存在 `PACKAGE-MANIFEST.env`,可通过 `./scripts/status.sh --brief` 查看。
|
||||
|
||||
默认情况下,宿主架构必须与 `PACKAGE-MANIFEST.env` 中的 `PACKAGE_ARCH` 匹配;若确需在异构主机上通过 QEMU/binfmt 运行,必须显式设置:
|
||||
|
||||
```bash
|
||||
@@ -16,19 +18,19 @@ ALLOW_CROSS_ARCH=1
|
||||
|
||||
`compose/` 是唯一的生产 Docker Compose 模板来源,Docker 安装包构建脚本会直接复制该目录。不要在其他目录维护第二份 compose、Prometheus 或 Grafana dashboard 文件。
|
||||
|
||||
旧的直接 Compose 部署入口已经移除。需要部署 amd64 或 arm64 时,统一先用 `scripts/docker/build_docker_installer_package.sh` 构建对应架构安装包,再按本说明执行 `install.sh`、`start.sh` 和 `status.sh`。
|
||||
旧的直接 Compose 部署入口已经移除。需要部署 amd64 或 arm64 时,统一先用 `scripts/docker/build_docker_installer_package.sh` 构建对应架构安装包,再按本说明执行 `scripts/install.sh`、`scripts/start.sh` 和 `scripts/status.sh`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
tar -xzf ours-rp-docker-installer-__PACKAGE_ARCH__-*.tar.gz
|
||||
cd ours-rp-docker-installer-__PACKAGE_ARCH__-*
|
||||
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
|
||||
cd ours-rp-installer-__PACKAGE_ARCH__-*
|
||||
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
cp .env.example .env # 如 install.sh 已自动创建,可直接编辑现有 .env
|
||||
vim .env
|
||||
./start.sh
|
||||
./status.sh
|
||||
./scripts/start.sh
|
||||
./scripts/status.sh
|
||||
```
|
||||
|
||||
默认配置:
|
||||
@@ -45,12 +47,17 @@ vim .env
|
||||
- `PERIODIC_SNAPSHOT_MAX_DELTAS=100`
|
||||
- `HOST_DATA_DIR=__HOST_DATA_DIR__`
|
||||
- `SOAK_RESTART_POLICY=unless-stopped`
|
||||
- `RPKI_IMAGE=ours-rp-runtime-__PACKAGE_ARCH__:dev`
|
||||
- `METRICS_IMAGE=ours-rp-metrics-__PACKAGE_ARCH__:dev`
|
||||
- `RPKI_IMAGE=__RUNTIME_IMAGE__`
|
||||
- `METRICS_IMAGE=__METRICS_IMAGE__`
|
||||
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
|
||||
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
|
||||
- `ALLOW_CROSS_ARCH=0`
|
||||
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;如需接入独立 RTR 服务 report,可设置为宿主机真实目录,例如 `/root/rpki/report`
|
||||
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
|
||||
|
||||
如需对接本地或第三方测试 TA,可使用 custom TAL 模式(`RIRS=custom` +
|
||||
`TAL_INPUT_MODE=custom-file-with-ta`,配合 `CUSTOM_TAL_PATH` / `CUSTOM_TA_PATH` /
|
||||
`CUSTOM_TAL_URI` / `HTTP_ROOT_CERT_PATHS` 与 `CUSTOM_FIXTURE_HOST_DIR` 挂载),
|
||||
配置项说明见 `.env.example` 中的注释段。
|
||||
|
||||
## 首次启动语义
|
||||
|
||||
@@ -78,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 操作前失败。
|
||||
|
||||
## 架构检查
|
||||
|
||||
@@ -115,14 +122,19 @@ GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
## 可选 RTR report 监控接入
|
||||
|
||||
如果同一台机器上已经有独立部署的 RTR 服务,并且它持续输出 `rtr-source-*`、`rtr-runtime-*`、`rtr-clients-*` JSON report,可在 `.env` 中配置:
|
||||
如果同一台机器上已经有独立部署的 RTR 服务,并且它持续输出 `rtr-source-*`、`rtr-runtime-*`、`rtr-clients-*` JSON report,执行:
|
||||
|
||||
```bash
|
||||
RTR_REPORT_DIR=/root/rpki/report
|
||||
RTR_REPORT_CONTAINER_DIR=/var/lib/ours-rp/rtr-report
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
|
||||
```
|
||||
|
||||
`artifact-metrics` 容器会以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。
|
||||
该命令只接受本机绝对路径,会先校验候选 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 秒)。
|
||||
|
||||
## 数据目录
|
||||
|
||||
@@ -170,12 +182,12 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
./status.sh
|
||||
./logs.sh ours-rp-soak --tail 200
|
||||
./restart.sh
|
||||
./stop.sh
|
||||
./cleanup.sh --keep-runs 100 --execute
|
||||
./uninstall.sh
|
||||
./scripts/status.sh
|
||||
./scripts/logs.sh ours-rp-soak --tail 200
|
||||
./scripts/restart.sh
|
||||
./scripts/stop.sh
|
||||
./scripts/cleanup.sh --keep-runs 100 --execute
|
||||
./scripts/uninstall.sh
|
||||
```
|
||||
|
||||
如果做有限轮次验收,例如 `MAX_RUNS=3`,建议同时设置:
|
||||
@@ -189,7 +201,7 @@ SOAK_RESTART_POLICY=no
|
||||
`uninstall.sh` 默认不删除数据。只有显式执行:
|
||||
|
||||
```bash
|
||||
./uninstall.sh --purge-data
|
||||
./scripts/uninstall.sh --purge-data
|
||||
```
|
||||
|
||||
才会删除 `HOST_DATA_DIR`。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Install
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
The installer is idempotent:
|
||||
@@ -17,13 +17,13 @@ The installer is idempotent:
|
||||
## Start
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
Start without waiting for the first snapshot:
|
||||
|
||||
```bash
|
||||
./start.sh --no-wait-first-run
|
||||
./scripts/start.sh --no-wait-first-run
|
||||
```
|
||||
|
||||
If you intentionally want to run this package on a different host architecture through QEMU/binfmt, first set:
|
||||
@@ -32,20 +32,20 @@ If you intentionally want to run this package on a different host architecture t
|
||||
ALLOW_CROSS_ARCH=1
|
||||
```
|
||||
|
||||
Then rerun `./install.sh` or `./self-check.sh`, and only then will the scripts attempt to enable the matching binfmt handler.
|
||||
Then rerun `./scripts/install.sh` or `./scripts/self-check.sh`, and only then will the scripts attempt to enable the matching binfmt handler.
|
||||
|
||||
## Stop and Restart
|
||||
|
||||
```bash
|
||||
./stop.sh
|
||||
./restart.sh
|
||||
./scripts/stop.sh
|
||||
./scripts/restart.sh
|
||||
```
|
||||
|
||||
## Status Checks
|
||||
|
||||
```bash
|
||||
./status.sh
|
||||
./self-check.sh
|
||||
./scripts/status.sh
|
||||
./scripts/self-check.sh
|
||||
```
|
||||
|
||||
Important checks:
|
||||
@@ -73,20 +73,42 @@ Important checks:
|
||||
To verify RTR report ingestion:
|
||||
|
||||
```bash
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
|
||||
./scripts/status.sh
|
||||
curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head
|
||||
```
|
||||
|
||||
Registration accepts only an absolute directory on the same host. The command retains a timestamped `.env` backup and updates only the metrics, Prometheus, and Grafana sidecars; the `ours-rp-soak` container ID must remain unchanged.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Extract the new package into a new directory and explicitly reuse the existing `.env` through the upgrade script:
|
||||
|
||||
```bash
|
||||
./upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
./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:
|
||||
|
||||
@@ -125,8 +147,8 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
./cleanup.sh --keep-runs 100
|
||||
./cleanup.sh --keep-runs 100 --execute
|
||||
./scripts/cleanup.sh --keep-runs 100
|
||||
./scripts/cleanup.sh --keep-runs 100 --execute
|
||||
```
|
||||
|
||||
Cleanup is dry-run by default. Add `--execute` after reviewing the output.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
安装脚本是幂等的:
|
||||
@@ -17,13 +17,13 @@
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
如需后台启动后不等待首轮 snapshot:
|
||||
|
||||
```bash
|
||||
./start.sh --no-wait-first-run
|
||||
./scripts/start.sh --no-wait-first-run
|
||||
```
|
||||
|
||||
如需显式允许异构主机通过 QEMU/binfmt 跑包,先在 `.env` 中设置:
|
||||
@@ -32,20 +32,20 @@
|
||||
ALLOW_CROSS_ARCH=1
|
||||
```
|
||||
|
||||
然后重新执行 `./install.sh` 或 `./self-check.sh`,脚本才会尝试启用对应 binfmt。
|
||||
然后重新执行 `./scripts/install.sh` 或 `./scripts/self-check.sh`,脚本才会尝试启用对应 binfmt。
|
||||
|
||||
## 停止和重启
|
||||
|
||||
```bash
|
||||
./stop.sh
|
||||
./restart.sh
|
||||
./scripts/stop.sh
|
||||
./scripts/restart.sh
|
||||
```
|
||||
|
||||
## 状态检查
|
||||
|
||||
```bash
|
||||
./status.sh
|
||||
./self-check.sh
|
||||
./scripts/status.sh
|
||||
./scripts/self-check.sh
|
||||
```
|
||||
|
||||
重点检查项:
|
||||
@@ -73,20 +73,42 @@ ALLOW_CROSS_ARCH=1
|
||||
如需确认 RTR report 已接入:
|
||||
|
||||
```bash
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
|
||||
./scripts/status.sh
|
||||
curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head
|
||||
```
|
||||
|
||||
注册只支持同机的绝对目录。注册命令会保留一份带时间戳的 `.env` 备份,并且只更新 metrics、Prometheus、Grafana 三个旁路容器;`ours-rp-soak` 的容器 ID 应保持不变。
|
||||
|
||||
## 升级
|
||||
|
||||
把新安装包解压到新目录后,推荐通过升级脚本显式复用旧 `.env`:
|
||||
|
||||
```bash
|
||||
./upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
./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 操作前失败。
|
||||
|
||||
升级不会删除以下数据:
|
||||
|
||||
@@ -125,8 +147,8 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
./cleanup.sh --keep-runs 100
|
||||
./cleanup.sh --keep-runs 100 --execute
|
||||
./scripts/cleanup.sh --keep-runs 100
|
||||
./scripts/cleanup.sh --keep-runs 100 --execute
|
||||
```
|
||||
|
||||
默认 dry-run,确认后加 `--execute`。
|
||||
|
||||
@@ -12,7 +12,7 @@ docker compose version
|
||||
If missing, run:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
## Package Architecture Mismatch
|
||||
@@ -55,11 +55,11 @@ docker run --rm --platform __PACKAGE_PLATFORM__ \
|
||||
|
||||
## RTR report metrics are empty
|
||||
|
||||
Check `.env`:
|
||||
First check registration and the report directory:
|
||||
|
||||
```bash
|
||||
grep '^RTR_REPORT_DIR=' .env
|
||||
test -d "$(grep '^RTR_REPORT_DIR=' .env | cut -d= -f2-)" && echo ok
|
||||
./scripts/status.sh
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
|
||||
```
|
||||
|
||||
Check the metrics container mount:
|
||||
@@ -74,7 +74,7 @@ docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-me
|
||||
All-five snapshot can be slow, especially under QEMU. Increase timeout:
|
||||
|
||||
```bash
|
||||
./start.sh --timeout-secs 14400
|
||||
./scripts/start.sh --timeout-secs 14400
|
||||
```
|
||||
|
||||
## Output Counts Are Too Low
|
||||
|
||||
@@ -12,7 +12,7 @@ docker compose version
|
||||
如果缺失,重新执行:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
## 架构不匹配报错
|
||||
@@ -55,11 +55,11 @@ docker run --rm --platform __PACKAGE_PLATFORM__ \
|
||||
|
||||
## RTR report 指标为空
|
||||
|
||||
检查 `.env`:
|
||||
先检查注册状态和 report 目录:
|
||||
|
||||
```bash
|
||||
grep '^RTR_REPORT_DIR=' .env
|
||||
test -d "$(grep '^RTR_REPORT_DIR=' .env | cut -d= -f2-)" && echo ok
|
||||
./scripts/status.sh
|
||||
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
|
||||
```
|
||||
|
||||
检查 metrics 容器是否挂载该目录:
|
||||
@@ -74,7 +74,7 @@ docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-me
|
||||
all5 snapshot 可能很慢,尤其在 QEMU 环境。可以提高超时:
|
||||
|
||||
```bash
|
||||
./start.sh --timeout-secs 14400
|
||||
./scripts/start.sh --timeout-secs 14400
|
||||
```
|
||||
|
||||
## 产物数量异常偏低
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
DRY_RUN=1
|
||||
KEEP_RUNS=""
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./cleanup.sh [--execute] [--keep-runs N]
|
||||
Usage: ./scripts/cleanup.sh [--execute] [--keep-runs N]
|
||||
|
||||
By default this is a dry-run. It removes old run_* directories beyond KEEP_RUNS
|
||||
and clears tmp contents.
|
||||
@@ -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}:dev}"
|
||||
RPKI_PLATFORM="${RPKI_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
METRICS_IMAGE="${METRICS_IMAGE:-ours-rp-metrics-${PACKAGE_ARCH}:dev}"
|
||||
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() {
|
||||
@@ -224,6 +434,82 @@ validate_rtr_report_dir() {
|
||||
[[ -d "$RTR_REPORT_DIR" ]] || die "RTR_REPORT_DIR does not exist or is not a directory: $RTR_REPORT_DIR"
|
||||
}
|
||||
|
||||
validate_rtr_registration_dir() {
|
||||
local report_dir="$1"
|
||||
|
||||
[[ -n "$report_dir" ]] || die "--report-dir is required"
|
||||
[[ "$report_dir" = /* ]] || die "--report-dir must be an absolute host path: $report_dir"
|
||||
[[ "$report_dir" =~ ^/[A-Za-z0-9._/@:+,-]+$ ]] || die "--report-dir contains unsupported characters: $report_dir"
|
||||
[[ -d "$report_dir" ]] || die "--report-dir does not exist: $report_dir"
|
||||
[[ -r "$report_dir" ]] || die "--report-dir is not readable: $report_dir"
|
||||
}
|
||||
|
||||
rtr_report_file_count() {
|
||||
local report_dir="$1"
|
||||
{
|
||||
find "$report_dir" -maxdepth 1 -type f \( \
|
||||
-name 'rtr-source-*.json' -o \
|
||||
-name 'rtr-runtime-*.json' -o \
|
||||
-name 'rtr-clients-*.json' \
|
||||
\) -print 2>/dev/null || true
|
||||
} | wc -l | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
latest_rtr_report_file() {
|
||||
local report_dir="$1"
|
||||
{
|
||||
find "$report_dir" -maxdepth 1 -type f \( \
|
||||
-name 'rtr-source-*.json' -o \
|
||||
-name 'rtr-runtime-*.json' -o \
|
||||
-name 'rtr-clients-*.json' \
|
||||
\) -printf '%T@ %p\n' 2>/dev/null || true
|
||||
} | sort -nr | awk 'NR == 1 { sub(/^[^ ]+ /, ""); print }'
|
||||
}
|
||||
|
||||
replace_env_key_file() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local temp_path
|
||||
|
||||
[[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || die "invalid environment key: $key"
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value must be single-line"
|
||||
|
||||
temp_path="$(mktemp "${env_path}.tmp.XXXXXX")"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { replaced = 0 }
|
||||
$0 ~ "^" key "=" {
|
||||
print key "=" value
|
||||
replaced = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!replaced) {
|
||||
print key "=" value
|
||||
}
|
||||
}
|
||||
' "$env_path" > "$temp_path"
|
||||
chmod --reference="$env_path" "$temp_path" 2>/dev/null || true
|
||||
mv "$temp_path" "$env_path"
|
||||
}
|
||||
|
||||
wait_for_endpoint() {
|
||||
local url="$1"
|
||||
local timeout_secs="$2"
|
||||
local elapsed=0
|
||||
|
||||
while (( elapsed < timeout_secs )); do
|
||||
if endpoint_ok "$url"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
create_data_dirs() {
|
||||
load_env
|
||||
mkdir -p \
|
||||
@@ -470,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() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./install.sh [--skip-dep-install]
|
||||
Usage: ./scripts/install.sh [--skip-dep-install]
|
||||
|
||||
Install or update the ours RP Docker installer package idempotently.
|
||||
USAGE
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
load_env
|
||||
compose_cmd --profile core --profile sidecar --profile monitor logs "$@"
|
||||
149
deploy/docker-installer/scripts/register_rtr_monitor.sh
Executable file
149
deploy/docker-installer/scripts/register_rtr_monitor.sh
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPORT_DIR=""
|
||||
DRY_RUN=0
|
||||
CANDIDATE_ENV=""
|
||||
CONFIG_FILE=""
|
||||
ORIGINAL_ENV_FILE=""
|
||||
ENV_BACKUP=""
|
||||
REGISTRATION_APPLIED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/register_rtr_monitor.sh --report-dir <absolute-host-path> [--dry-run]
|
||||
|
||||
Registers one local RTR report directory for the artifact metrics sidecar.
|
||||
The directory must be on the same host as this installer and may contain
|
||||
rtr-source-*.json, rtr-runtime-*.json, and rtr-clients-*.json report files.
|
||||
|
||||
This command recreates only artifact-metrics, Prometheus, and Grafana. It does
|
||||
not restart or recreate the ours-rp-soak container.
|
||||
EOF
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ -n "$CANDIDATE_ENV" && -f "$CANDIDATE_ENV" ]] && rm -f "$CANDIDATE_ENV"
|
||||
[[ -n "$CONFIG_FILE" && -f "$CONFIG_FILE" ]] && rm -f "$CONFIG_FILE"
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
restore_failed_registration() {
|
||||
if (( REGISTRATION_APPLIED == 0 )) || [[ -z "$ENV_BACKUP" || ! -f "$ENV_BACKUP" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "registration failed after .env replacement; restoring previous metrics configuration"
|
||||
cp -p "$ENV_BACKUP" "$ORIGINAL_ENV_FILE"
|
||||
ENV_FILE="$ORIGINAL_ENV_FILE"
|
||||
load_env || warn "unable to reload restored .env"
|
||||
compose_cmd --profile sidecar up -d --force-recreate artifact-metrics \
|
||||
|| warn "unable to recreate artifact-metrics with restored configuration"
|
||||
}
|
||||
|
||||
fail_after_apply() {
|
||||
restore_failed_registration
|
||||
die "$*"
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--report-dir)
|
||||
[[ $# -ge 2 ]] || die "--report-dir requires a value"
|
||||
REPORT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd docker
|
||||
require_cmd curl
|
||||
require_cmd grep
|
||||
require_cmd jq
|
||||
validate_rtr_registration_dir "$REPORT_DIR"
|
||||
load_env
|
||||
assert_arch_compatibility
|
||||
|
||||
ORIGINAL_ENV_FILE="$ENV_FILE"
|
||||
REPORT_FILE_COUNT="$(rtr_report_file_count "$REPORT_DIR")"
|
||||
LATEST_REPORT_FILE="$(latest_rtr_report_file "$REPORT_DIR")"
|
||||
if (( REPORT_FILE_COUNT == 0 )); then
|
||||
warn "no recognized RTR report JSON files are currently present in $REPORT_DIR"
|
||||
fi
|
||||
|
||||
CANDIDATE_ENV="$(mktemp "$INSTALLER_ROOT/.env.rtr-register.XXXXXX")"
|
||||
cp -p "$ORIGINAL_ENV_FILE" "$CANDIDATE_ENV"
|
||||
replace_env_key_file "$CANDIDATE_ENV" "RTR_REPORT_DIR" "$REPORT_DIR"
|
||||
|
||||
ENV_FILE="$CANDIDATE_ENV"
|
||||
load_env
|
||||
CONFIG_FILE="$(mktemp "$INSTALLER_ROOT/.rtr-compose-config.XXXXXX")"
|
||||
compose_cmd --profile sidecar --profile monitor config --format json > "$CONFIG_FILE"
|
||||
jq -e --arg source "$REPORT_DIR" --arg target "$RTR_REPORT_CONTAINER_DIR" '
|
||||
.services["artifact-metrics"].volumes
|
||||
| any(.[]; .type == "bind" and .source == $source and .target == $target and .read_only == true)
|
||||
' "$CONFIG_FILE" >/dev/null || die "candidate compose configuration is missing expected read-only RTR bind mount"
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
printf 'dry_run=ok\n'
|
||||
printf 'report_dir=%s\n' "$REPORT_DIR"
|
||||
printf 'recognized_report_files=%s\n' "$REPORT_FILE_COUNT"
|
||||
printf 'latest_report=%s\n' "${LATEST_REPORT_FILE:--}"
|
||||
printf 'expected_mount_source=%s\n' "$REPORT_DIR"
|
||||
printf 'expected_mount_target=%s\n' "$RTR_REPORT_CONTAINER_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ENV_FILE="$ORIGINAL_ENV_FILE"
|
||||
load_env
|
||||
soak_container_before="$(compose_cmd ps -q ours-rp-soak || true)"
|
||||
ENV_BACKUP="${ORIGINAL_ENV_FILE}.rtr-register-$(date -u +%Y%m%dT%H%M%SZ).bak"
|
||||
cp -p "$ORIGINAL_ENV_FILE" "$ENV_BACKUP"
|
||||
mv "$CANDIDATE_ENV" "$ORIGINAL_ENV_FILE"
|
||||
CANDIDATE_ENV=""
|
||||
REGISTRATION_APPLIED=1
|
||||
|
||||
load_env
|
||||
compose_cmd --profile sidecar up -d --force-recreate artifact-metrics \
|
||||
|| fail_after_apply "unable to recreate artifact-metrics"
|
||||
compose_cmd --profile sidecar --profile monitor up -d prometheus grafana \
|
||||
|| fail_after_apply "unable to start Prometheus and Grafana"
|
||||
|
||||
soak_container_after="$(compose_cmd ps -q ours-rp-soak || true)"
|
||||
if [[ -n "$soak_container_before" && "$soak_container_before" != "$soak_container_after" ]]; then
|
||||
fail_after_apply "ours-rp-soak container changed during RTR registration; investigate before continuing"
|
||||
fi
|
||||
|
||||
wait_for_endpoint "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" 60 \
|
||||
|| fail_after_apply "artifact-metrics did not become ready"
|
||||
wait_for_endpoint "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/ready" 60 \
|
||||
|| fail_after_apply "Prometheus did not become ready"
|
||||
wait_for_endpoint "http://127.0.0.1:${GRAFANA_PORT:-3000}/api/health" 60 \
|
||||
|| fail_after_apply "Grafana did not become ready"
|
||||
|
||||
if (( REPORT_FILE_COUNT > 0 )); then
|
||||
curl -fsS "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep -q '^ours_rp_rtr_' \
|
||||
|| fail_after_apply "artifact-metrics exposes no RTR metrics after registration"
|
||||
fi
|
||||
|
||||
printf 'registration=ok\n'
|
||||
printf 'report_dir=%s\n' "$REPORT_DIR"
|
||||
printf 'recognized_report_files=%s\n' "$REPORT_FILE_COUNT"
|
||||
printf 'latest_report=%s\n' "${LATEST_REPORT_FILE:--}"
|
||||
printf 'env_backup=%s\n' "$ENV_BACKUP"
|
||||
printf 'ours_rp_soak_container_unchanged=%s\n' "${soak_container_before:-not-running}"
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
QUICK=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: ./self-check.sh [--quick]"
|
||||
echo "Usage: ./scripts/self-check.sh [--quick]"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
WAIT_FIRST_RUN=1
|
||||
TIMEOUT_SECS=""
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./start.sh [--no-wait-first-run] [--timeout-secs N]
|
||||
Usage: ./scripts/start.sh [--no-wait-first-run] [--timeout-secs N]
|
||||
|
||||
Start ours RP. If no successful run exists, wait for the first snapshot to succeed
|
||||
before starting metrics, Prometheus and Grafana.
|
||||
@@ -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"
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
BRIEF=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: ./status.sh [--brief]"
|
||||
echo "Usage: ./scripts/status.sh [--brief]"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -26,6 +26,11 @@ host_arch="$(detect_host_arch)"
|
||||
mode="$(arch_mode)"
|
||||
echo "installer_root=$INSTALLER_ROOT"
|
||||
echo "manifest_file=$MANIFEST_FILE"
|
||||
echo "package_name=${package_name:-}"
|
||||
echo "source_commit=${source_commit:-${git_commit:-}}"
|
||||
echo "source_commit_short=${source_commit_short:-}"
|
||||
echo "source_dirty=${source_dirty:-}"
|
||||
echo "build_timestamp_utc=${build_timestamp_utc:-${created_at_utc:-}}"
|
||||
echo "host_data_dir=$HOST_DATA_DIR"
|
||||
echo "host_arch=$host_arch"
|
||||
echo "package_arch=$PACKAGE_ARCH"
|
||||
@@ -34,8 +39,12 @@ echo "allow_cross_arch=$ALLOW_CROSS_ARCH"
|
||||
echo "arch_mode=$mode"
|
||||
echo "runtime_image=$RPKI_IMAGE"
|
||||
echo "runtime_platform=$RPKI_PLATFORM"
|
||||
echo "runtime_image_revision=${runtime_image_revision:-}"
|
||||
echo "runtime_image_dirty=${runtime_image_dirty:-}"
|
||||
echo "metrics_image=$METRICS_IMAGE"
|
||||
echo "metrics_platform=$METRICS_PLATFORM"
|
||||
echo "metrics_image_revision=${metrics_image_revision:-}"
|
||||
echo "metrics_image_dirty=${metrics_image_dirty:-}"
|
||||
echo "rtr_report_dir=$RTR_REPORT_DIR"
|
||||
echo "rtr_report_container_dir=$RTR_REPORT_CONTAINER_DIR"
|
||||
echo "monitor_platform=$MONITOR_PLATFORM"
|
||||
@@ -67,4 +76,12 @@ if [[ "$BRIEF" == "0" ]]; then
|
||||
endpoint_ok "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" && echo "metrics=ok" || echo "metrics=unavailable"
|
||||
endpoint_ok "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/ready" && echo "prometheus=ok" || echo "prometheus=unavailable"
|
||||
endpoint_ok "http://127.0.0.1:${GRAFANA_PORT:-3000}/api/health" && echo "grafana=ok" || echo "grafana=unavailable"
|
||||
echo
|
||||
if [[ "$RTR_REPORT_DIR" == "$HOST_DATA_DIR/empty-rtr-report" ]]; then
|
||||
echo "rtr_registration=fallback-empty-directory"
|
||||
else
|
||||
echo "rtr_registration=registered"
|
||||
fi
|
||||
echo "rtr_report_files=$(rtr_report_file_count "$RTR_REPORT_DIR")"
|
||||
echo "rtr_latest_report=$(latest_rtr_report_file "$RTR_REPORT_DIR" || true)"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
load_env
|
||||
compose_cmd --profile core --profile sidecar --profile monitor stop "$@"
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
PURGE_DATA=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: ./uninstall.sh [--purge-data]"
|
||||
echo "Usage: ./scripts/uninstall.sh [--purge-data]"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
143
deploy/docker-installer/scripts/upgrade.sh
Executable file
143
deploy/docker-installer/scripts/upgrade.sh
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REUSE_ENV_FROM=""
|
||||
UPDATE_PACKAGE_IMAGE=1
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./scripts/upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-image]
|
||||
|
||||
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.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--reuse-env-from)
|
||||
REUSE_ENV_FROM="$2"
|
||||
shift 2
|
||||
;;
|
||||
--keep-reused-image)
|
||||
UPDATE_PACKAGE_IMAGE=0
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
overlay_reused_env() {
|
||||
local source_env="$1"
|
||||
local target_env="$2"
|
||||
local key
|
||||
local value
|
||||
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)
|
||||
if [[ "$UPDATE_PACKAGE_IMAGE" != "0" ]]; then
|
||||
preserved_package_keys+=("$key")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[[ -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
|
||||
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"
|
||||
else
|
||||
log "keeping existing env at $ENV_FILE; reuse source ignored: $REUSE_ENV_FROM"
|
||||
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
|
||||
install_docker_if_missing
|
||||
load_installer_images
|
||||
ensure_binfmt_if_needed
|
||||
verify_runtime_image
|
||||
verify_metrics_image
|
||||
verify_monitor_images
|
||||
compose_cmd --profile core --profile sidecar --profile monitor up -d --force-recreate
|
||||
"$SCRIPT_DIR/status.sh" --brief || true
|
||||
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'
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/common.sh
|
||||
source "$SCRIPT_DIR/scripts/common.sh"
|
||||
|
||||
REUSE_ENV_FROM=""
|
||||
UPDATE_PACKAGE_IMAGE=1
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-image]
|
||||
|
||||
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.
|
||||
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.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--reuse-env-from)
|
||||
REUSE_ENV_FROM="$2"
|
||||
shift 2
|
||||
;;
|
||||
--keep-reused-image)
|
||||
UPDATE_PACKAGE_IMAGE=0
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown option: $1"
|
||||
;;
|
||||
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
|
||||
case "$key" in
|
||||
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
|
||||
continue
|
||||
;;
|
||||
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE)
|
||||
[[ "$UPDATE_PACKAGE_IMAGE" == "0" ]] || continue
|
||||
;;
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
else
|
||||
log "keeping existing env at $ENV_FILE; reuse source ignored: $REUSE_ENV_FROM"
|
||||
fi
|
||||
fi
|
||||
|
||||
load_env
|
||||
assert_arch_compatibility
|
||||
create_data_dirs
|
||||
install_docker_if_missing
|
||||
load_installer_images
|
||||
ensure_binfmt_if_needed
|
||||
verify_runtime_image
|
||||
verify_metrics_image
|
||||
verify_monitor_images
|
||||
compose_cmd --profile core --profile sidecar --profile monitor up -d --force-recreate
|
||||
"$SCRIPT_DIR/status.sh" --brief || true
|
||||
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,20 +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 runtime
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS rpki-client-builder
|
||||
|
||||
LABEL org.opencontainers.image.title="ours-rp-metrics" \
|
||||
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment"
|
||||
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="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.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,22 +81,28 @@ 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
|
||||
|
||||
LABEL org.opencontainers.image.title="ours-rp-runtime" \
|
||||
org.opencontainers.image.description="Ours RP runtime image for multi-arch Docker Compose deployment"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
@@ -123,6 +129,16 @@ COPY tests/fixtures/ta/ /opt/ours-rp/fixtures/ta/
|
||||
COPY fixtures/live_20260619/tal/ /opt/ours-rp/fixtures/live_20260619/tal/
|
||||
COPY fixtures/live_20260619/ta/ /opt/ours-rp/fixtures/live_20260619/ta/
|
||||
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
ARG SOURCE_DIRTY=unknown
|
||||
ARG BUILD_TIMESTAMP_UTC=unknown
|
||||
|
||||
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}" \
|
||||
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
|
||||
|
||||
RUN chmod +x /opt/ours-rp/run_soak.sh /opt/ours-rp/bin/* \
|
||||
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs /var/lib/ours-rp/tmp
|
||||
|
||||
|
||||
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,10 +5,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
TARGET_ARCH="${TARGET_ARCH:-}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
IMAGE_TAR="${IMAGE_TAR:-}"
|
||||
METRICS_IMAGE="${METRICS_IMAGE:-}"
|
||||
METRICS_IMAGE_TAR="${METRICS_IMAGE_TAR:-}"
|
||||
VERSION="${VERSION:-}"
|
||||
IMAGE_TAG=""
|
||||
IMAGE_TAR=""
|
||||
METRICS_IMAGE=""
|
||||
METRICS_IMAGE_TAR=""
|
||||
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
|
||||
PROMETHEUS_IMAGE_TAR="${PROMETHEUS_IMAGE_TAR:-}"
|
||||
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
|
||||
@@ -16,6 +17,7 @@ GRAFANA_IMAGE_TAR="${GRAFANA_IMAGE_TAR:-}"
|
||||
OUT_DIR="${OUT_DIR:-}"
|
||||
PACKAGE_PREFIX="${PACKAGE_PREFIX:-}"
|
||||
TEMPLATE_DIR="${TEMPLATE_DIR:-$REPO_ROOT/deploy/docker-installer}"
|
||||
ALLOW_DIRTY=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
@@ -24,12 +26,10 @@ Usage:
|
||||
|
||||
Options:
|
||||
--arch <arch> Target architecture: amd64|arm64
|
||||
--image <tag> Runtime image tag recorded in package manifest.
|
||||
--image-tar <path> Existing docker save tar/tar.gz to include.
|
||||
--metrics-image <tag>
|
||||
Metrics image tag recorded in package manifest.
|
||||
--metrics-image-tar <path>
|
||||
Existing metrics docker save tar/tar.gz to include.
|
||||
--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,10 +42,13 @@ Options:
|
||||
--prefix <name> Package directory/tar prefix.
|
||||
--template-dir <path>
|
||||
Package template directory.
|
||||
--allow-dirty Allow a development build from a dirty worktree and mark
|
||||
image tags, package name and manifest as dirty.
|
||||
-h, --help Show help.
|
||||
|
||||
If --image-tar or --metrics-image-tar is omitted, the script uses the
|
||||
matching docker-save archive under target/<arch>-docker/.
|
||||
Every package rebuilds runtime and metrics images from the current HEAD. Their
|
||||
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
|
||||
}
|
||||
|
||||
@@ -71,16 +74,20 @@ safe_tag_name() {
|
||||
printf '%s' "$1" | tr '/:' '--'
|
||||
}
|
||||
|
||||
default_runtime_tag() {
|
||||
printf 'ours-rp-runtime-%s:dev\n' "$1"
|
||||
runtime_image_tag() {
|
||||
local arch="$1"
|
||||
local revision="$2"
|
||||
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$revision"
|
||||
}
|
||||
|
||||
default_metrics_tag() {
|
||||
printf 'ours-rp-metrics-%s:dev\n' "$1"
|
||||
metrics_image_tag() {
|
||||
local arch="$1"
|
||||
local revision="$2"
|
||||
printf 'panda-rpki-metrics-%s:%s\n' "$arch" "$revision"
|
||||
}
|
||||
|
||||
default_package_prefix() {
|
||||
printf 'ours-rp-docker-installer-%s\n' "$1"
|
||||
printf 'panda-rpki-rp-installer-%s\n' "$1"
|
||||
}
|
||||
|
||||
default_host_data_dir() {
|
||||
@@ -163,20 +170,10 @@ save_image_if_needed() {
|
||||
printf '%s\n' "$tar_path"
|
||||
}
|
||||
|
||||
default_saved_image_tar() {
|
||||
image_label() {
|
||||
local image="$1"
|
||||
local out_dir="$REPO_ROOT/target/${TARGET_ARCH}-docker"
|
||||
local safe_name
|
||||
safe_name="$(safe_tag_name "$image")"
|
||||
if [[ -f "$out_dir/${safe_name}.tar.gz" ]]; then
|
||||
printf '%s\n' "$out_dir/${safe_name}.tar.gz"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$out_dir/${safe_name}.tar" ]]; then
|
||||
printf '%s\n' "$out_dir/${safe_name}.tar"
|
||||
return 0
|
||||
fi
|
||||
printf '\n'
|
||||
local key="$2"
|
||||
docker image inspect --format "{{ index .Config.Labels \"$key\" }}" "$image"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -188,21 +185,13 @@ while [[ $# -gt 0 ]]; do
|
||||
}
|
||||
shift 2
|
||||
;;
|
||||
--image)
|
||||
IMAGE_TAG="$2"
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-tar)
|
||||
IMAGE_TAR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--metrics-image)
|
||||
METRICS_IMAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--metrics-image-tar)
|
||||
METRICS_IMAGE_TAR="$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
|
||||
;;
|
||||
--prometheus-image)
|
||||
PROMETHEUS_IMAGE="$2"
|
||||
@@ -232,6 +221,10 @@ while [[ $# -gt 0 ]]; do
|
||||
TEMPLATE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--allow-dirty)
|
||||
ALLOW_DIRTY=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -255,12 +248,56 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [[ -z "$IMAGE_TAG" ]]; then
|
||||
IMAGE_TAG="$(default_runtime_tag "$TARGET_ARCH")"
|
||||
SOURCE_COMMIT_FULL="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD)"
|
||||
SOURCE_DIRTY="false"
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --short)" ]]; then
|
||||
SOURCE_DIRTY="true"
|
||||
fi
|
||||
if [[ -z "$METRICS_IMAGE" ]]; then
|
||||
METRICS_IMAGE="$(default_metrics_tag "$TARGET_ARCH")"
|
||||
if [[ "$SOURCE_DIRTY" == "true" && "$ALLOW_DIRTY" != "1" ]]; then
|
||||
cat >&2 <<EOF
|
||||
refusing to build a release installer from a dirty worktree.
|
||||
source_commit: $SOURCE_COMMIT_FULL
|
||||
Use --allow-dirty only for development verification; the package and image tags
|
||||
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="$VERSION"
|
||||
if [[ "$SOURCE_DIRTY" == "true" ]]; then
|
||||
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
|
||||
fi
|
||||
IMAGE_TAG="$(runtime_image_tag "$TARGET_ARCH" "$REVISION_TOKEN")"
|
||||
METRICS_IMAGE="$(metrics_image_tag "$TARGET_ARCH" "$REVISION_TOKEN")"
|
||||
if [[ -z "$OUT_DIR" ]]; then
|
||||
OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-installer"
|
||||
fi
|
||||
@@ -272,37 +309,35 @@ TARGET_PLATFORM="$(platform_for_arch "$TARGET_ARCH")"
|
||||
HOST_DATA_DIR_DEFAULT="$(default_host_data_dir "$TARGET_ARCH")"
|
||||
COMPOSE_PROJECT_DEFAULT="$(default_compose_project "$TARGET_ARCH")"
|
||||
METRICS_INSTANCE_DEFAULT="$(default_metrics_instance "$TARGET_ARCH")"
|
||||
|
||||
if [[ -z "$IMAGE_TAR" ]]; then
|
||||
IMAGE_TAR="$(default_saved_image_tar "$IMAGE_TAG")"
|
||||
fi
|
||||
if [[ -z "$METRICS_IMAGE_TAR" ]]; then
|
||||
METRICS_IMAGE_TAR="$(default_saved_image_tar "$METRICS_IMAGE")"
|
||||
fi
|
||||
|
||||
[[ -n "$IMAGE_TAR" && -f "$IMAGE_TAR" ]] || {
|
||||
cat >&2 <<EOF
|
||||
missing runtime image tar for $TARGET_ARCH.
|
||||
|
||||
Build one first, for example:
|
||||
scripts/docker/build_docker_runtime_image.sh --arch $TARGET_ARCH --image $IMAGE_TAG
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
[[ -n "$METRICS_IMAGE_TAR" && -f "$METRICS_IMAGE_TAR" ]] || {
|
||||
cat >&2 <<EOF
|
||||
missing metrics image tar for $TARGET_ARCH.
|
||||
|
||||
Build one first, for example:
|
||||
scripts/docker/build_docker_metrics_image.sh --arch $TARGET_ARCH --image $METRICS_IMAGE
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
IMAGE_OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-docker"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
commit="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
package_name="${PACKAGE_PREFIX}-${timestamp}-${commit}"
|
||||
echo "rebuilding runtime image: $IMAGE_TAG"
|
||||
"$SCRIPT_DIR/build_docker_runtime_image.sh" \
|
||||
--arch "$TARGET_ARCH" \
|
||||
--image "$IMAGE_TAG" \
|
||||
--out-dir "$IMAGE_OUT_DIR"
|
||||
echo "rebuilding metrics image: $METRICS_IMAGE"
|
||||
"$SCRIPT_DIR/build_docker_metrics_image.sh" \
|
||||
--arch "$TARGET_ARCH" \
|
||||
--image "$METRICS_IMAGE" \
|
||||
--out-dir "$IMAGE_OUT_DIR"
|
||||
|
||||
IMAGE_TAR="$IMAGE_OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar.gz"
|
||||
METRICS_IMAGE_TAR="$IMAGE_OUT_DIR/$(safe_tag_name "$METRICS_IMAGE").tar.gz"
|
||||
[[ -f "$IMAGE_TAR" ]] || { echo "runtime image build did not produce $IMAGE_TAR" >&2; exit 2; }
|
||||
[[ -f "$METRICS_IMAGE_TAR" ]] || { echo "metrics image build did not produce $METRICS_IMAGE_TAR" >&2; exit 2; }
|
||||
|
||||
runtime_image_revision="$(image_label "$IMAGE_TAG" "org.opencontainers.image.revision")"
|
||||
metrics_image_revision="$(image_label "$METRICS_IMAGE" "org.opencontainers.image.revision")"
|
||||
runtime_image_dirty="$(image_label "$IMAGE_TAG" "org.opencontainers.image.source-dirty")"
|
||||
metrics_image_dirty="$(image_label "$METRICS_IMAGE" "org.opencontainers.image.source-dirty")"
|
||||
[[ "$runtime_image_revision" == "$SOURCE_COMMIT_FULL" ]] || { echo "runtime image revision mismatch: $runtime_image_revision != $SOURCE_COMMIT_FULL" >&2; exit 2; }
|
||||
[[ "$metrics_image_revision" == "$SOURCE_COMMIT_FULL" ]] || { echo "metrics image revision mismatch: $metrics_image_revision != $SOURCE_COMMIT_FULL" >&2; exit 2; }
|
||||
[[ "$runtime_image_dirty" == "$SOURCE_DIRTY" ]] || { echo "runtime image dirty flag mismatch" >&2; exit 2; }
|
||||
[[ "$metrics_image_dirty" == "$SOURCE_DIRTY" ]] || { echo "metrics image dirty flag mismatch" >&2; exit 2; }
|
||||
|
||||
package_name="${PACKAGE_PREFIX}-${REVISION_TOKEN}-${TIMESTAMP}"
|
||||
stage="$OUT_DIR/$package_name"
|
||||
tar_path="$OUT_DIR/$package_name.tar.gz"
|
||||
|
||||
@@ -312,7 +347,7 @@ mkdir -p "$stage/images"
|
||||
cp "$IMAGE_TAR" "$stage/images/"
|
||||
cp "$METRICS_IMAGE_TAR" "$stage/images/"
|
||||
|
||||
monitor_image_stage="$OUT_DIR/.monitor-images-$TARGET_ARCH-$timestamp"
|
||||
monitor_image_stage="$OUT_DIR/.monitor-images-$TARGET_ARCH-$TIMESTAMP"
|
||||
rm -rf "$monitor_image_stage"
|
||||
mkdir -p "$monitor_image_stage"
|
||||
prometheus_tar="$(save_image_if_needed "$PROMETHEUS_IMAGE" "$PROMETHEUS_IMAGE_TAR" "$monitor_image_stage" "prometheus" "$TARGET_PLATFORM")"
|
||||
@@ -354,6 +389,10 @@ replace_text_in_file "$stage/docs/operations.zh-CN.md" "__HOST_DATA_DIR__" "$HOS
|
||||
replace_text_in_file "$stage/docs/operations.en.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
|
||||
replace_text_in_file "$stage/docs/troubleshooting.zh-CN.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
|
||||
replace_text_in_file "$stage/docs/troubleshooting.en.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
|
||||
replace_text_in_file "$stage/docs/README.zh-CN.md" "__RUNTIME_IMAGE__" "$IMAGE_TAG"
|
||||
replace_text_in_file "$stage/docs/README.en.md" "__RUNTIME_IMAGE__" "$IMAGE_TAG"
|
||||
replace_text_in_file "$stage/docs/README.zh-CN.md" "__METRICS_IMAGE__" "$METRICS_IMAGE"
|
||||
replace_text_in_file "$stage/docs/README.en.md" "__METRICS_IMAGE__" "$METRICS_IMAGE"
|
||||
|
||||
runtime_tar_sha256="$(sha256sum "$IMAGE_TAR" | awk '{print $1}')"
|
||||
metrics_tar_sha256="$(sha256sum "$METRICS_IMAGE_TAR" | awk '{print $1}')"
|
||||
@@ -361,9 +400,16 @@ prometheus_tar_sha256="$(sha256sum "$prometheus_tar" | awk '{print $1}')"
|
||||
grafana_tar_sha256="$(sha256sum "$grafana_tar" | awk '{print $1}')"
|
||||
|
||||
cat > "$stage/PACKAGE-MANIFEST.env" <<EOF
|
||||
package_schema_version=2
|
||||
package_name=$package_name
|
||||
created_at_utc=$timestamp
|
||||
git_commit=$commit
|
||||
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
|
||||
git_commit=$SOURCE_COMMIT_FULL
|
||||
git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')
|
||||
PACKAGE_ARCH=$TARGET_ARCH
|
||||
PACKAGE_PLATFORM=$TARGET_PLATFORM
|
||||
@@ -377,6 +423,9 @@ image_tag=$IMAGE_TAG
|
||||
image_tar=$(basename "$IMAGE_TAR")
|
||||
image_tar_size_bytes=$(wc -c < "$IMAGE_TAR")
|
||||
image_tar_sha256=$runtime_tar_sha256
|
||||
runtime_image=$IMAGE_TAG
|
||||
runtime_image_revision=$runtime_image_revision
|
||||
runtime_image_dirty=$runtime_image_dirty
|
||||
rpki_image=$IMAGE_TAG
|
||||
rpki_platform=$TARGET_PLATFORM
|
||||
METRICS_IMAGE=$METRICS_IMAGE
|
||||
@@ -385,6 +434,8 @@ metrics_image=$METRICS_IMAGE
|
||||
metrics_image_tar=$(basename "$METRICS_IMAGE_TAR")
|
||||
metrics_image_tar_size_bytes=$(wc -c < "$METRICS_IMAGE_TAR")
|
||||
metrics_image_tar_sha256=$metrics_tar_sha256
|
||||
metrics_image_revision=$metrics_image_revision
|
||||
metrics_image_dirty=$metrics_image_dirty
|
||||
PROMETHEUS_IMAGE=$PROMETHEUS_IMAGE
|
||||
prometheus_image=$PROMETHEUS_IMAGE
|
||||
prometheus_image_tar=$(basename "$prometheus_tar")
|
||||
@@ -407,10 +458,18 @@ 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
|
||||
build_timestamp_utc: $TIMESTAMP
|
||||
package_arch: $TARGET_ARCH
|
||||
package_platform: $TARGET_PLATFORM
|
||||
runtime_image: $IMAGE_TAG
|
||||
runtime_image_revision: $runtime_image_revision
|
||||
metrics_image: $METRICS_IMAGE
|
||||
metrics_image_revision: $metrics_image_revision
|
||||
prometheus_image: $PROMETHEUS_IMAGE
|
||||
grafana_image: $GRAFANA_IMAGE
|
||||
default_host_data_dir: $HOST_DATA_DIR_DEFAULT
|
||||
@@ -419,7 +478,12 @@ cross_arch_default: deny
|
||||
cross_arch_enable_var: ALLOW_CROSS_ARCH
|
||||
EOF
|
||||
|
||||
chmod +x "$stage"/*.sh "$stage/scripts"/*.sh
|
||||
chmod +x "$stage/scripts"/*.sh
|
||||
|
||||
if find "$stage" -maxdepth 1 -type f -name '*.sh' -print -quit | grep -q .; then
|
||||
echo "error: operational scripts must be installed under scripts/ only" >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -C "$OUT_DIR" -czf "$tar_path" "$package_name"
|
||||
package_sha256="$(sha256sum "$tar_path" | awk '{print $1}')"
|
||||
rm -rf "$monitor_image_stage"
|
||||
@@ -429,6 +493,10 @@ rm -rf "$monitor_image_stage"
|
||||
echo "package_dir=$stage"
|
||||
echo "package_size_bytes=$(wc -c < "$tar_path")"
|
||||
echo "package_sha256=$package_sha256"
|
||||
echo "source_commit=$SOURCE_COMMIT_FULL"
|
||||
echo "source_commit_short=$SOURCE_COMMIT_SHORT"
|
||||
echo "source_dirty=$SOURCE_DIRTY"
|
||||
echo "build_timestamp_utc=$TIMESTAMP"
|
||||
echo "package_arch=$TARGET_ARCH"
|
||||
echo "package_platform=$TARGET_PLATFORM"
|
||||
echo "manifest=$stage/PACKAGE-MANIFEST.env"
|
||||
|
||||
@@ -65,7 +65,11 @@ EOF
|
||||
fi
|
||||
|
||||
if [[ -z "$IMAGE_TAG" ]]; then
|
||||
IMAGE_TAG="ours-rp-metrics-${TARGET_ARCH}:dev"
|
||||
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
|
||||
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
|
||||
fi
|
||||
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>:dev)
|
||||
--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
|
||||
@@ -55,12 +59,21 @@ safe_tag_name() {
|
||||
printf '%s' "$1" | tr '/:' '--'
|
||||
}
|
||||
|
||||
local_base_image_tag() {
|
||||
local role="$1"
|
||||
local arch="$2"
|
||||
local source_image="$3"
|
||||
printf 'ours-rp-base-%s-%s:%s\n' "$role" "$arch" "$(safe_tag_name "$source_image")"
|
||||
}
|
||||
|
||||
platform_for_arch() {
|
||||
printf 'linux/%s\n' "$1"
|
||||
}
|
||||
|
||||
default_image_tag() {
|
||||
printf 'ours-rp-runtime-%s:dev\n' "$1"
|
||||
local arch="$1"
|
||||
local commit_short="$2"
|
||||
printf 'panda-rpki-rp-%s:%s\n' "$arch" "$commit_short"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
@@ -142,8 +155,20 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
SOURCE_COMMIT_FULL="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
|
||||
SOURCE_DIRTY="false"
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
|
||||
SOURCE_DIRTY="true"
|
||||
fi
|
||||
BUILD_TIMESTAMP_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [[ -z "$IMAGE_TAG" ]]; then
|
||||
IMAGE_TAG="$(default_image_tag "$TARGET_ARCH")"
|
||||
default_revision="$SOURCE_COMMIT_SHORT"
|
||||
if [[ "$SOURCE_DIRTY" == "true" ]]; then
|
||||
default_revision="${default_revision}-dirty"
|
||||
fi
|
||||
IMAGE_TAG="$(default_image_tag "$TARGET_ARCH" "$default_revision")"
|
||||
fi
|
||||
if [[ -z "$OUT_DIR" ]]; then
|
||||
OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-docker"
|
||||
@@ -154,12 +179,29 @@ mkdir -p "$OUT_DIR"
|
||||
|
||||
target_platform="$(platform_for_arch "$TARGET_ARCH")"
|
||||
local_host_arch="$(host_arch)"
|
||||
builder_platform="$(platform_for_arch "$local_host_arch")"
|
||||
builder_source_image="$BUILDER_IMAGE"
|
||||
runtime_source_image="$RUNTIME_IMAGE"
|
||||
builder_local_image="$(local_base_image_tag builder "$local_host_arch" "$builder_source_image")"
|
||||
runtime_local_image="$(local_base_image_tag runtime "$TARGET_ARCH" "$runtime_source_image")"
|
||||
|
||||
if [[ "$INSTALL_BINFMT" == "1" && "$local_host_arch" != "$TARGET_ARCH" ]]; then
|
||||
echo "installing binfmt/qemu for $TARGET_ARCH"
|
||||
docker run --rm --privileged tonistiigi/binfmt --install "$TARGET_ARCH"
|
||||
fi
|
||||
|
||||
# Pulling through the Docker daemon uses its configured proxy. Buildx then consumes
|
||||
# these architecture-pinned local aliases without issuing separate registry metadata
|
||||
# requests for the Dockerfile FROM instructions.
|
||||
echo "pulling builder base image: $builder_source_image ($builder_platform)"
|
||||
docker pull --platform "$builder_platform" "$builder_source_image"
|
||||
docker tag "$builder_source_image" "$builder_local_image"
|
||||
echo "pulling runtime base image: $runtime_source_image ($target_platform)"
|
||||
docker pull --platform "$target_platform" "$runtime_source_image"
|
||||
docker tag "$runtime_source_image" "$runtime_local_image"
|
||||
BUILDER_IMAGE="$builder_local_image"
|
||||
RUNTIME_IMAGE="$runtime_local_image"
|
||||
|
||||
if [[ "$BUILDER_NAME" != "default" ]] && ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
docker buildx create --name "$BUILDER_NAME" --driver docker-container --use >/dev/null
|
||||
else
|
||||
@@ -174,18 +216,29 @@ build_log="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build.log"
|
||||
echo "building $target_platform image: $IMAGE_TAG"
|
||||
echo "repo: $REPO_ROOT"
|
||||
echo "dockerfile: $DOCKERFILE"
|
||||
echo "builder_image: $BUILDER_IMAGE"
|
||||
echo "runtime_image: $RUNTIME_IMAGE"
|
||||
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"
|
||||
start_epoch="$(date +%s)"
|
||||
|
||||
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 \
|
||||
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
|
||||
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
|
||||
--build-arg "SOURCE_COMMIT=$SOURCE_COMMIT_FULL" \
|
||||
--build-arg "SOURCE_DIRTY=$SOURCE_DIRTY" \
|
||||
--build-arg "BUILD_TIMESTAMP_UTC=$BUILD_TIMESTAMP_UTC" \
|
||||
--metadata-file "$metadata_path" \
|
||||
-t "$IMAGE_TAG" \
|
||||
-f "$DOCKERFILE" \
|
||||
@@ -195,10 +248,15 @@ 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 \
|
||||
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
|
||||
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
|
||||
--build-arg "SOURCE_COMMIT=$SOURCE_COMMIT_FULL" \
|
||||
--build-arg "SOURCE_DIRTY=$SOURCE_DIRTY" \
|
||||
--build-arg "BUILD_TIMESTAMP_UTC=$BUILD_TIMESTAMP_UTC" \
|
||||
--metadata-file "$metadata_path" \
|
||||
-t "$IMAGE_TAG" \
|
||||
-f "$DOCKERFILE" \
|
||||
@@ -223,16 +281,22 @@ fi
|
||||
echo "image=$IMAGE_TAG"
|
||||
echo "platform=$target_platform"
|
||||
echo "arch=$TARGET_ARCH"
|
||||
echo "builder_image=$BUILDER_IMAGE"
|
||||
echo "runtime_image=$RUNTIME_IMAGE"
|
||||
echo "builder_image=$builder_source_image"
|
||||
echo "runtime_image=$runtime_source_image"
|
||||
echo "builder_local_image=$BUILDER_IMAGE"
|
||||
echo "runtime_local_image=$RUNTIME_IMAGE"
|
||||
echo "elapsed_secs=$elapsed_secs"
|
||||
echo "metadata=$metadata_path"
|
||||
echo "tar=$tar_path"
|
||||
echo "tar_size_bytes=$(wc -c < "$tar_path" 2>/dev/null || echo 0)"
|
||||
echo "tar_sha256=$tar_sha256"
|
||||
echo "git_commit=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
echo "source_commit=$SOURCE_COMMIT_FULL"
|
||||
echo "source_commit_short=$SOURCE_COMMIT_SHORT"
|
||||
echo "source_dirty=$SOURCE_DIRTY"
|
||||
echo "build_timestamp_utc=$BUILD_TIMESTAMP_UTC"
|
||||
echo "git_commit=$SOURCE_COMMIT_FULL"
|
||||
echo "git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo "built_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "built_at_utc=$BUILD_TIMESTAMP_UTC"
|
||||
} > "$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build-summary.env"
|
||||
|
||||
echo "build complete: elapsed=${elapsed_secs}s tar=$tar_path"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,6 +29,7 @@ pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
|
||||
pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1);
|
||||
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
|
||||
pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32);
|
||||
pub const OID_QT_CPS: &str = "1.3.6.1.5.5.7.2.1";
|
||||
|
||||
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
|
||||
pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35);
|
||||
|
||||
@@ -13,11 +13,14 @@ use crate::data_model::common::{
|
||||
use crate::data_model::oid::{
|
||||
OID_AD_CA_ISSUERS_RAW, OID_AD_CA_REPOSITORY, OID_AD_CA_REPOSITORY_RAW, OID_AD_RPKI_MANIFEST,
|
||||
OID_AD_RPKI_MANIFEST_RAW, OID_AD_RPKI_NOTIFY, OID_AD_RPKI_NOTIFY_RAW, OID_AD_SIGNED_OBJECT,
|
||||
OID_AD_SIGNED_OBJECT_RAW, OID_AUTHORITY_INFO_ACCESS_RAW, OID_AUTHORITY_KEY_IDENTIFIER_RAW,
|
||||
OID_AUTONOMOUS_SYS_IDS_RAW, OID_BASIC_CONSTRAINTS_RAW, OID_CERTIFICATE_POLICIES_RAW,
|
||||
OID_CP_IPADDR_ASNUMBER, OID_CP_IPADDR_ASNUMBER_RAW, OID_CRL_DISTRIBUTION_POINTS_RAW,
|
||||
OID_IP_ADDR_BLOCKS_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW,
|
||||
OID_SUBJECT_INFO_ACCESS_RAW, OID_SUBJECT_KEY_IDENTIFIER_RAW,
|
||||
OID_AD_SIGNED_OBJECT_RAW, OID_AUTHORITY_INFO_ACCESS, OID_AUTHORITY_INFO_ACCESS_RAW,
|
||||
OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_AUTONOMOUS_SYS_IDS,
|
||||
OID_AUTONOMOUS_SYS_IDS_RAW, OID_BASIC_CONSTRAINTS, OID_BASIC_CONSTRAINTS_RAW,
|
||||
OID_CERTIFICATE_POLICIES, OID_CERTIFICATE_POLICIES_RAW, OID_CP_IPADDR_ASNUMBER,
|
||||
OID_CP_IPADDR_ASNUMBER_RAW, OID_CRL_DISTRIBUTION_POINTS, OID_CRL_DISTRIBUTION_POINTS_RAW,
|
||||
OID_EXTENDED_KEY_USAGE, OID_IP_ADDR_BLOCKS, OID_IP_ADDR_BLOCKS_RAW, OID_KEY_USAGE, OID_QT_CPS,
|
||||
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SUBJECT_INFO_ACCESS,
|
||||
OID_SUBJECT_INFO_ACCESS_RAW, OID_SUBJECT_KEY_IDENTIFIER, OID_SUBJECT_KEY_IDENTIFIER_RAW,
|
||||
};
|
||||
|
||||
/// Resource Certificate kind (semantic classification).
|
||||
@@ -27,6 +30,14 @@ pub enum ResourceCertKind {
|
||||
Ee,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResourceCertificateRole {
|
||||
TrustAnchor,
|
||||
Ca,
|
||||
SignedObjectEe,
|
||||
RouterEe,
|
||||
}
|
||||
|
||||
/// A parsed RPKI Resource Certificate (RFC 6487) data model.
|
||||
///
|
||||
/// This module intentionally focuses on the semantics needed by Signed Object validation and
|
||||
@@ -59,6 +70,7 @@ pub struct RpkixTbsCertificate {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RcExtensions {
|
||||
pub basic_constraints_ca: bool,
|
||||
pub basic_constraints: Option<BasicConstraintsProfile>,
|
||||
pub subject_key_identifier: Option<Vec<u8>>,
|
||||
/// Authority Key Identifier (AKI) keyIdentifier value.
|
||||
pub authority_key_identifier: Option<Vec<u8>>,
|
||||
@@ -68,11 +80,26 @@ pub struct RcExtensions {
|
||||
pub ca_issuers_uris: Option<Vec<String>>,
|
||||
pub subject_info_access: Option<SubjectInfoAccess>,
|
||||
pub certificate_policies_oid: Option<String>,
|
||||
pub certificate_policies: Option<CertificatePoliciesProfile>,
|
||||
pub extension_oids: Vec<String>,
|
||||
|
||||
pub ip_resources: Option<IpResourceSet>,
|
||||
pub as_resources: Option<AsResourceSet>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BasicConstraintsProfile {
|
||||
pub ca: bool,
|
||||
pub critical: bool,
|
||||
pub path_len_constraint: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CertificatePoliciesProfile {
|
||||
pub policy_oid: String,
|
||||
pub qualifier_oids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResourceCertificateParsed {
|
||||
pub raw_der: Vec<u8>,
|
||||
@@ -114,13 +141,14 @@ impl AlgorithmIdentifierValue {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RcExtensionsParsed {
|
||||
pub basic_constraints_ca: Vec<bool>,
|
||||
pub basic_constraints: Vec<BasicConstraintsProfile>,
|
||||
pub subject_key_identifier: Vec<(Vec<u8>, bool)>,
|
||||
pub authority_key_identifier: Vec<(AuthorityKeyIdentifierParsed, bool)>,
|
||||
pub crl_distribution_points: Vec<(CrlDistributionPointsParsed, bool)>,
|
||||
pub authority_info_access: Vec<(AuthorityInfoAccessParsed, bool)>,
|
||||
pub subject_info_access: Vec<(SubjectInfoAccessParsed, bool)>,
|
||||
pub certificate_policies: Vec<(Vec<String>, bool)>,
|
||||
pub certificate_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)>,
|
||||
pub extension_oids: Vec<String>,
|
||||
pub ip_resources: Vec<(IpResourceSet, bool)>,
|
||||
pub as_resources: Vec<(AsResourceSet, bool)>,
|
||||
}
|
||||
@@ -406,11 +434,45 @@ pub enum ResourceCertificateProfileError {
|
||||
#[error("certificatePolicies criticality must be critical (RFC 6487 §4.8.9)")]
|
||||
CertificatePoliciesCriticality,
|
||||
|
||||
#[error("certificatePolicies must be present (RFC 6487 §4.8.9)")]
|
||||
CertificatePoliciesMissing,
|
||||
|
||||
#[error(
|
||||
"certificatePolicies must contain RPKI policy OID {OID_CP_IPADDR_ASNUMBER}, got {0} (RFC 6487 §4.8.9)"
|
||||
)]
|
||||
InvalidCertificatePolicy(String),
|
||||
|
||||
#[error("certificatePolicies may contain at most one CPS qualifier (RFC 6487 §4.8.9)")]
|
||||
CertificatePoliciesTooManyQualifiers,
|
||||
|
||||
#[error(
|
||||
"certificatePolicies qualifier must be id-qt-cps ({OID_QT_CPS}), got {0} (RFC 6487 §4.8.9)"
|
||||
)]
|
||||
CertificatePoliciesInvalidQualifier(String),
|
||||
|
||||
#[error("basicConstraints must be present in CA certificates (RFC 6487 §4.8.1)")]
|
||||
BasicConstraintsMissing,
|
||||
|
||||
#[error("basicConstraints criticality must be critical in CA certificates (RFC 6487 §4.8.1)")]
|
||||
BasicConstraintsCriticality,
|
||||
|
||||
#[error("basicConstraints cA must be TRUE in CA certificates (RFC 6487 §4.8.1)")]
|
||||
BasicConstraintsCaFalse,
|
||||
|
||||
#[error(
|
||||
"basicConstraints pathLenConstraint must be absent in CA certificates (RFC 6487 §4.8.1)"
|
||||
)]
|
||||
BasicConstraintsPathLenPresent,
|
||||
|
||||
#[error("basicConstraints must be absent in EE certificates (RFC 6487 §4.8.1)")]
|
||||
BasicConstraintsEeMustOmit,
|
||||
|
||||
#[error("extension {oid} is not permitted for {role} resource certificates (RFC 6487 §4.8)")]
|
||||
DisallowedExtension { role: &'static str, oid: String },
|
||||
|
||||
#[error("autonomousSysIds RDI field must be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3)")]
|
||||
AsResourcesRdiPresent,
|
||||
|
||||
#[error(
|
||||
"SIA id-ad-signedObject accessLocation must be URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)"
|
||||
)]
|
||||
@@ -567,6 +629,87 @@ impl ResourceCertificate {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_rfc6487_profile(
|
||||
&self,
|
||||
role: ResourceCertificateRole,
|
||||
) -> Result<(), ResourceCertificateProfileError> {
|
||||
let role_name = match role {
|
||||
ResourceCertificateRole::TrustAnchor => "trust anchor CA",
|
||||
ResourceCertificateRole::Ca => "CA",
|
||||
ResourceCertificateRole::SignedObjectEe => "signed-object EE",
|
||||
ResourceCertificateRole::RouterEe => "router EE",
|
||||
};
|
||||
let ca_role = matches!(
|
||||
role,
|
||||
ResourceCertificateRole::TrustAnchor | ResourceCertificateRole::Ca
|
||||
);
|
||||
|
||||
if ca_role {
|
||||
let constraints = self
|
||||
.tbs
|
||||
.extensions
|
||||
.basic_constraints
|
||||
.as_ref()
|
||||
.ok_or(ResourceCertificateProfileError::BasicConstraintsMissing)?;
|
||||
if !constraints.critical {
|
||||
return Err(ResourceCertificateProfileError::BasicConstraintsCriticality);
|
||||
}
|
||||
if !constraints.ca {
|
||||
return Err(ResourceCertificateProfileError::BasicConstraintsCaFalse);
|
||||
}
|
||||
if constraints.path_len_constraint.is_some() {
|
||||
return Err(ResourceCertificateProfileError::BasicConstraintsPathLenPresent);
|
||||
}
|
||||
} else if self.tbs.extensions.basic_constraints.is_some() {
|
||||
return Err(ResourceCertificateProfileError::BasicConstraintsEeMustOmit);
|
||||
}
|
||||
|
||||
for oid in &self.tbs.extensions.extension_oids {
|
||||
if !is_permitted_extension(oid, role) {
|
||||
return Err(ResourceCertificateProfileError::DisallowedExtension {
|
||||
role: role_name,
|
||||
oid: oid.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let policies = self
|
||||
.tbs
|
||||
.extensions
|
||||
.certificate_policies
|
||||
.as_ref()
|
||||
.ok_or(ResourceCertificateProfileError::CertificatePoliciesMissing)?;
|
||||
if policies.policy_oid != OID_CP_IPADDR_ASNUMBER {
|
||||
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
|
||||
policies.policy_oid.clone(),
|
||||
));
|
||||
}
|
||||
if policies.qualifier_oids.len() > 1 {
|
||||
return Err(ResourceCertificateProfileError::CertificatePoliciesTooManyQualifiers);
|
||||
}
|
||||
if let Some(qualifier_oid) = policies.qualifier_oids.first() {
|
||||
if qualifier_oid != OID_QT_CPS {
|
||||
return Err(
|
||||
ResourceCertificateProfileError::CertificatePoliciesInvalidQualifier(
|
||||
qualifier_oid.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self
|
||||
.tbs
|
||||
.extensions
|
||||
.as_resources
|
||||
.as_ref()
|
||||
.is_some_and(|resources| resources.rdi.is_some())
|
||||
{
|
||||
return Err(ResourceCertificateProfileError::AsResourcesRdiPresent);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_strict_name_profile(&self) -> Result<(), ResourceCertificateProfileError> {
|
||||
validate_strict_rpki_name(&self.tbs.issuer_name, "issuer")?;
|
||||
validate_strict_rpki_name(&self.tbs.subject_name, "subject")?;
|
||||
@@ -590,6 +733,22 @@ impl ResourceCertificate {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_permitted_extension(oid: &str, role: ResourceCertificateRole) -> bool {
|
||||
matches!(
|
||||
oid,
|
||||
OID_BASIC_CONSTRAINTS
|
||||
| OID_KEY_USAGE
|
||||
| OID_SUBJECT_KEY_IDENTIFIER
|
||||
| OID_AUTHORITY_KEY_IDENTIFIER
|
||||
| OID_CRL_DISTRIBUTION_POINTS
|
||||
| OID_AUTHORITY_INFO_ACCESS
|
||||
| OID_SUBJECT_INFO_ACCESS
|
||||
| OID_CERTIFICATE_POLICIES
|
||||
| OID_IP_ADDR_BLOCKS
|
||||
| OID_AUTONOMOUS_SYS_IDS
|
||||
) || (role == ResourceCertificateRole::RouterEe && oid == OID_EXTENDED_KEY_USAGE)
|
||||
}
|
||||
|
||||
fn validate_strict_rpki_name(
|
||||
name: &X509NameDer,
|
||||
role: &'static str,
|
||||
@@ -744,13 +903,24 @@ mod strict_name_tests {
|
||||
#[test]
|
||||
fn profile_rejects_rfc8360_v2_policy_oid() {
|
||||
let extensions = RcExtensionsParsed {
|
||||
basic_constraints_ca: vec![true],
|
||||
basic_constraints: vec![BasicConstraintsProfile {
|
||||
ca: true,
|
||||
critical: true,
|
||||
path_len_constraint: None,
|
||||
}],
|
||||
subject_key_identifier: Vec::new(),
|
||||
authority_key_identifier: Vec::new(),
|
||||
crl_distribution_points: Vec::new(),
|
||||
authority_info_access: Vec::new(),
|
||||
subject_info_access: Vec::new(),
|
||||
certificate_policies: vec![(vec!["1.3.6.1.5.5.7.14.3".to_string()], true)],
|
||||
certificate_policies: vec![(
|
||||
vec![CertificatePoliciesProfile {
|
||||
policy_oid: "1.3.6.1.5.5.7.14.3".to_string(),
|
||||
qualifier_oids: Vec::new(),
|
||||
}],
|
||||
true,
|
||||
)],
|
||||
extension_oids: Vec::new(),
|
||||
ip_resources: Vec::new(),
|
||||
as_resources: Vec::new(),
|
||||
};
|
||||
@@ -821,23 +991,25 @@ impl RcExtensionsParsed {
|
||||
// NOTE(perf): `self` is consumed. Prefer moving decoded fields out rather than cloning,
|
||||
// especially for large resource sets and URI lists.
|
||||
let RcExtensionsParsed {
|
||||
basic_constraints_ca,
|
||||
basic_constraints,
|
||||
subject_key_identifier,
|
||||
authority_key_identifier,
|
||||
crl_distribution_points,
|
||||
authority_info_access,
|
||||
subject_info_access,
|
||||
certificate_policies,
|
||||
extension_oids,
|
||||
ip_resources,
|
||||
as_resources,
|
||||
} = self;
|
||||
|
||||
if basic_constraints_ca.len() > 1 {
|
||||
if basic_constraints.len() > 1 {
|
||||
return Err(ResourceCertificateProfileError::DuplicateExtension(
|
||||
"basicConstraints",
|
||||
));
|
||||
}
|
||||
let basic_constraints_ca = basic_constraints_ca.first().copied().unwrap_or(false);
|
||||
let basic_constraints = basic_constraints.into_iter().next();
|
||||
let basic_constraints_ca = basic_constraints.as_ref().is_some_and(|bc| bc.ca);
|
||||
|
||||
let subject_key_identifier = match subject_key_identifier.len() {
|
||||
0 => None,
|
||||
@@ -1032,25 +1204,25 @@ impl RcExtensionsParsed {
|
||||
}
|
||||
};
|
||||
|
||||
let certificate_policies_oid = match certificate_policies.len() {
|
||||
let certificate_policies = match certificate_policies.len() {
|
||||
0 => None,
|
||||
1 => {
|
||||
let (oids, critical) = certificate_policies.into_iter().next().expect("len==1");
|
||||
let (policies, critical) = certificate_policies.into_iter().next().expect("len==1");
|
||||
if !critical {
|
||||
return Err(ResourceCertificateProfileError::CertificatePoliciesCriticality);
|
||||
}
|
||||
if oids.len() != 1 {
|
||||
if policies.len() != 1 {
|
||||
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
|
||||
"expected exactly one policy".into(),
|
||||
));
|
||||
}
|
||||
let policy_oid = oids.into_iter().next().expect("len==1");
|
||||
if policy_oid != OID_CP_IPADDR_ASNUMBER {
|
||||
let policy = policies.into_iter().next().expect("len==1");
|
||||
if policy.policy_oid != OID_CP_IPADDR_ASNUMBER {
|
||||
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
|
||||
policy_oid,
|
||||
policy.policy_oid,
|
||||
));
|
||||
}
|
||||
Some(OID_CP_IPADDR_ASNUMBER.to_string())
|
||||
Some(policy)
|
||||
}
|
||||
_ => {
|
||||
return Err(ResourceCertificateProfileError::DuplicateExtension(
|
||||
@@ -1093,12 +1265,17 @@ impl RcExtensionsParsed {
|
||||
|
||||
Ok(RcExtensions {
|
||||
basic_constraints_ca,
|
||||
basic_constraints,
|
||||
subject_key_identifier,
|
||||
authority_key_identifier,
|
||||
crl_distribution_points_uris,
|
||||
ca_issuers_uris,
|
||||
subject_info_access,
|
||||
certificate_policies_oid,
|
||||
certificate_policies_oid: certificate_policies
|
||||
.as_ref()
|
||||
.map(|_| OID_CP_IPADDR_ASNUMBER.to_string()),
|
||||
certificate_policies,
|
||||
extension_oids,
|
||||
ip_resources,
|
||||
as_resources,
|
||||
})
|
||||
@@ -1127,26 +1304,32 @@ fn algorithm_identifier_value(
|
||||
fn parse_extensions_parse(
|
||||
exts: &[X509Extension<'_>],
|
||||
) -> Result<RcExtensionsParsed, ResourceCertificateParseError> {
|
||||
let mut basic_constraints_ca: Vec<bool> = Vec::new();
|
||||
let mut basic_constraints: Vec<BasicConstraintsProfile> = Vec::new();
|
||||
let mut ski: Vec<(Vec<u8>, bool)> = Vec::new();
|
||||
let mut aki: Vec<(AuthorityKeyIdentifierParsed, bool)> = Vec::new();
|
||||
let mut crldp: Vec<(CrlDistributionPointsParsed, bool)> = Vec::new();
|
||||
let mut aia: Vec<(AuthorityInfoAccessParsed, bool)> = Vec::new();
|
||||
let mut sia: Vec<(SubjectInfoAccessParsed, bool)> = Vec::new();
|
||||
let mut cert_policies: Vec<(Vec<String>, bool)> = Vec::new();
|
||||
let mut cert_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)> = Vec::new();
|
||||
let mut extension_oids: Vec<String> = Vec::with_capacity(exts.len());
|
||||
|
||||
let mut ip_resources: Vec<(IpResourceSet, bool)> = Vec::new();
|
||||
let mut as_resources: Vec<(AsResourceSet, bool)> = Vec::new();
|
||||
|
||||
for ext in exts {
|
||||
let oid = ext.oid.as_bytes();
|
||||
extension_oids.push(ext.oid.to_id_string());
|
||||
if oid == OID_BASIC_CONSTRAINTS_RAW {
|
||||
let ParsedExtension::BasicConstraints(bc) = ext.parsed_extension() else {
|
||||
return Err(ResourceCertificateParseError::Parse(
|
||||
"basicConstraints parse failed".into(),
|
||||
));
|
||||
};
|
||||
basic_constraints_ca.push(bc.ca);
|
||||
basic_constraints.push(BasicConstraintsProfile {
|
||||
ca: bc.ca,
|
||||
critical: ext.critical,
|
||||
path_len_constraint: bc.path_len_constraint,
|
||||
});
|
||||
} else if oid == OID_SUBJECT_KEY_IDENTIFIER_RAW {
|
||||
let ParsedExtension::SubjectKeyIdentifier(s) = ext.parsed_extension() else {
|
||||
return Err(ResourceCertificateParseError::Parse(
|
||||
@@ -1195,16 +1378,30 @@ fn parse_extensions_parse(
|
||||
"certificatePolicies parse failed".into(),
|
||||
));
|
||||
};
|
||||
let mut oids: Vec<String> = Vec::with_capacity(cp.len());
|
||||
let mut policies: Vec<CertificatePoliciesProfile> = Vec::with_capacity(cp.len());
|
||||
for p in cp.iter() {
|
||||
let b = p.policy_id.as_bytes();
|
||||
if b == OID_CP_IPADDR_ASNUMBER_RAW {
|
||||
oids.push(OID_CP_IPADDR_ASNUMBER.to_string());
|
||||
let policy_oid = if b == OID_CP_IPADDR_ASNUMBER_RAW {
|
||||
OID_CP_IPADDR_ASNUMBER.to_string()
|
||||
} else {
|
||||
oids.push(p.policy_id.to_id_string());
|
||||
}
|
||||
p.policy_id.to_id_string()
|
||||
};
|
||||
let qualifier_oids = p
|
||||
.policy_qualifiers
|
||||
.as_ref()
|
||||
.map(|qualifiers| {
|
||||
qualifiers
|
||||
.iter()
|
||||
.map(|qualifier| qualifier.policy_qualifier_id.to_id_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
policies.push(CertificatePoliciesProfile {
|
||||
policy_oid,
|
||||
qualifier_oids,
|
||||
});
|
||||
}
|
||||
cert_policies.push((oids, ext.critical));
|
||||
cert_policies.push((policies, ext.critical));
|
||||
} else if oid == OID_IP_ADDR_BLOCKS_RAW {
|
||||
let parsed = IpResourceSet::decode_extn_value(ext.value)
|
||||
.map_err(|_e| ResourceCertificateParseError::InvalidIpResourcesEncoding)?;
|
||||
@@ -1217,13 +1414,14 @@ fn parse_extensions_parse(
|
||||
}
|
||||
|
||||
Ok(RcExtensionsParsed {
|
||||
basic_constraints_ca,
|
||||
basic_constraints,
|
||||
subject_key_identifier: ski,
|
||||
authority_key_identifier: aki,
|
||||
crl_distribution_points: crldp,
|
||||
authority_info_access: aia,
|
||||
subject_info_access: sia,
|
||||
certificate_policies: cert_policies,
|
||||
extension_oids,
|
||||
ip_resources,
|
||||
as_resources,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::data_model::oid::{
|
||||
use crate::data_model::rc::{
|
||||
AsIdOrRange, AsIdentifierChoice, ResourceCertKind, ResourceCertificate,
|
||||
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
|
||||
ResourceCertificateRole,
|
||||
};
|
||||
use crate::validation::cert_path::{CertPathError, validate_ee_cert_path_with_predecoded_ee};
|
||||
use x509_parser::extensions::ParsedExtension;
|
||||
@@ -202,6 +203,7 @@ impl BgpsecRouterCertificateParsed {
|
||||
self,
|
||||
) -> Result<BgpsecRouterCertificate, BgpsecRouterCertificateProfileError> {
|
||||
let rc = self.rc_parsed.validate_profile()?;
|
||||
rc.validate_rfc6487_profile(ResourceCertificateRole::RouterEe)?;
|
||||
if rc.kind != ResourceCertKind::Ee {
|
||||
return Err(BgpsecRouterCertificateProfileError::NotEe);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::data_model::oid::{
|
||||
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SIGNED_DATA,
|
||||
OID_SIGNED_DATA_RAW, OID_SUBJECT_INFO_ACCESS,
|
||||
};
|
||||
use crate::data_model::rc::{ResourceCertificate, SubjectInfoAccess};
|
||||
use crate::data_model::rc::{ResourceCertificate, ResourceCertificateRole, SubjectInfoAccess};
|
||||
use asn1_rs::{Any, Class, FromBer, FromDer as Asn1FromDer, Header, Tag};
|
||||
use ring::digest;
|
||||
use x509_parser::extensions::ParsedExtension;
|
||||
@@ -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(
|
||||
@@ -822,6 +851,8 @@ fn validate_ee_certificate(
|
||||
};
|
||||
}
|
||||
};
|
||||
rc.validate_rfc6487_profile(ResourceCertificateRole::SignedObjectEe)
|
||||
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
|
||||
if strict_name {
|
||||
rc.validate_strict_name_profile()
|
||||
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
|
||||
@@ -1520,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>> {
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
|
||||
use crate::data_model::rc::{
|
||||
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
|
||||
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
|
||||
ResourceCertificateRole,
|
||||
};
|
||||
use crate::data_model::tal::Tal;
|
||||
|
||||
@@ -218,6 +219,7 @@ impl TaCertificateParsed {
|
||||
if rc_ca.kind != ResourceCertKind::Ca {
|
||||
return Err(TaCertificateProfileError::NotCa);
|
||||
}
|
||||
rc_ca.validate_rfc6487_profile(ResourceCertificateRole::TrustAnchor)?;
|
||||
|
||||
if rc_ca.tbs.issuer_name != rc_ca.tbs.subject_name {
|
||||
return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject);
|
||||
@@ -244,6 +246,9 @@ pub enum TrustAnchorError {
|
||||
#[error("TA certificate error: {0} (RFC 8630 §2.3)")]
|
||||
TaCertificate(#[from] TaCertificateDecodeError),
|
||||
|
||||
#[error("TA certificate self-signature error: {0} (RFC 8630 §2.3)")]
|
||||
TaSelfSignature(#[from] TaCertificateVerifyError),
|
||||
|
||||
#[error("{0}")]
|
||||
Bind(#[from] TrustAnchorBindError),
|
||||
}
|
||||
@@ -269,6 +274,7 @@ impl TrustAnchor {
|
||||
resolved_uri: Option<&Url>,
|
||||
) -> Result<Self, TrustAnchorError> {
|
||||
let ta_certificate = TaCertificate::decode_der(ta_der)?;
|
||||
ta_certificate.verify_self_signature()?;
|
||||
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
|
||||
}
|
||||
|
||||
@@ -278,6 +284,7 @@ impl TrustAnchor {
|
||||
resolved_uri: Option<&Url>,
|
||||
) -> Result<Self, TrustAnchorError> {
|
||||
let ta_certificate = TaCertificate::decode_der_with_strict_name(ta_der)?;
|
||||
ta_certificate.verify_self_signature()?;
|
||||
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
|
||||
}
|
||||
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::{HeaderMap, LOCATION};
|
||||
use url::Url;
|
||||
|
||||
use crate::sync::rrdp::Fetcher;
|
||||
use crate::sync::rrdp::{Fetcher, RrdpError, RrdpFetchError, RrdpOrigin, RrdpResourceKind};
|
||||
|
||||
const MAX_RRDP_REDIRECTS: usize = 10;
|
||||
|
||||
thread_local! {
|
||||
static HTTP_TIMEOUT_OVERRIDE: RefCell<Option<Duration>> = const { RefCell::new(None) };
|
||||
@@ -20,6 +23,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 +64,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(),
|
||||
}
|
||||
}
|
||||
@@ -55,6 +80,9 @@ pub struct BlockingHttpFetcher {
|
||||
short_client: Client,
|
||||
large_body_client: Client,
|
||||
retry_short_client: Client,
|
||||
rrdp_short_client: Client,
|
||||
rrdp_large_body_client: Client,
|
||||
rrdp_retry_short_client: Client,
|
||||
short_timeout: Duration,
|
||||
large_body_timeout: Duration,
|
||||
}
|
||||
@@ -84,7 +112,31 @@ 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())?;
|
||||
let rrdp_short_client = Self::rrdp_client_builder(
|
||||
&config,
|
||||
connect_timeout,
|
||||
config.timeout,
|
||||
config.user_agent.clone(),
|
||||
)?
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rrdp_large_body_client = Self::rrdp_client_builder(
|
||||
&config,
|
||||
connect_timeout,
|
||||
large_body_timeout,
|
||||
config.user_agent.clone(),
|
||||
)?
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rrdp_retry_short_client = Self::rrdp_client_builder(
|
||||
&config,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(1),
|
||||
config.user_agent.clone(),
|
||||
)?
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -92,6 +144,9 @@ impl BlockingHttpFetcher {
|
||||
short_client,
|
||||
large_body_client,
|
||||
retry_short_client,
|
||||
rrdp_short_client,
|
||||
rrdp_large_body_client,
|
||||
rrdp_retry_short_client,
|
||||
short_timeout,
|
||||
large_body_timeout,
|
||||
})
|
||||
@@ -115,6 +170,16 @@ impl BlockingHttpFetcher {
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
fn rrdp_client_builder(
|
||||
config: &HttpFetcherConfig,
|
||||
connect_timeout: Duration,
|
||||
timeout: Duration,
|
||||
user_agent: String,
|
||||
) -> Result<reqwest::blocking::ClientBuilder, String> {
|
||||
Ok(Self::client_builder(config, connect_timeout, timeout, user_agent)?
|
||||
.redirect(reqwest::redirect::Policy::none()))
|
||||
}
|
||||
|
||||
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
|
||||
@@ -238,6 +303,141 @@ impl BlockingHttpFetcher {
|
||||
(&self.short_client, "short", self.short_timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn rrdp_client_for_uri(&self, uri: &str) -> (&Client, &'static str, Duration) {
|
||||
if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) {
|
||||
return (&self.rrdp_retry_short_client, "retry_short", timeout);
|
||||
}
|
||||
if uses_large_body_timeout(uri) {
|
||||
(
|
||||
&self.rrdp_large_body_client,
|
||||
"large_body",
|
||||
self.large_body_timeout,
|
||||
)
|
||||
} else {
|
||||
(&self.rrdp_short_client, "short", self.short_timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn rrdp_response(
|
||||
&self,
|
||||
resource: RrdpResourceKind,
|
||||
uri: &str,
|
||||
notification_origin: &RrdpOrigin,
|
||||
) -> Result<(reqwest::blocking::Response, &'static str, Duration), RrdpFetchError> {
|
||||
let mut current = Url::parse(uri).map_err(|e| RrdpError::InvalidRrdpUri {
|
||||
resource,
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let current_origin = RrdpOrigin::from_url(¤t, resource)?;
|
||||
if ¤t_origin != notification_origin {
|
||||
return Err(RrdpError::CrossOriginReference {
|
||||
resource,
|
||||
notification_origin: notification_origin.to_string(),
|
||||
resource_origin: current_origin.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
for redirects in 0..=MAX_RRDP_REDIRECTS {
|
||||
let (client, profile, timeout) = self.rrdp_client_for_uri(current.as_str());
|
||||
let response = client
|
||||
.get(current.clone())
|
||||
.send()
|
||||
.map_err(|e| RrdpFetchError::Fetch(format!("http request failed: {e:?}")))?;
|
||||
if !response.status().is_redirection() {
|
||||
return Ok((response, profile, timeout));
|
||||
}
|
||||
if redirects == MAX_RRDP_REDIRECTS {
|
||||
return Err(RrdpError::RedirectLimitExceeded {
|
||||
resource,
|
||||
max: MAX_RRDP_REDIRECTS,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.ok_or(RrdpError::RedirectLocationMissing { resource })?
|
||||
.to_str()
|
||||
.map_err(|_| RrdpError::InvalidRedirectLocation {
|
||||
resource,
|
||||
location: "<non-UTF-8 header>".to_string(),
|
||||
})?;
|
||||
let next = current
|
||||
.join(location)
|
||||
.map_err(|_| RrdpError::InvalidRedirectLocation {
|
||||
resource,
|
||||
location: location.to_string(),
|
||||
})?;
|
||||
let next_origin = RrdpOrigin::from_url(&next, resource)?;
|
||||
crate::progress_log::emit(
|
||||
"rrdp_redirect_checked",
|
||||
serde_json::json!({
|
||||
"resource": resource.to_string(),
|
||||
"from": current.as_str(),
|
||||
"to": next.as_str(),
|
||||
"notification_origin": notification_origin.to_string(),
|
||||
"same_origin": &next_origin == notification_origin,
|
||||
"rfc": "RFC 9674 §3.2",
|
||||
}),
|
||||
);
|
||||
if &next_origin != notification_origin {
|
||||
crate::progress_log::emit(
|
||||
"rrdp_cross_origin_rejected",
|
||||
serde_json::json!({
|
||||
"resource": resource.to_string(),
|
||||
"notification_origin": notification_origin.to_string(),
|
||||
"resource_origin": next_origin.to_string(),
|
||||
"reason": "redirect",
|
||||
"rfc": "RFC 9674 §3.2",
|
||||
}),
|
||||
);
|
||||
return Err(RrdpError::CrossOriginRedirect {
|
||||
resource,
|
||||
notification_origin: notification_origin.to_string(),
|
||||
redirect_origin: next_origin.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
unreachable!("redirect loop either returns or reaches its limit")
|
||||
}
|
||||
|
||||
fn rrdp_fetch_bytes(
|
||||
&self,
|
||||
resource: RrdpResourceKind,
|
||||
uri: &str,
|
||||
notification_origin: &RrdpOrigin,
|
||||
) -> Result<Vec<u8>, RrdpFetchError> {
|
||||
let (response, _profile, _timeout) = self.rrdp_response(resource, uri, notification_origin)?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
|
||||
}
|
||||
response
|
||||
.bytes()
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| RrdpFetchError::Fetch(format!("http read body failed: {e}")))
|
||||
}
|
||||
|
||||
fn rrdp_fetch_to_writer(
|
||||
&self,
|
||||
resource: RrdpResourceKind,
|
||||
uri: &str,
|
||||
notification_origin: &RrdpOrigin,
|
||||
out: &mut dyn Write,
|
||||
) -> Result<u64, RrdpFetchError> {
|
||||
let (mut response, _profile, _timeout) = self.rrdp_response(resource, uri, notification_origin)?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(RrdpFetchError::Fetch(format!("http status {status}")));
|
||||
}
|
||||
response
|
||||
.copy_to(out)
|
||||
.map_err(|e| RrdpFetchError::Fetch(format!("http stream body failed: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for BlockingHttpFetcher {
|
||||
@@ -354,6 +554,25 @@ impl Fetcher for BlockingHttpFetcher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_rrdp(
|
||||
&self,
|
||||
resource: RrdpResourceKind,
|
||||
uri: &str,
|
||||
notification_origin: &RrdpOrigin,
|
||||
) -> Result<Vec<u8>, RrdpFetchError> {
|
||||
self.rrdp_fetch_bytes(resource, uri, notification_origin)
|
||||
}
|
||||
|
||||
fn fetch_rrdp_to_writer(
|
||||
&self,
|
||||
resource: RrdpResourceKind,
|
||||
uri: &str,
|
||||
notification_origin: &RrdpOrigin,
|
||||
out: &mut dyn Write,
|
||||
) -> Result<u64, RrdpFetchError> {
|
||||
self.rrdp_fetch_to_writer(resource, uri, notification_origin, out)
|
||||
}
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, name: &str) -> String {
|
||||
@@ -376,6 +595,8 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
@@ -532,4 +753,179 @@ 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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rrdp_same_origin_redirect_is_followed_manually() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
thread::spawn(move || {
|
||||
for response in [
|
||||
b"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".as_slice(),
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".as_slice(),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().expect("accept");
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf);
|
||||
stream.write_all(response).expect("response");
|
||||
}
|
||||
});
|
||||
let uri = format!("http://{addr}/start");
|
||||
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
|
||||
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||
timeout: Duration::from_secs(2),
|
||||
..HttpFetcherConfig::default()
|
||||
})
|
||||
.expect("http");
|
||||
let body = http
|
||||
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
|
||||
.expect("same-origin redirect accepted");
|
||||
assert_eq!(body, b"ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rrdp_cross_origin_redirect_is_rejected_before_target_request() {
|
||||
let target = TcpListener::bind(("127.0.0.1", 0)).expect("bind target");
|
||||
target.set_nonblocking(true).expect("nonblocking target");
|
||||
let target_addr = target.local_addr().expect("target addr");
|
||||
let target_hits = Arc::new(AtomicUsize::new(0));
|
||||
let target_hits_worker = Arc::clone(&target_hits);
|
||||
let target_thread = thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + StdDuration::from_millis(400);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match target.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
target_hits_worker.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(StdDuration::from_millis(5));
|
||||
}
|
||||
Err(e) => panic!("target accept: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let source = TcpListener::bind(("127.0.0.1", 0)).expect("bind source");
|
||||
let source_addr = source.local_addr().expect("source addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = source.accept().expect("accept source");
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf);
|
||||
let response = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/foreign\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(response.as_bytes()).expect("redirect");
|
||||
});
|
||||
let uri = format!("http://{source_addr}/start");
|
||||
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
|
||||
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
|
||||
timeout: Duration::from_secs(2),
|
||||
..HttpFetcherConfig::default()
|
||||
})
|
||||
.expect("http");
|
||||
let err = http
|
||||
.fetch_rrdp(RrdpResourceKind::Notification, &uri, &origin)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
RrdpFetchError::Rrdp(RrdpError::CrossOriginRedirect { .. })
|
||||
));
|
||||
target_thread.join().expect("target thread");
|
||||
assert_eq!(target_hits.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
412
src/storage.rs
412
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};
|
||||
|
||||
@@ -19,7 +20,7 @@ pub use config::{
|
||||
ALL_COLUMN_FAMILY_NAMES, CF_CHILD_CERTIFICATE_CACHE_PROJECTION, CF_MANIFEST_REPLAY_META,
|
||||
CF_PUBLICATION_POINT_CACHE_PROJECTION, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW,
|
||||
CF_ROA_CACHE_PROJECTION, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER,
|
||||
CF_TRANSPORT_PREFETCH, CF_VCIR, column_family_descriptors,
|
||||
CF_TRANSPORT_PREFETCH, CF_VCIR, CF_VCIR_FAILED_FETCH_REUSE_IDENTITY, column_family_descriptors,
|
||||
};
|
||||
use keys::*;
|
||||
use pack::compute_sha256_32;
|
||||
@@ -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>>,
|
||||
@@ -585,6 +593,62 @@ impl VcirInstanceGate {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VcirFailedFetchReuseIdentity {
|
||||
#[serde(rename = "c")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub current_ca_sha256: [u8; 32],
|
||||
#[serde(rename = "t")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub ta_context_digest: [u8; 32],
|
||||
#[serde(rename = "p")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "f")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub policy_fingerprint: [u8; 32],
|
||||
#[serde(rename = "nb")]
|
||||
pub effective_not_before: PackTime,
|
||||
#[serde(rename = "nu")]
|
||||
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(
|
||||
"vcir_failed_fetch_reuse_identity.effective_not_before",
|
||||
&self.effective_not_before,
|
||||
)?;
|
||||
let effective_until = parse_time(
|
||||
"vcir_failed_fetch_reuse_identity.effective_until",
|
||||
&self.effective_until,
|
||||
)?;
|
||||
if effective_not_before >= effective_until {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "vcir_failed_fetch_reuse_identity.effective_window",
|
||||
detail: "effective_not_before must be before effective_until".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn contains_validation_time(&self, validation_time: time::OffsetDateTime) -> bool {
|
||||
let Ok(effective_not_before) = self.effective_not_before.parse() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(effective_until) = self.effective_until.parse() else {
|
||||
return false;
|
||||
};
|
||||
validation_time >= effective_not_before && validation_time < effective_until
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VcirChildEntry {
|
||||
pub child_manifest_rsync_uri: String,
|
||||
@@ -1062,11 +1126,12 @@ pub struct RoaCacheObjectMeta {
|
||||
pub source_object_hash: [u8; 32],
|
||||
pub ee_serial: Vec<u8>,
|
||||
pub crl_uri: String,
|
||||
pub earliest_safe_reuse_time: PackTime,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
@@ -1127,6 +1192,8 @@ pub struct RoaCacheObjectProjection {
|
||||
pub ee_serial: Option<Vec<u8>>,
|
||||
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
|
||||
pub crl_uri: Option<String>,
|
||||
#[serde(rename = "n", default, skip_serializing_if = "Option::is_none")]
|
||||
pub earliest_safe_reuse_time_unix: Option<i64>,
|
||||
#[serde(rename = "x")]
|
||||
pub outputs_effective_until_unix: i64,
|
||||
#[serde(rename = "o")]
|
||||
@@ -1150,6 +1217,15 @@ impl RoaCacheObjectProjection {
|
||||
if let Some(crl_uri) = &self.crl_uri {
|
||||
validate_non_empty("roa_cache_projection.entries[].crl_uri", crl_uri)?;
|
||||
}
|
||||
if let Some(earliest_safe_reuse_time_unix) = self.earliest_safe_reuse_time_unix
|
||||
&& earliest_safe_reuse_time_unix >= self.outputs_effective_until_unix
|
||||
{
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[].effective_window",
|
||||
detail: "earliest_safe_reuse_time_unix must be before outputs_effective_until_unix"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
if self.outputs.is_empty() {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[]",
|
||||
@@ -1196,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]>,
|
||||
@@ -1338,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],
|
||||
@@ -1590,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 {
|
||||
@@ -1601,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1612,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1645,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],
|
||||
@@ -1681,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 {
|
||||
@@ -1694,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(),
|
||||
@@ -1749,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
|
||||
@@ -1867,6 +1948,9 @@ impl RoaCacheProjection {
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
context: Option<&RoaCacheProjectionContext>,
|
||||
) -> StorageResult<Option<Self>> {
|
||||
let Some(context) = context else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut issuer_ca_sha256_hex = None;
|
||||
let mut crl_sha256_by_uri = Vec::new();
|
||||
for artifact in &vcir.related_artifacts {
|
||||
@@ -1893,13 +1977,18 @@ impl RoaCacheProjection {
|
||||
}
|
||||
crl_sha256_by_uri.sort_by(|left, right| left.uri.cmp(&right.uri));
|
||||
|
||||
let meta_by_uri = context.map(|context| {
|
||||
context
|
||||
.object_meta
|
||||
.iter()
|
||||
.map(|meta| (meta.source_object_uri.as_str(), meta))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
let meta_by_uri = context
|
||||
.object_meta
|
||||
.iter()
|
||||
.map(|meta| (meta.source_object_uri.as_str(), meta))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let vcir_validation_time =
|
||||
vcir.last_successful_validation_time
|
||||
.parse()
|
||||
.map_err(|detail| StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix",
|
||||
detail,
|
||||
})?;
|
||||
let mut entries: Vec<RoaCacheObjectProjection> = Vec::new();
|
||||
let mut entry_index_by_uri: HashMap<String, usize> = HashMap::new();
|
||||
for output in &vcir.local_outputs {
|
||||
@@ -1915,23 +2004,26 @@ impl RoaCacheProjection {
|
||||
entity: "roa_cache_projection.entries[].outputs_effective_until_unix",
|
||||
detail,
|
||||
})?;
|
||||
let meta = meta_by_uri
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(output.source_object_uri.as_str()).copied());
|
||||
if context.is_some() && meta.is_none() {
|
||||
let Some(meta) = meta_by_uri.get(output.source_object_uri.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
if meta.source_object_hash != output.source_object_hash {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[]",
|
||||
detail: format!(
|
||||
"metadata source object hash mismatch for {}",
|
||||
output.source_object_uri
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(meta) = meta {
|
||||
if meta.source_object_hash != output.source_object_hash {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[]",
|
||||
detail: format!(
|
||||
"metadata source object hash mismatch for {}",
|
||||
output.source_object_uri
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
let earliest_safe_reuse_time_unix = meta
|
||||
.earliest_safe_reuse_time
|
||||
.parse()
|
||||
.map(|time| time.max(vcir_validation_time).unix_timestamp())
|
||||
.map_err(|detail| StorageError::InvalidData {
|
||||
entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix",
|
||||
detail,
|
||||
})?;
|
||||
if let Some(entry_index) = entry_index_by_uri.get(output.source_object_uri.as_str()) {
|
||||
let entry = &mut entries[*entry_index];
|
||||
if entry.source_object_hash != output.source_object_hash {
|
||||
@@ -1952,8 +2044,9 @@ impl RoaCacheProjection {
|
||||
entries.push(RoaCacheObjectProjection {
|
||||
source_object_uri: output.source_object_uri.clone(),
|
||||
source_object_hash: output.source_object_hash,
|
||||
ee_serial: meta.map(|meta| meta.ee_serial.clone()),
|
||||
crl_uri: meta.map(|meta| meta.crl_uri.clone()),
|
||||
ee_serial: Some(meta.ee_serial.clone()),
|
||||
crl_uri: Some(meta.crl_uri.clone()),
|
||||
earliest_safe_reuse_time_unix: Some(earliest_safe_reuse_time_unix),
|
||||
outputs_effective_until_unix: projected_output_effective_until,
|
||||
outputs: vec![projected_output],
|
||||
});
|
||||
@@ -1968,8 +2061,8 @@ 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: context.map(|context| context.parent_context_digest),
|
||||
policy_fingerprint: context.map(|context| context.policy_fingerprint),
|
||||
ca_validation_context_digest: Some(context.ca_validation_context_digest),
|
||||
policy_fingerprint: Some(context.policy_fingerprint),
|
||||
crl_sha256_by_uri,
|
||||
entries,
|
||||
};
|
||||
@@ -1989,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(),
|
||||
});
|
||||
}
|
||||
@@ -2075,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 {
|
||||
@@ -2774,6 +2872,24 @@ fn write_roa_cache_projection_to_batch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_vcir_failed_fetch_reuse_identity_to_batch(
|
||||
failed_fetch_reuse_identity_cf: &ColumnFamily,
|
||||
batch: &mut WriteBatch,
|
||||
manifest_rsync_uri: &str,
|
||||
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
|
||||
) -> StorageResult<()> {
|
||||
let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
|
||||
match failed_fetch_reuse_identity {
|
||||
Some(identity) => {
|
||||
identity.validate_internal()?;
|
||||
let value = encode_cbor(identity, "vcir_failed_fetch_reuse_identity")?;
|
||||
batch.put_cf(failed_fetch_reuse_identity_cf, key.as_bytes(), value);
|
||||
}
|
||||
None => batch.delete_cf(failed_fetch_reuse_identity_cf, key.as_bytes()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_publication_point_cache_projection_to_batch(
|
||||
projection_cf: &ColumnFamily,
|
||||
batch: &mut WriteBatch,
|
||||
@@ -2801,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);
|
||||
@@ -2839,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>,
|
||||
@@ -3055,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() {
|
||||
@@ -3114,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() {
|
||||
@@ -3267,6 +3474,19 @@ impl RocksStore {
|
||||
self.put_vcir_with_publication_point_cache_projection(vcir, None)
|
||||
}
|
||||
|
||||
pub fn put_vcir_with_failed_fetch_reuse_identity(
|
||||
&self,
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
failed_fetch_reuse_identity: &VcirFailedFetchReuseIdentity,
|
||||
) -> StorageResult<()> {
|
||||
self.put_vcir_with_projection_action(
|
||||
vcir,
|
||||
None,
|
||||
PublicationPointCacheProjectionWriteAction::Keep,
|
||||
Some(failed_fetch_reuse_identity),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn put_vcir_with_publication_point_cache_projection(
|
||||
&self,
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
@@ -3288,6 +3508,7 @@ impl RocksStore {
|
||||
vcir,
|
||||
roa_cache_context,
|
||||
publication_point_projection_action,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3296,9 +3517,11 @@ impl RocksStore {
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
roa_cache_context: Option<&RoaCacheProjectionContext>,
|
||||
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
|
||||
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
|
||||
) -> StorageResult<()> {
|
||||
vcir.validate_internal()?;
|
||||
let vcir_cf = self.cf(CF_VCIR)?;
|
||||
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
|
||||
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
|
||||
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
|
||||
let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?;
|
||||
@@ -3308,6 +3531,12 @@ impl RocksStore {
|
||||
let key = vcir_key(&vcir.manifest_rsync_uri);
|
||||
let value = encode_cbor(vcir, "vcir")?;
|
||||
batch.put_cf(vcir_cf, key.as_bytes(), value);
|
||||
write_vcir_failed_fetch_reuse_identity_to_batch(
|
||||
failed_fetch_reuse_identity_cf,
|
||||
&mut batch,
|
||||
&vcir.manifest_rsync_uri,
|
||||
failed_fetch_reuse_identity,
|
||||
)?;
|
||||
let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri);
|
||||
let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?;
|
||||
batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value);
|
||||
@@ -3371,6 +3600,21 @@ impl RocksStore {
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
roa_cache_context: Option<&RoaCacheProjectionContext>,
|
||||
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
|
||||
) -> StorageResult<VcirReplaceTimingBreakdown> {
|
||||
self.replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
|
||||
vcir,
|
||||
roa_cache_context,
|
||||
publication_point_projection_action,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
|
||||
&self,
|
||||
vcir: &ValidatedCaInstanceResult,
|
||||
roa_cache_context: Option<&RoaCacheProjectionContext>,
|
||||
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
|
||||
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
|
||||
) -> StorageResult<VcirReplaceTimingBreakdown> {
|
||||
let mut timing = VcirReplaceTimingBreakdown {
|
||||
rss_before_kb: process_vm_rss_kb(),
|
||||
@@ -3385,6 +3629,7 @@ impl RocksStore {
|
||||
|
||||
let batch_build_started = std::time::Instant::now();
|
||||
let vcir_cf = self.cf(CF_VCIR)?;
|
||||
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
|
||||
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
|
||||
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
|
||||
let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?;
|
||||
@@ -3397,6 +3642,12 @@ impl RocksStore {
|
||||
timing.vcir_value_bytes = vcir_value.len() as u64;
|
||||
batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value);
|
||||
timing.rss_after_vcir_encode_kb = process_vm_rss_kb();
|
||||
write_vcir_failed_fetch_reuse_identity_to_batch(
|
||||
failed_fetch_reuse_identity_cf,
|
||||
&mut batch,
|
||||
&vcir.manifest_rsync_uri,
|
||||
failed_fetch_reuse_identity,
|
||||
)?;
|
||||
|
||||
let replay_meta_encode_started = std::time::Instant::now();
|
||||
let replay_meta = ManifestReplayMeta::from_vcir(vcir);
|
||||
@@ -3465,6 +3716,27 @@ impl RocksStore {
|
||||
Ok(Some(vcir))
|
||||
}
|
||||
|
||||
pub fn get_vcir_failed_fetch_reuse_identity(
|
||||
&self,
|
||||
manifest_rsync_uri: &str,
|
||||
) -> StorageResult<Option<VcirFailedFetchReuseIdentity>> {
|
||||
let cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
|
||||
let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
|
||||
let Some(bytes) = self
|
||||
.db
|
||||
.get_cf(cf, key.as_bytes())
|
||||
.map_err(|e| StorageError::RocksDb(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let identity = decode_cbor::<VcirFailedFetchReuseIdentity>(
|
||||
&bytes,
|
||||
"vcir_failed_fetch_reuse_identity",
|
||||
)?;
|
||||
identity.validate_internal()?;
|
||||
Ok(Some(identity))
|
||||
}
|
||||
|
||||
pub fn get_manifest_replay_meta(
|
||||
&self,
|
||||
manifest_rsync_uri: &str,
|
||||
@@ -4267,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;
|
||||
@@ -4308,11 +4610,18 @@ impl RocksStore {
|
||||
|
||||
pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> {
|
||||
let vcir_cf = self.cf(CF_VCIR)?;
|
||||
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
|
||||
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
|
||||
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
|
||||
let mut batch = WriteBatch::default();
|
||||
let key = vcir_key(manifest_rsync_uri);
|
||||
batch.delete_cf(vcir_cf, key.as_bytes());
|
||||
let failed_fetch_reuse_identity_key =
|
||||
vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
|
||||
batch.delete_cf(
|
||||
failed_fetch_reuse_identity_cf,
|
||||
failed_fetch_reuse_identity_key.as_bytes(),
|
||||
);
|
||||
let replay_key = manifest_replay_meta_key(manifest_rsync_uri);
|
||||
batch.delete_cf(replay_cf, replay_key.as_bytes());
|
||||
let projection_key = roa_cache_projection_key(manifest_rsync_uri);
|
||||
@@ -4518,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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user