20260526 增加持续soak监控与本地回放工具

This commit is contained in:
yuyr 2026-05-26 18:02:40 +08:00
parent cda7fdb135
commit 7e1c24fcc3
30 changed files with 4541 additions and 12 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
target/
Cargo.lock
perf.*
specs/* copy.excalidraw

131
monitor/README.md Normal file
View File

@ -0,0 +1,131 @@
# Ours RP Prometheus / Grafana Monitor
本目录提供本地开发监控栈,用于采集 `rpki_artifact_metrics` 暴露的 ours RP soak 指标。
## 前置条件
1. Docker + Docker Compose v2
2. 宿主机已启动 `rpki_artifact_metrics`,并监听 Docker 网桥可访问的地址,例如 `0.0.0.0:9556`
3. Prometheus 容器通过 `host.docker.internal:9556` 访问宿主 sidecar。
Linux Docker 下 compose 已配置:
```yaml
extra_hosts:
- host.docker.internal:host-gateway
```
## 启动
```bash
cd rpki_2/rpki/monitor
docker compose up -d
```
默认镜像使用官方 Docker Hub 镜像:
```text
prom/prometheus:v2.55.1
grafana/grafana:11.3.1
```
如需切到其它镜像源:
```bash
PROMETHEUS_IMAGE=<mirror>/prom/prometheus:v2.55.1 \
GRAFANA_IMAGE=<mirror>/grafana/grafana:11.3.1 \
docker compose up -d
```
默认端口:
- Prometheus: <http://localhost:9090>
- Grafana: <http://localhost:3000>
- Grafana 默认账号密码:`admin` / `admin`
如端口冲突:
```bash
PROMETHEUS_PORT=19090 GRAFANA_PORT=13000 docker compose up -d
```
## 停止
```bash
cd rpki_2/rpki/monitor
docker compose down
```
保留数据 volume。若要清理数据
```bash
docker compose down -v
```
## 典型本地联调命令
先启动 APNIC soak 和 metrics sidecar例如
```bash
# soak .env 关键配置
MAX_RUNS=-1
RIRS=apnic
RETAIN_RUNS=5
INTERVAL_SECS=0
# metrics sidecar
rpki_artifact_metrics \
--run-root /path/to/portable-soak \
--listen 0.0.0.0:9556 \
--poll-secs 5 \
--instance local-apnic-continuous
```
再启动监控栈:
```bash
cd rpki_2/rpki/monitor
docker compose up -d
```
## 验证
Prometheus target
```bash
curl -s 'http://localhost:9090/api/v1/targets' | python3 -m json.tool
```
Prometheus query
```bash
curl -G 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=up{job="ours-rp-artifact-metrics"}'
curl -G 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=ours_rp_run_completed_total{status="success"}'
```
Grafana health
```bash
curl -s http://localhost:3000/api/health | python3 -m json.tool
```
Grafana dashboard
- 打开 <http://localhost:3000/d/ours-rp-soak-overview/ours-rp-soak-overview>
## 主要指标
- `ours_rp_metrics_service_up`
- `ours_rp_run_completed_total`
- `ours_rp_run_duration_seconds`
- `ours_rp_run_max_rss_bytes`
- `ours_rp_vrps`
- `ours_rp_vaps`
- `ours_rp_publication_points`
- `ours_rp_repo_sync_phase_count`
- `ours_rp_large_publication_points{object_count_gt="10|50|100|..."}`
- `ours_rp_cir_objects`
- `ours_rp_ccr_state_items`

View File

@ -0,0 +1,38 @@
services:
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}
container_name: ours-rp-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-7d}
- --web.enable-lifecycle
extra_hosts:
- host.docker.internal:host-gateway
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
restart: unless-stopped
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:11.3.1}
container_name: ours-rp-grafana
depends_on:
- prometheus
ports:
- "${GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
restart: unless-stopped
volumes:
prometheus-data:
grafana-data:

View File

@ -0,0 +1,632 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"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": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"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": "ours_rp_repo_sync_phase_count{phase=\"rrdp_ok\"}",
"legendFormat": "rrdp ok",
"refId": "A"
}
],
"title": "RRDP OK Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"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": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "fallback",
"refId": "A"
}
],
"title": "Rsync Fallback Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"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": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "A"
}
],
"title": "Failed No Cache Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"repo_sync_total\"}",
"legendFormat": "repo sync total",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rrdp_download_total\"}",
"legendFormat": "rrdp download",
"refId": "B"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rsync_download_total\"}",
"legendFormat": "rsync download",
"refId": "C"
}
],
"title": "Repo Sync Download Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count",
"legendFormat": "{{phase}}",
"refId": "A"
}
],
"title": "Repo Sync Phase Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 8
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rrdp failed, rsync ok",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "rrdp failed, rsync failed",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "C"
},
{
"expr": "ours_rp_tree_instances{state=\"failed\"}",
"legendFormat": "tree failed",
"refId": "D"
}
],
"title": "Repo Failure / Fallback Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 20,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rsync fallback duration",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "failed duration",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_duration_seconds_total{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache duration",
"refId": "C"
}
],
"title": "Repo Failure / Fallback Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 29,
"w": 12,
"h": 9
},
"id": 9,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_rrdp_rsync_failed_repository_duration_seconds",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "RRDP + Rsync Failed Repositories",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"rank": true,
"transport": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"phase": 2,
"uri": 3,
"Value": 4
},
"renameByName": {
"Value": "duration"
}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 38,
"w": 24,
"h": 9
},
"id": 10,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_publication_point_object_count)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top Publication Points by Objects",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"terminal_state": 3,
"transport": 4,
"uri": 5,
"Value": 6
},
"renameByName": {}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 29,
"w": 12,
"h": 9
},
"id": 11,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_repository_sync_duration_seconds)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top 20 Repositories by Sync Duration",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"transport": 3,
"uri": 4,
"Value": 5
},
"renameByName": {
"Value": "value"
}
}
}
]
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak",
"repo-sync"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Repo Sync",
"uid": "ours-rp-repo-sync",
"version": 3,
"weekStart": ""
}

View File

@ -0,0 +1,582 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"id": 1,
"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": "ours_rp_cir_trust_anchors",
"legendFormat": "RIRs",
"refId": "A"
}
],
"title": "Current Run RIRs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"id": 2,
"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": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
}
],
"title": "Latest Wall Time",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"id": 3,
"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": "ours_rp_run_max_rss_bytes",
"legendFormat": "rss",
"refId": "A"
}
],
"title": "Latest Max RSS",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"id": 4,
"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": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
},
{
"expr": "ours_rp_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "B"
}
],
"title": "Run / Validation Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_vrps",
"legendFormat": "VRPs",
"refId": "A"
},
{
"expr": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "B"
},
{
"expr": "ours_rp_cir_objects",
"legendFormat": "CIR objects",
"refId": "C"
}
],
"title": "Output and Input Sizes",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_large_publication_points",
"legendFormat": "> {{object_count_gt}} objects",
"refId": "A"
}
],
"title": "Large Publication Points by Object Count",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 4,
"w": 6,
"h": 4
},
"id": 9,
"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": "ours_rp_run_sequence",
"legendFormat": "seq",
"refId": "A"
}
],
"title": "Latest Run Sequence",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 4,
"w": 6,
"h": 4
},
"id": 10,
"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": "ours_rp_run_success",
"legendFormat": "success",
"refId": "A"
}
],
"title": "Latest Run Success",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 4,
"w": 6,
"h": 4
},
"id": 11,
"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": "ours_rp_vrps",
"legendFormat": "VRPs",
"refId": "A"
}
],
"title": "VRPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 4,
"w": 6,
"h": 4
},
"id": 12,
"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": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "A"
}
],
"title": "VAPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"id": 13,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"report_write\"}",
"legendFormat": "report write",
"refId": "E"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"ccr_write\"}",
"legendFormat": "ccr write",
"refId": "F"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"cir_write\"}",
"legendFormat": "cir write",
"refId": "G"
}
],
"title": "Output Stage Durations",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Soak Overview",
"uid": "ours-rp-soak-overview",
"version": 4,
"weekStart": ""
}

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: ours-rp
orgId: 1
folder: Ours RP
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true

View File

@ -0,0 +1,13 @@
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: ours-rp-artifact-metrics
metrics_path: /metrics
static_configs:
- targets:
- host.docker.internal:9556
labels:
rp: ours-rp
source: artifact-sidecar

View File

@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
build_local_repo_replay_package.sh --out <path> [--tar-gz]
Build a standalone local repository tree replay package for Routinator and
rpki-client. The package does not include repository data and does not include
materialize tooling.
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SRC_DIR="$ROOT_DIR/scripts/local_repo_replay"
OUT=""
TAR_GZ=0
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
--tar-gz) TAR_GZ=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$OUT" ]] || { usage >&2; exit 2; }
if [[ "$TAR_GZ" -eq 1 ]]; then
PACKAGE_DIR="$(mktemp -d)"
TARGET_DIR="$PACKAGE_DIR/local-repo-replay-package"
else
TARGET_DIR="$OUT"
rm -rf "$TARGET_DIR"
fi
mkdir -p "$TARGET_DIR/scripts" "$TARGET_DIR/docs" "$TARGET_DIR/examples"
install -m 0755 "$SRC_DIR/run_routinator_from_local_tree.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/run_rpki_client_from_local_tree.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/run_dual_local_tree_replay.sh" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/prepare_tals.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/normalize_rp_outputs.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/compare_normalized_sets.py" "$TARGET_DIR/scripts/"
install -m 0755 "$SRC_DIR/summarize_replay.py" "$TARGET_DIR/scripts/"
install -m 0755 "$ROOT_DIR/scripts/cir/cir-rsync-wrapper" "$TARGET_DIR/scripts/"
install -m 0755 "$ROOT_DIR/scripts/cir/cir-local-link-sync.py" "$TARGET_DIR/scripts/"
install -m 0644 "$SRC_DIR/templates/README.md" "$TARGET_DIR/README.md"
install -m 0644 "$SRC_DIR/templates/docs/input_tree_requirements.md" "$TARGET_DIR/docs/"
install -m 0644 "$SRC_DIR/templates/docs/offline_replay_limits.md" "$TARGET_DIR/docs/"
install -m 0644 "$SRC_DIR/templates/docs/output_files.md" "$TARGET_DIR/docs/"
install -m 0755 "$SRC_DIR/templates/examples/routinator_example.sh" "$TARGET_DIR/examples/"
install -m 0755 "$SRC_DIR/templates/examples/rpki_client_example.sh" "$TARGET_DIR/examples/"
install -m 0755 "$SRC_DIR/templates/examples/dual_compare_example.sh" "$TARGET_DIR/examples/"
cat > "$TARGET_DIR/env.example" <<'EOF'
# 本地目录树 replay 示例配置。目录树由使用者提前准备,不包含在 package 中。
TAL_DIR=/data/replay/tals
MIRROR_ROOT=/data/replay/mirror
ROUTINATOR_BIN=/opt/routinator/target/release/routinator
RPKI_CLIENT_BIN=/opt/rpki-client/src/rpki-client
RPKI_CLIENT_CACHE_DIR=/data/replay/work/rpki-client-cache
VALIDATION_TIME=2026-05-23T00:00:00Z
EOF
if grep -R -E 'cir_materialize|repo-bytes\\.db|\\.cir' "$TARGET_DIR/scripts" >/dev/null; then
echo "package contains forbidden materialize/repo-bytes implementation references" >&2
exit 1
fi
if [[ "$TAR_GZ" -eq 1 ]]; then
mkdir -p "$(dirname "$OUT")"
tar -C "$PACKAGE_DIR" -czf "$OUT" local-repo-replay-package
rm -rf "$PACKAGE_DIR"
echo "$OUT"
else
echo "$TARGET_DIR"
fi

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def read_set(path: Path) -> set[str]:
if not path.is_file():
return set()
return {line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()}
def compare(left: set[str], right: set[str]) -> dict[str, object]:
union = left | right
inter = left & right
return {
"left": len(left),
"right": len(right),
"intersection": len(inter),
"onlyLeft": len(left - right),
"onlyRight": len(right - left),
"jaccard": round(len(inter) / len(union), 8) if union else 1.0,
"onlyLeftSamples": sorted(left - right)[:20],
"onlyRightSamples": sorted(right - left)[:20],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--left", type=Path, required=True)
parser.add_argument("--right", type=Path, required=True)
parser.add_argument("--left-name", default="left")
parser.add_argument("--right-name", default="right")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
result = {
"leftName": args.left_name,
"rightName": args.right_name,
"vrps": compare(read_set(args.left / "vrps.normalized.txt"), read_set(args.right / "vrps.normalized.txt")),
"vaps": compare(read_set(args.left / "vaps.normalized.txt"), read_set(args.right / "vaps.normalized.txt")),
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(args.out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Any
def normalize_asn(value: Any) -> str:
text = str(value).strip().upper()
if text.startswith("AS"):
text = text[2:]
return f"AS{int(text)}"
def write_set(path: Path, rows: set[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(sorted(rows)) + ("\n" if rows else ""), encoding="utf-8")
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def normalize_vrps_from_json(path: Path) -> set[str]:
data = load_json(path)
rows: set[str] = set()
objects: list[dict[str, Any]] = []
if isinstance(data, dict):
for key in ("roas", "routeOrigins", "valid_roas"):
value = data.get(key)
if isinstance(value, list):
objects.extend(item for item in value if isinstance(item, dict))
elif isinstance(data, list):
objects = [item for item in data if isinstance(item, dict)]
for item in objects:
asn = item.get("asn") or item.get("asID") or item.get("origin")
prefix = item.get("prefix")
max_length = item.get("maxLength") or item.get("max_length") or item.get("maxlen") or item.get("maxLengthPrefix")
if asn is None or prefix is None or max_length is None:
continue
rows.add(f"{normalize_asn(asn)}|{prefix}|{int(max_length)}")
return rows
def normalize_vaps_from_json(path: Path) -> set[str]:
data = load_json(path)
rows: set[str] = set()
objects: list[dict[str, Any]] = []
if isinstance(data, dict):
for key in ("aspas", "aspaAssertions", "vaps"):
value = data.get(key)
if isinstance(value, list):
objects.extend(item for item in value if isinstance(item, dict))
elif isinstance(data, list):
objects = [item for item in data if isinstance(item, dict)]
for item in objects:
customer = (
item.get("customer")
or item.get("customer_asid")
or item.get("customerASID")
or item.get("customerAsid")
or item.get("customerAsn")
)
providers = item.get("providers") or item.get("provider_asns") or item.get("providerASNs") or []
if customer is None:
continue
provider_asns = [normalize_asn(provider) for provider in providers]
rows.add(f"{normalize_asn(customer)}|{','.join(sorted(set(provider_asns), key=lambda value: int(value[2:])))}")
return rows
def normalize_vrps_from_csv(path: Path) -> set[str]:
rows: set[str] = set()
with path.open(newline="", encoding="utf-8", errors="replace") as handle:
for row in csv.DictReader(handle):
asn = row.get("ASN") or row.get("asn") or row.get("AS")
prefix = row.get("IP Prefix") or row.get("prefix") or row.get("Prefix")
max_length = row.get("Max Length") or row.get("maxLength") or row.get("max_length")
if asn and prefix and max_length:
rows.add(f"{normalize_asn(asn)}|{prefix}|{int(max_length)}")
return rows
def normalize_routinator(input_path: Path) -> tuple[set[str], set[str]]:
return normalize_vrps_from_json(input_path), normalize_vaps_from_json(input_path)
def normalize_rpki_client(input_path: Path) -> tuple[set[str], set[str]]:
json_path = input_path / "json" if input_path.is_dir() else input_path
csv_path = input_path / "csv" if input_path.is_dir() else input_path
vrps: set[str] = set()
vaps: set[str] = set()
if json_path.is_file():
vrps |= normalize_vrps_from_json(json_path)
vaps |= normalize_vaps_from_json(json_path)
if csv_path.is_file():
vrps |= normalize_vrps_from_csv(csv_path)
return vrps, vaps
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--kind", choices=["routinator", "rpki-client"], required=True)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--vrps-out", type=Path, required=True)
parser.add_argument("--vaps-out", type=Path, required=True)
args = parser.parse_args()
if args.kind == "routinator":
vrps, vaps = normalize_routinator(args.input)
else:
vrps, vaps = normalize_rpki_client(args.input)
write_set(args.vrps_out, vrps)
write_set(args.vaps_out, vaps)
print(json.dumps({"vrps": len(vrps), "vaps": len(vaps)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def split_tal(text: str) -> tuple[list[str], list[str]]:
lines = text.splitlines()
for index, line in enumerate(lines):
if line.strip() == "":
return lines[:index], lines[index + 1 :]
return lines, []
def rsync_only_tal(source: Path) -> tuple[str, dict[str, object]]:
text = source.read_text(encoding="utf-8", errors="replace")
uri_lines, key_lines = split_tal(text)
uris = [
line.strip()
for line in uri_lines
if line.strip() and not line.lstrip().startswith("#")
]
rsync_uris = [
uri for uri in uris
if uri.lower().startswith("rsync://")
]
if not rsync_uris:
return text if text.endswith("\n") else text + "\n", {
"file": source.name,
"mode": "unchanged_no_rsync_uri",
"uris": len(uris),
"rsyncUris": 0,
}
out = "\n".join(rsync_uris) + "\n\n" + "\n".join(key_lines).strip() + "\n"
return out, {
"file": source.name,
"mode": "rsync_only",
"uris": len(uris),
"rsyncUris": len(rsync_uris),
}
def collect_tals(tal_dir: Path | None, tals: list[Path]) -> list[Path]:
paths: list[Path] = []
if tal_dir is not None:
paths.extend(sorted(tal_dir.glob("*.tal")))
paths.extend(tals)
unique: dict[str, Path] = {}
for path in paths:
unique[path.name] = path
return [unique[name] for name in sorted(unique)]
def main() -> int:
parser = argparse.ArgumentParser(description="Prepare TALs for local rsync-tree replay.")
parser.add_argument("--tal-dir", type=Path)
parser.add_argument("--tal", type=Path, action="append", default=[])
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--summary", type=Path)
args = parser.parse_args()
sources = collect_tals(args.tal_dir, args.tal)
if not sources:
raise SystemExit("no TAL files provided")
args.out_dir.mkdir(parents=True, exist_ok=True)
rows = []
for source in sources:
if not source.is_file():
raise SystemExit(f"TAL file not found: {source}")
content, row = rsync_only_tal(source)
(args.out_dir / source.name).write_text(content, encoding="utf-8")
rows.append(row)
if args.summary is not None:
args.summary.parent.mkdir(parents=True, exist_ok=True)
args.summary.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_dual_local_tree_replay.sh \
--routinator-bin <path> --routinator-mirror-root <dir> \
--rpki-client-bin <path> --rpki-client-mirror-root <dir> \
--tal-dir <dir> --out-dir <dir> [--validation-time <RFC3339>]
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROUTINATOR_BIN=""
ROUTINATOR_MIRROR_ROOT=""
RPKI_CLIENT_BIN=""
RPKI_CLIENT_MIRROR_ROOT=""
RPKI_CLIENT_CACHE_DIR=""
TAL_DIR=""
OUT_DIR=""
VALIDATION_TIME=""
while [[ $# -gt 0 ]]; do
case "$1" in
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--routinator-mirror-root) ROUTINATOR_MIRROR_ROOT="$2"; shift 2 ;;
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--rpki-client-mirror-root) RPKI_CLIENT_MIRROR_ROOT="$2"; shift 2 ;;
--rpki-client-cache-dir) RPKI_CLIENT_CACHE_DIR="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$RPKI_CLIENT_MIRROR_ROOT" ]]; then
RPKI_CLIENT_MIRROR_ROOT="$ROUTINATOR_MIRROR_ROOT"
fi
[[ -n "$ROUTINATOR_BIN" && -n "$ROUTINATOR_MIRROR_ROOT" && -n "$RPKI_CLIENT_BIN" && -n "$RPKI_CLIENT_MIRROR_ROOT" && -n "$TAL_DIR" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
mkdir -p "$OUT_DIR"
TIME_ARGS=()
if [[ -n "$VALIDATION_TIME" ]]; then
TIME_ARGS=(--validation-time "$VALIDATION_TIME")
fi
CACHE_ARGS=()
if [[ -n "$RPKI_CLIENT_CACHE_DIR" ]]; then
CACHE_ARGS=(--cache-dir "$RPKI_CLIENT_CACHE_DIR")
fi
"$SCRIPT_DIR/run_routinator_from_local_tree.sh" \
--routinator-bin "$ROUTINATOR_BIN" \
--mirror-root "$ROUTINATOR_MIRROR_ROOT" \
--tal-dir "$TAL_DIR" \
--out-dir "$OUT_DIR/routinator" \
--enable-aspa \
"${TIME_ARGS[@]}"
"$SCRIPT_DIR/run_rpki_client_from_local_tree.sh" \
--rpki-client-bin "$RPKI_CLIENT_BIN" \
--mirror-root "$RPKI_CLIENT_MIRROR_ROOT" \
--tal-dir "$TAL_DIR" \
--out-dir "$OUT_DIR/rpki-client" \
"${CACHE_ARGS[@]}" \
"${TIME_ARGS[@]}"
python3 "$SCRIPT_DIR/compare_normalized_sets.py" \
--left "$OUT_DIR/routinator" \
--right "$OUT_DIR/rpki-client" \
--left-name routinator \
--right-name rpki-client \
--out "$OUT_DIR/compare-summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_routinator_from_local_tree.sh \
--routinator-bin <path> \
--mirror-root <local-rsync-mirror-root> \
--tal-dir <dir> | --tal <file>... \
--out-dir <path> \
[--validation-time <RFC3339>] \
[--real-rsync-bin <path>] \
[--enable-aspa]
The input mirror is prepared by the caller. This script does not generate it.
Pass --validation-time only if FAKETIME_LIB points to a working libfaketime
library; otherwise Routinator will validate at wall-clock time.
Example:
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROUTINATOR_BIN=""
MIRROR_ROOT=""
OUT_DIR=""
VALIDATION_TIME=""
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
ENABLE_ASPA=0
TAL_DIR=""
TALS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--routinator-bin) ROUTINATOR_BIN="$2"; shift 2 ;;
--mirror-root) MIRROR_ROOT="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--tal) TALS+=("$2"); shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
--enable-aspa) ENABLE_ASPA=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$ROUTINATOR_BIN" && -n "$MIRROR_ROOT" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
[[ -x "$ROUTINATOR_BIN" ]] || { echo "routinator binary not executable: $ROUTINATOR_BIN" >&2; exit 2; }
[[ -d "$MIRROR_ROOT" ]] || { echo "mirror root not found: $MIRROR_ROOT" >&2; exit 2; }
if [[ -z "$TAL_DIR" && "${#TALS[@]}" -eq 0 ]]; then
echo "provide --tal-dir or at least one --tal" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
WORK_DIR="$OUT_DIR/work"
REPO_DIR="$WORK_DIR/repository"
EXTRA_TALS="$WORK_DIR/tals"
CONFIG_FILE="$WORK_DIR/routinator.conf"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$EXTRA_TALS"
PREPARE_TAL_ARGS=(--out-dir "$EXTRA_TALS" --summary "$OUT_DIR/prepared-tals.json")
if [[ -n "$TAL_DIR" ]]; then
PREPARE_TAL_ARGS+=(--tal-dir "$TAL_DIR")
fi
for tal in "${TALS[@]}"; do
PREPARE_TAL_ARGS+=(--tal "$tal")
done
python3 "$SCRIPT_DIR/prepare_tals.py" "${PREPARE_TAL_ARGS[@]}"
cat > "$CONFIG_FILE" <<EOF
repository-dir = "$REPO_DIR"
no-rir-tals = true
extra-tals-dir = "$EXTRA_TALS"
disable-rrdp = true
rrdp-fallback = "never"
rsync-command = "$SCRIPT_DIR/cir-rsync-wrapper"
EOF
export CIR_MIRROR_ROOT="$(cd "$MIRROR_ROOT" && pwd)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
export LOCAL_REPO_REPLAY_VALIDATION_TIME="$VALIDATION_TIME"
export LOCAL_REPO_REPLAY_OUT_DIR="$OUT_DIR"
ARGS=(
"$ROUTINATOR_BIN"
--config "$CONFIG_FILE"
--repository-dir "$REPO_DIR"
--disable-rrdp
--rrdp-fallback never
--rsync-command "$SCRIPT_DIR/cir-rsync-wrapper"
--no-rir-tals
--extra-tals-dir "$EXTRA_TALS"
)
if [[ "$ENABLE_ASPA" -eq 1 ]]; then
echo 'enable-aspa = true' >> "$CONFIG_FILE"
ARGS+=(--enable-aspa)
fi
if [[ -n "$VALIDATION_TIME" && -z "${FAKETIME_LIB:-}" ]]; then
echo "warning: --validation-time is ignored for Routinator because FAKETIME_LIB is not set" >&2
fi
VRP_ARGS=(vrps -o "$OUT_DIR/vrps.csv")
JSON_ARGS=(vrps -f jsonext -o "$OUT_DIR/routinator.json")
/usr/bin/time -v -o "$OUT_DIR/process-time.txt" bash -c '
set -euo pipefail
if [[ -n "$LOCAL_REPO_REPLAY_VALIDATION_TIME" && -n "${FAKETIME_LIB:-}" ]]; then
faketime_value="$(python3 - <<'"'"'PY'"'"' "$LOCAL_REPO_REPLAY_VALIDATION_TIME"
from datetime import datetime, timezone
import sys
dt = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc)
print("@" + dt.strftime("%Y-%m-%d %H:%M:%S"))
PY
)"
export LD_PRELOAD="$FAKETIME_LIB"
export TZ=UTC
unset FAKETIME_FMT
export FAKETIME="$faketime_value"
export FAKETIME_DONT_FAKE_MONOTONIC=1
fi
"$@" update --complete >"$LOCAL_REPO_REPLAY_OUT_DIR/update.log" 2>&1 || true
"$@" vrps -o "$LOCAL_REPO_REPLAY_OUT_DIR/vrps.csv" >"$LOCAL_REPO_REPLAY_OUT_DIR/vrps.log" 2>&1
"$@" vrps -f jsonext -o "$LOCAL_REPO_REPLAY_OUT_DIR/routinator.json" >"$LOCAL_REPO_REPLAY_OUT_DIR/json.log" 2>&1
' local_repo_replay "${ARGS[@]}"
python3 "$SCRIPT_DIR/normalize_rp_outputs.py" --kind routinator --input "$OUT_DIR/routinator.json" --vrps-out "$OUT_DIR/vrps.normalized.txt" --vaps-out "$OUT_DIR/vaps.normalized.txt"
python3 "$SCRIPT_DIR/summarize_replay.py" --rp routinator --out-dir "$OUT_DIR" --summary "$OUT_DIR/summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
run_rpki_client_from_local_tree.sh \
--rpki-client-bin <path> \
--mirror-root <local-rsync-mirror-root> \
--tal-dir <dir> | --tal <file>... \
--out-dir <path> \
[--cache-dir <work-cache-dir>] \
[--validation-time <RFC3339>] \
[--real-rsync-bin <path>] \
[--parser-workers <n>]
The input mirror is prepared by the caller. This script disables RRDP, points
rpki-client's rsync command at a local URI mapper, and fetches only from the
local filesystem tree.
EOF
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RPKI_CLIENT_BIN=""
CACHE_DIR=""
MIRROR_ROOT=""
OUT_DIR=""
TAL_DIR=""
VALIDATION_TIME=""
PARSER_WORKERS="${PARSER_WORKERS:-4}"
REAL_RSYNC_BIN="${REAL_RSYNC_BIN:-/usr/bin/rsync}"
TALS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--rpki-client-bin) RPKI_CLIENT_BIN="$2"; shift 2 ;;
--mirror-root) MIRROR_ROOT="$2"; shift 2 ;;
--cache-dir) CACHE_DIR="$2"; shift 2 ;;
--tal-dir) TAL_DIR="$2"; shift 2 ;;
--tal) TALS+=("$2"); shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--validation-time) VALIDATION_TIME="$2"; shift 2 ;;
--real-rsync-bin) REAL_RSYNC_BIN="$2"; shift 2 ;;
--parser-workers) PARSER_WORKERS="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$RPKI_CLIENT_BIN" && -n "$MIRROR_ROOT" && -n "$OUT_DIR" ]] || { usage >&2; exit 2; }
[[ -x "$RPKI_CLIENT_BIN" ]] || { echo "rpki-client binary not executable: $RPKI_CLIENT_BIN" >&2; exit 2; }
[[ -d "$MIRROR_ROOT" ]] || { echo "mirror root not found: $MIRROR_ROOT" >&2; exit 2; }
if [[ -z "$TAL_DIR" && "${#TALS[@]}" -eq 0 ]]; then
echo "provide --tal-dir or at least one --tal" >&2
exit 2
fi
mkdir -p "$OUT_DIR"
if [[ -z "$CACHE_DIR" ]]; then
CACHE_DIR="$OUT_DIR/cache"
fi
mkdir -p "$CACHE_DIR" "$OUT_DIR/out"
chmod a+rwx "$OUT_DIR" "$CACHE_DIR" "$OUT_DIR/out"
WORK_DIR="$OUT_DIR/work"
PREPARED_TALS="$WORK_DIR/tals"
rm -rf "$WORK_DIR"
mkdir -p "$PREPARED_TALS"
PREPARE_TAL_ARGS=(--out-dir "$PREPARED_TALS" --summary "$OUT_DIR/prepared-tals.json")
if [[ -n "$TAL_DIR" ]]; then
PREPARE_TAL_ARGS+=(--tal-dir "$TAL_DIR")
fi
for tal in "${TALS[@]}"; do
PREPARE_TAL_ARGS+=(--tal "$tal")
done
python3 "$SCRIPT_DIR/prepare_tals.py" "${PREPARE_TAL_ARGS[@]}"
CLIENT_TAL_ARGS=()
while IFS= read -r tal; do
CLIENT_TAL_ARGS+=(-t "$tal")
done < <(find "$PREPARED_TALS" -maxdepth 1 -type f -name '*.tal' | sort)
TIME_ARGS=()
if [[ -n "$VALIDATION_TIME" ]]; then
epoch="$(python3 - <<'PY' "$VALIDATION_TIME"
from datetime import datetime, timezone
import sys
print(int(datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()))
PY
)"
TIME_ARGS=(-P "$epoch")
fi
export CIR_MIRROR_ROOT="$(cd "$MIRROR_ROOT" && pwd)"
export REAL_RSYNC_BIN="$REAL_RSYNC_BIN"
export CIR_LOCAL_LINK_MODE=1
/usr/bin/time -v -o "$OUT_DIR/process-time.txt" \
"$RPKI_CLIENT_BIN" \
-R -e "$SCRIPT_DIR/cir-rsync-wrapper" -j -c -p "$PARSER_WORKERS" \
"${TIME_ARGS[@]}" \
"${CLIENT_TAL_ARGS[@]}" \
-d "$CACHE_DIR" \
"$OUT_DIR/out" >"$OUT_DIR/stdout.log" 2>"$OUT_DIR/stderr.log"
python3 "$SCRIPT_DIR/normalize_rp_outputs.py" --kind rpki-client --input "$OUT_DIR/out" --vrps-out "$OUT_DIR/vrps.normalized.txt" --vaps-out "$OUT_DIR/vaps.normalized.txt"
python3 "$SCRIPT_DIR/summarize_replay.py" --rp rpki-client --out-dir "$OUT_DIR" --summary "$OUT_DIR/summary.json"
echo "done: $OUT_DIR"

View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
def count_lines(path: Path) -> int:
if not path.is_file():
return 0
return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip())
def parse_time(path: Path) -> dict[str, object]:
if not path.is_file():
return {}
text = path.read_text(encoding="utf-8", errors="replace")
elapsed = re.search(r"Elapsed \(wall clock\) time .*: (.+)", text)
rss = re.search(r"Maximum resident set size \(kbytes\): (\d+)", text)
return {
"elapsed": elapsed.group(1).strip() if elapsed else "",
"maxRssKb": int(rss.group(1)) if rss else 0,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--rp", required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--summary", type=Path, required=True)
args = parser.parse_args()
summary = {
"rp": args.rp,
"vrps": count_lines(args.out_dir / "vrps.normalized.txt"),
"vaps": count_lines(args.out_dir / "vaps.normalized.txt"),
"time": parse_time(args.out_dir / "process-time.txt"),
"artifacts": sorted(path.name for path in args.out_dir.iterdir() if path.is_file()),
}
args.summary.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(args.summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,118 @@
# Local Repository Tree Replay Package
This package replays already prepared local RPKI repository trees with
Routinator and rpki-client.
It is intentionally independent from CIR:
- it does not read `.cir`;
- it does not read `repo-bytes.db`;
- it does not call `cir_materialize`;
- it does not generate a local repository tree.
The caller must prepare the local repository/cache tree before running these
scripts.
## Contents
```text
local-repo-replay-package/
scripts/
run_routinator_from_local_tree.sh
run_rpki_client_from_local_tree.sh
run_dual_local_tree_replay.sh
prepare_tals.py
cir-rsync-wrapper
cir-local-link-sync.py
normalize_rp_outputs.py
compare_normalized_sets.py
summarize_replay.py
docs/
input_tree_requirements.md
offline_replay_limits.md
output_files.md
examples/
routinator_example.sh
rpki_client_example.sh
dual_compare_example.sh
env.example
```
## Routinator replay
```bash
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/routinator \
--enable-aspa
```
The script uses `--disable-rrdp`, `--rsync-command ./scripts/cir-rsync-wrapper`,
and the local mirror root to satisfy rsync fetches from the local filesystem.
The wrapper name is historical; in this package it is only a generic
`rsync://` to local-path mapper.
If `--validation-time` is needed for Routinator, set `FAKETIME_LIB` to a working
libfaketime shared library. Otherwise Routinator validates at wall-clock time.
On Ubuntu, install and use faketime like this:
```bash
sudo apt-get install -y libfaketime
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/routinator \
--validation-time 2026-05-14T06:48:00Z \
--enable-aspa
```
Without `FAKETIME_LIB`, old local trees can produce empty or smaller output
because Routinator validates manifests and CRLs against current wall-clock time.
## rpki-client replay
```bash
./scripts/run_rpki_client_from_local_tree.sh \
--rpki-client-bin /opt/rpki-client/src/rpki-client \
--mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/rpki-client \
--parser-workers 4
```
The script uses `rpki-client -R -e ./scripts/cir-rsync-wrapper` so RRDP is
disabled and rsync fetches are served from the local mirror. `--cache-dir` is an
optional working cache directory used by rpki-client during this local replay.
## Dual replay
```bash
./scripts/run_dual_local_tree_replay.sh \
--routinator-bin /opt/routinator/target/release/routinator \
--routinator-mirror-root /data/replay/mirror \
--rpki-client-bin /opt/rpki-client/src/rpki-client \
--rpki-client-mirror-root /data/replay/mirror \
--tal-dir /data/replay/tals \
--out-dir /data/replay/out/dual
```
If `--validation-time` is passed to dual replay, remember to export
`FAKETIME_LIB` first so Routinator and rpki-client use the same logical
validation time.
## Outputs
Each run writes normalized output:
- `vrps.normalized.txt`
- `vaps.normalized.txt`
- `summary.json`
- raw RP output and logs
- `process-time.txt`
See `docs/output_files.md`.

View File

@ -0,0 +1,39 @@
# Input Tree Requirements
The input tree is not part of this package. The caller must prepare it before
running replay.
## Mirror root
The mirror root must map rsync URIs to local paths:
```text
rsync://rpki.example.net/repo/a/b/c.roa
=> <mirror-root>/rpki.example.net/repo/a/b/c.roa
```
The tree must contain all objects needed by the selected TALs: TA certificates,
manifests, CRLs, ROAs, ASPAs, router certs, and child CA certificates.
Both Routinator and rpki-client scripts consume this same mirror root through a
local rsync command wrapper.
## rpki-client working cache
For rpki-client replay, `--cache-dir` is only rpki-client's working cache
directory for this local run. It is not the input dataset. The authoritative
input is `--mirror-root`.
## TALs
Provide either `--tal-dir <dir>` or repeated `--tal <file>`.
The scripts prepare a replay-local TAL copy that prefers `rsync://` TA
certificate URIs. This prevents a TAL with an HTTPS URI listed first from
escaping to the network during local replay. The TAL set should match the local
tree. Mixing a tree from one run with different TALs may produce meaningless
differences.
## No generation
This package does not generate the tree and does not repair missing objects.

View File

@ -0,0 +1,38 @@
# Offline Replay Limits
## Routinator
The Routinator script disables RRDP and uses an rsync command wrapper to map
rsync URLs to local paths. It still runs Routinator's normal validation logic.
If the local mirror does not contain required objects, validation can fail or
produce fewer outputs.
## rpki-client
The rpki-client script uses `-R` to disable RRDP and `-e` to point rsync at the
local mapper. rpki-client still builds its normal working cache, but every
rsync source is rewritten to the local mirror.
If the mirror was incomplete or produced by a different TAL set, replay results
may differ from the original run.
## Validation time
rpki-client supports `-P <posix-seconds>`. Routinator does not expose the same
simple command-line evaluation-time option in the tested version; if `FAKETIME_LIB`
is configured, the script can run Routinator under faketime. Without
`FAKETIME_LIB`, `--validation-time` is intentionally ignored for Routinator and
current wall-clock validation can reject stale manifests or CRLs.
Ubuntu example:
```bash
sudo apt-get install -y libfaketime
export FAKETIME_LIB=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1
```
The script sets `TZ=UTC` and converts RFC3339 validation time to libfaketime
absolute UTC format, for example `2026-05-14T06:48:00Z` becomes
`@2026-05-14 06:48:00`. Setting `TZ=UTC` is required because libfaketime parses
absolute timestamps in the process timezone.

View File

@ -0,0 +1,16 @@
# Output Files
Each replay output directory can contain:
- `vrps.normalized.txt`: one normalized VRP per line.
- `vaps.normalized.txt`: one normalized VAP/ASPA per line.
- `summary.json`: counts and resource summary.
- `process-time.txt`: `/usr/bin/time -v` output.
- RP-specific raw output:
- Routinator: `routinator.json`, `vrps.csv`.
- rpki-client: `out/json`, `out/csv`, and other native files.
- logs:
- `stdout.log` / `stderr.log` for rpki-client.
- `update.log`, `vrps.log`, `json.log` for Routinator.
Dual replay additionally writes `compare-summary.json`.

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_dual_local_tree_replay.sh \
--routinator-bin "${ROUTINATOR_BIN:-/opt/routinator/target/release/routinator}" \
--routinator-mirror-root "${ROUTINATOR_MIRROR_ROOT:-/data/replay/mirror}" \
--rpki-client-bin "${RPKI_CLIENT_BIN:-/opt/rpki-client/src/rpki-client}" \
--rpki-client-mirror-root "${RPKI_CLIENT_MIRROR_ROOT:-/data/replay/mirror}" \
--rpki-client-cache-dir "${RPKI_CLIENT_CACHE_DIR:-/data/replay/work/rpki-client-cache}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/dual}"

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_routinator_from_local_tree.sh \
--routinator-bin "${ROUTINATOR_BIN:-/opt/routinator/target/release/routinator}" \
--mirror-root "${MIRROR_ROOT:-/data/replay/mirror}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/routinator}" \
--enable-aspa

View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
./scripts/run_rpki_client_from_local_tree.sh \
--rpki-client-bin "${RPKI_CLIENT_BIN:-/opt/rpki-client/src/rpki-client}" \
--mirror-root "${MIRROR_ROOT:-/data/replay/mirror}" \
--cache-dir "${RPKI_CLIENT_CACHE_DIR:-/data/replay/work/rpki-client-cache}" \
--tal-dir "${TAL_DIR:-/data/replay/tals}" \
--out-dir "${OUT_DIR:-/data/replay/out/rpki-client}" \
--parser-workers "${PARSER_WORKERS:-4}"

View File

@ -2,6 +2,7 @@
# 复制为 .env 后可以在远端直接调整;所有路径默认相对 package 根目录。
# 最大运行轮次。重复执行 run_soak.sh 时会从已有最后一轮之后继续编号。
# 正整数表示固定运行轮次;负数(例如 -1表示持续运行不自动停止0 非法。
MAX_RUNS=3
# 两轮之间等待秒数。做连续无等待验收时设置为 0。
@ -15,7 +16,7 @@ RIRS=afrinic,apnic,arin,lacnic,ripe
# 运行根目录。默认使用 package 根目录;如需把产物写到独立数据盘,可改成绝对路径。
RUN_ROOT="${PACKAGE_ROOT}"
# 保留最近多少轮 run 目录。旧 run 会由 rpki_daemon 自身或后续脚本策略清理
# 保留最近多少轮 run 目录。持续运行模式建议设置为 5 或按磁盘容量评估
RETAIN_RUNS=10
# 是否输出 compact report JSON。1 表示启用0 表示关闭。

View File

@ -77,6 +77,11 @@ validate_non_negative_int() {
[[ "$value" =~ ^[0-9]+$ ]] || die "$name must be an integer: $value"
}
validate_max_runs() {
[[ "$MAX_RUNS" =~ ^-?[0-9]+$ ]] || die "MAX_RUNS must be an integer: $MAX_RUNS"
[[ "$MAX_RUNS" != "0" ]] || die "MAX_RUNS must be non-zero; use a positive value for fixed runs or -1 for continuous mode"
}
validate_rsync_scope() {
case "$RSYNC_SCOPE" in
publication-point|module-root)
@ -486,20 +491,30 @@ PY
apply_outer_retention() {
local dirs=()
local retain_limit="$RETAIN_RUNS"
local keep_run="${1:-}"
local run_dir
shopt -s nullglob
for run_dir in "$RUNS_ROOT"/run_[0-9][0-9][0-9][0-9]; do
[[ -d "$run_dir" ]] && dirs+=("$run_dir")
done
shopt -u nullglob
if (( ${#dirs[@]} <= RETAIN_RUNS )); then
if (( ${#dirs[@]} <= retain_limit )); then
return 0
fi
mapfile -t dirs < <(printf '%s\n' "${dirs[@]}" | sort)
local remove_count=$(( ${#dirs[@]} - RETAIN_RUNS ))
local index
for (( index = 0; index < remove_count; index++ )); do
rm -rf "${dirs[$index]}"
local remove_count=$(( ${#dirs[@]} - retain_limit ))
local removed_count=0
local candidate
for candidate in "${dirs[@]}"; do
if [[ -n "$keep_run" && "$(basename "$candidate")" == "$keep_run" ]]; then
continue
fi
rm -rf "$candidate"
removed_count=$((removed_count + 1))
if (( removed_count >= remove_count )); then
break
fi
done
}
@ -519,6 +534,7 @@ run_one_round() {
local summary_state
mkdir -p "$run_dir" "$daemon_state_root"
apply_outer_retention "$run_id"
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_run_meta "$run_dir/run-meta.json" "running" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "" \
@ -573,7 +589,7 @@ main() {
require_command python3
require_command date
require_command find
validate_positive_int "MAX_RUNS" "$MAX_RUNS"
validate_max_runs
validate_non_negative_int "INTERVAL_SECS" "$INTERVAL_SECS"
validate_positive_int "RETAIN_RUNS" "$RETAIN_RUNS"
validate_rsync_scope
@ -599,12 +615,20 @@ main() {
local max_index
local next_index
local run_forever=0
local stop_index=0
max_index="$(max_existing_run_index)"
next_index=$((max_index + 1))
local stop_index=$((max_index + MAX_RUNS))
if (( MAX_RUNS < 0 )); then
run_forever=1
echo "run_soak mode=continuous max_existing_run_index=$max_index next_run=$(printf 'run_%04d' "$next_index")"
else
stop_index=$((max_index + MAX_RUNS))
echo "run_soak mode=fixed max_existing_run_index=$max_index next_run=$(printf 'run_%04d' "$next_index") stop_run=$(printf 'run_%04d' "$stop_index")"
fi
local any_failed=0
while (( next_index <= stop_index )); do
while (( run_forever == 1 || next_index <= stop_index )); do
INVALID_DB_PATH=""
INVALID_STATE_PATH=""
INVALID_TMP_PATH=""
@ -649,7 +673,7 @@ main() {
echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2
any_failed=1
fi
if (( next_index < stop_index && INTERVAL_SECS > 0 )); then
if (( (run_forever == 1 || next_index < stop_index) && INTERVAL_SECS > 0 )); then
sleep "$INTERVAL_SECS"
fi
next_index=$((next_index + 1))

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ pub mod decode;
pub mod encode;
#[cfg(feature = "full")]
pub mod export;
#[cfg(feature = "full")]
pub mod materialize;
pub mod model;
pub mod sequence;
@ -15,6 +16,7 @@ pub use export::{
CirExportError, CirExportSummary, CirTrustAnchorBinding, build_cir_from_run,
build_cir_from_run_multi, export_cir_from_run, export_cir_from_run_multi, write_cir_file,
};
#[cfg(feature = "full")]
pub use materialize::{
CirMaterializeError, CirMaterializeSummary, materialize_cir, materialize_cir_from_raw_store,
materialize_cir_from_repo_bytes, mirror_relative_path_for_rsync_uri, resolve_static_pool_file,

View File

@ -12,6 +12,7 @@ pub mod audit_downloads;
pub mod audit_trace;
#[cfg(feature = "full")]
pub mod blob_store;
#[cfg(feature = "full")]
pub mod cli;
#[cfg(feature = "full")]
pub mod current_repo_index;

View File

@ -589,6 +589,48 @@ mod tests {
}
}
struct FailRrdpThenFailRsyncExecutor {
rrdp_count: Arc<AtomicUsize>,
rsync_count: Arc<AtomicUsize>,
}
impl RepoTransportExecutor for FailRrdpThenFailRsyncExecutor {
fn execute_transport(&self, task: RepoTransportTask) -> RepoTransportResultEnvelope {
match task.mode {
RepoTransportMode::Rrdp => {
self.rrdp_count.fetch_add(1, Ordering::SeqCst);
RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rrdp,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: 10,
result: RepoTransportResultKind::Failed {
detail: "rrdp failed".to_string(),
warnings: vec![Warning::new("rrdp failed")],
},
}
}
RepoTransportMode::Rsync => {
self.rsync_count.fetch_add(1, Ordering::SeqCst);
RepoTransportResultEnvelope {
dedup_key: task.dedup_key,
repo_identity: task.repo_identity,
mode: RepoTransportMode::Rsync,
tal_id: task.tal_id,
rir_id: task.rir_id,
timing_ms: 12,
result: RepoTransportResultKind::Failed {
detail: "rsync failed".to_string(),
warnings: vec![Warning::new("rsync failed")],
},
}
}
}
}
}
#[test]
fn phase1_runtime_waits_for_rrdp_transport_and_returns_rrdp_outcome() {
let coordinator = GlobalRunCoordinator::new(
@ -852,6 +894,42 @@ mod tests {
assert_eq!(rsync_count.load(Ordering::SeqCst), 1);
}
#[test]
fn phase1_runtime_terminal_failure_keeps_rsync_failure_duration() {
let rrdp_count = Arc::new(AtomicUsize::new(0));
let rsync_count = Arc::new(AtomicUsize::new(0));
let coordinator = GlobalRunCoordinator::new(
ParallelPhase1Config::default(),
vec![TalInputSpec::from_url("https://example.test/arin.tal")],
);
let pool = RepoTransportWorkerPool::new(
RepoWorkerPoolConfig { max_workers: 1 },
FailRrdpThenFailRsyncExecutor {
rrdp_count: Arc::clone(&rrdp_count),
rsync_count: Arc::clone(&rsync_count),
},
)
.expect("pool");
let runtime = Phase1RepoSyncRuntime::new(
coordinator,
pool,
Arc::new(|_base: &str| "rsync://example.test/module/".to_string()),
SyncPreference::RrdpThenRsync,
);
let outcome = runtime
.sync_publication_point_repo(&sample_ca("rsync://example.test/repo/root.mft"))
.expect("sync repo");
assert!(!outcome.repo_sync_ok);
assert_eq!(
outcome.repo_sync_phase.as_deref(),
Some("rrdp_failed_rsync_failed")
);
assert_eq!(outcome.repo_sync_duration_ms, 12);
assert_eq!(rrdp_count.load(Ordering::SeqCst), 1);
assert_eq!(rsync_count.load(Ordering::SeqCst), 1);
}
#[test]
fn phase1_runtime_prefetch_submits_transport_task_before_consumption() {
let rrdp_count = Arc::new(AtomicUsize::new(0));

View File

@ -433,6 +433,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
}
let repo_sync_started = std::time::Instant::now();
let mut runtime_repo_sync_duration_ms = None;
let (repo_sync_ok, repo_sync_err, repo_sync_source, repo_sync_phase): (
bool,
Option<String>,
@ -444,9 +445,10 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
repo_sync_err,
repo_sync_source,
repo_sync_phase,
repo_sync_duration_ms: _,
repo_sync_duration_ms,
warnings: repo_warnings,
} = runtime.sync_publication_point_repo(ca)?;
runtime_repo_sync_duration_ms = Some(repo_sync_duration_ms);
warnings.extend(repo_warnings);
(
repo_sync_ok,
@ -575,7 +577,11 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
}
}
};
let repo_sync_duration_ms = repo_sync_started.elapsed().as_millis() as u64;
let repo_sync_duration_ms = effective_repo_sync_duration_ms(
repo_sync_started.elapsed().as_millis() as u64,
runtime_repo_sync_duration_ms,
repo_sync_ok,
);
crate::progress_log::emit(
"publication_point_repo_sync_done",
serde_json::json!({
@ -1640,6 +1646,19 @@ fn repo_sync_source_label(source: crate::sync::repo::RepoSyncSource) -> &'static
}
}
fn effective_repo_sync_duration_ms(
elapsed_ms: u64,
runtime_reported_duration_ms: Option<u64>,
repo_sync_ok: bool,
) -> u64 {
if repo_sync_ok {
return elapsed_ms;
}
runtime_reported_duration_ms
.map(|runtime_ms| elapsed_ms.max(runtime_ms))
.unwrap_or(elapsed_ms)
}
fn kind_from_vcir_artifact_kind(kind: VcirArtifactKind) -> AuditObjectKind {
match kind {
VcirArtifactKind::Mft => AuditObjectKind::Manifest,
@ -6463,6 +6482,15 @@ authorityKeyIdentifier = keyid:always
assert!(audit.objects.is_empty());
}
#[test]
fn effective_repo_sync_duration_uses_runtime_duration_for_failures() {
assert_eq!(effective_repo_sync_duration_ms(0, Some(12), false), 12);
assert_eq!(effective_repo_sync_duration_ms(5, Some(12), false), 12);
assert_eq!(effective_repo_sync_duration_ms(20, Some(12), false), 20);
assert_eq!(effective_repo_sync_duration_ms(5, None, false), 5);
assert_eq!(effective_repo_sync_duration_ms(5, Some(12), true), 5);
}
#[test]
fn reconstruct_snapshot_from_vcir_reports_missing_manifest_and_related_raw_bytes() {
let now = time::OffsetDateTime::now_utc();