Compare commits
14 Commits
feature/14
...
f86fbd4939
| Author | SHA1 | Date | |
|---|---|---|---|
| f86fbd4939 | |||
| 4074323302 | |||
| b00a5f920c | |||
| 654b6d1c56 | |||
| daf0764cd0 | |||
| 06f4b086b4 | |||
| 6d9eedd680 | |||
| e80fba44c3 | |||
| c48dc04909 | |||
| a5902c0a5e | |||
| a77982deaa | |||
| 4865d3698d | |||
| 59623b449b | |||
| 7aac4855bd |
@@ -1,6 +1,7 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
docker/
|
||||
perf.*
|
||||
**/* copy.excalidraw
|
||||
ui/rpki-explorer/node_modules/
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# `build_docker_installer_package.sh` rewrites package-specific placeholders
|
||||
# when producing amd64/arm64 release packages.
|
||||
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md
|
||||
# Every assignment is a required non-empty configuration key with a package
|
||||
# default. During upgrade: .env.example < reused old .env < command environment.
|
||||
|
||||
# Package metadata and architecture guardrails.
|
||||
PACKAGE_ARCH=__PACKAGE_ARCH__
|
||||
@@ -86,6 +88,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:
|
||||
|
||||
@@ -39,6 +39,10 @@ services:
|
||||
- ${METRICS_POLL_SECS:-10}
|
||||
- --instance
|
||||
- ${METRICS_INSTANCE:-package-installer}
|
||||
- --ccr-check-bin
|
||||
- ${CCR_CHECK_BIN:-/opt/ours-rp/bin/rpki-client}
|
||||
- --ccr-check-timeout-secs
|
||||
- ${CCR_CHECK_TIMEOUT_SECS:-120}
|
||||
ports:
|
||||
- "${METRICS_PORT:-9556}:9556"
|
||||
volumes:
|
||||
|
||||
@@ -850,6 +850,264 @@
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"refresh": "5s",
|
||||
|
||||
@@ -80,7 +80,7 @@ Supported values:
|
||||
- `rfc6487`: switch back to the original RFC 6487 resource containment behavior.
|
||||
|
||||
The installer / soak runner passes `--resource-validation-mode` to every `rpki` child start.
|
||||
When an upgraded deployment reuses an older `.env` that does not contain this key, the installer appends the new default `validation-update-03` automatically.
|
||||
When an upgraded deployment reuses an older `.env` that does not contain this key, the new package `.env.example` default `validation-update-03` remains in the final `.env`; an empty final value fails before Docker operations.
|
||||
|
||||
## Architecture Guardrails
|
||||
|
||||
@@ -125,6 +125,12 @@ If a separately deployed RTR service on the same host continuously writes `rtr-s
|
||||
|
||||
The command accepts only an absolute path on the same host, validates the candidate Compose mount, and atomically updates `.env`. It recreates only `artifact-metrics`, Prometheus, and Grafana; it does not restart or recreate `ours-rp-soak`. The `artifact-metrics` container mounts the directory read-only and reads it through `RPKI_METRICS_RTR_REPORT_DIR`, producing `ours_rp_rtr_*` metrics. By default, `RTR_REPORT_DIR` points at an empty fallback directory under the installer data root, so the metrics container also starts cleanly without an RTR service.
|
||||
|
||||
## CCR artifact format check metrics
|
||||
|
||||
The metrics image ships the official `rpki-client 9.8`. For every newly completed run, `artifact-metrics` runs `rpki-client -f result.ccr` once and exports `ours_rp_ccr_format_check_*` metrics (cumulative pass/ccr_parse_error/tool_error counters, latest check result, check duration, consecutive failures). The Grafana `Ours RP Soak Overview` dashboard shows them in a dedicated panel row. On service start only the latest existing run is checked; older history is not backfilled.
|
||||
|
||||
Related environment variables (normally no need to change): `CCR_CHECK_BIN` (defaults to the in-image `/opt/ours-rp/bin/rpki-client`) and `CCR_CHECK_TIMEOUT_SECS` (default 120 seconds).
|
||||
|
||||
## Data Directory
|
||||
|
||||
Default host directory:
|
||||
|
||||
@@ -80,7 +80,7 @@ RESOURCE_VALIDATION_MODE=validation-update-03
|
||||
- `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。
|
||||
|
||||
installer / soak runner 每次启动 `rpki` 子进程时都会显式传入 `--resource-validation-mode`。
|
||||
如果升级时复用旧 `.env` 且缺少该变量,installer 会自动补入默认值 `validation-update-03`,不会破坏旧 `.env` 复用流程。
|
||||
如果升级时复用旧 `.env` 且缺少该变量,新包 `.env.example` 中的默认值 `validation-update-03` 会保留在最终 `.env` 中;最终值为空时升级会在 Docker 操作前失败。
|
||||
|
||||
## 架构检查
|
||||
|
||||
@@ -125,6 +125,12 @@ GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
该命令只接受本机绝对路径,会先校验候选 Compose 挂载,再原子更新 `.env`。随后只重建 `artifact-metrics`、Prometheus 和 Grafana,不重启或重建 `ours-rp-soak`。`artifact-metrics` 以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。
|
||||
|
||||
## CCR 产物格式检查监控
|
||||
|
||||
metrics 镜像内置官方 `rpki-client 9.8`。`artifact-metrics` 会对每个新完成 run 的 `result.ccr` 执行一次 `rpki-client -f` 格式检查,并输出 `ours_rp_ccr_format_check_*` 指标(累计 pass/ccr_parse_error/tool_error、最近检查结果、检查耗时、连续失败数)。Grafana `Ours RP Soak Overview` 底部有对应面板。服务启动时只补查最新的一个历史 run,不回扫全部历史。
|
||||
|
||||
相关环境变量(通常无需修改):`CCR_CHECK_BIN`(默认为镜像内 `/opt/ours-rp/bin/rpki-client`)、`CCR_CHECK_TIMEOUT_SECS`(默认 120 秒)。
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认宿主机目录:
|
||||
|
||||
@@ -89,9 +89,26 @@ Extract the new package into a new directory and explicitly reuse the existing `
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
If the new package directory already has a `.env`, the upgrade script keeps it.
|
||||
Configuration is merged in this order, with a key on the right overriding the same key on the left:
|
||||
|
||||
If an older `.env` does not contain `METRICS_IMAGE` or `METRICS_PLATFORM`, the install/upgrade flow backfills package-matched defaults automatically.
|
||||
```text
|
||||
new .env.example < old .env < environment variables on this upgrade command
|
||||
```
|
||||
|
||||
For example, override the next-run interval and Grafana password for this upgrade:
|
||||
|
||||
```bash
|
||||
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
Before any image is loaded or container is started, upgrade verifies that every key declared by the new template is present and non-empty in the final `.env`. An explicit empty assignment fails, for example `INTERVAL_SECS= ./scripts/upgrade.sh ...`. Removed keys from an old `.env` are not copied, and the script reports key names only, never password-like values.
|
||||
|
||||
The installer reads `.env` as Compose-compatible dotenv assignments. Do not put shell command substitutions or environment references such as `$HOME` in the file; use concrete values or command-level overrides instead.
|
||||
|
||||
If the new package directory already has a `.env`, the upgrade script keeps it. In that case `--reuse-env-from` is not imported again, but environment variables from the current command still override the existing `.env`.
|
||||
|
||||
When an older `.env` lacks a key introduced by the new package, the new `.env.example` default remains in the final `.env`. Upgrade fails before Docker operations if any final declared value is empty.
|
||||
|
||||
Upgrade does not delete:
|
||||
|
||||
|
||||
@@ -89,9 +89,26 @@ curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_'
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。
|
||||
配置按以下优先级合并,右侧同名键覆盖左侧:
|
||||
|
||||
如果旧 `.env` 缺失 `METRICS_IMAGE` 或 `METRICS_PLATFORM`,升级/安装链路会按当前包架构自动补齐默认值。
|
||||
```text
|
||||
新包 .env.example < 旧 .env < 本次 upgrade 命令的环境变量
|
||||
```
|
||||
|
||||
例如,临时调整下轮触发间隔和 Grafana 密码:
|
||||
|
||||
```bash
|
||||
INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='new-password' \
|
||||
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
|
||||
```
|
||||
|
||||
升级会在加载镜像或启动容器前检查:新模板声明的所有变量在最终 `.env` 中都必须存在且非空。显式传入空值会失败,例如 `INTERVAL_SECS= ./scripts/upgrade.sh ...`。旧 `.env` 中已不在新模板内的键不会复制,脚本只输出相关键名而不会输出密码等值。
|
||||
|
||||
installer 按与 Compose 一致的 dotenv 赋值格式读取 `.env`。不要在文件中使用 Shell 命令替换或 `$HOME` 一类环境变量引用;请使用确定的值,或在执行 upgrade 时通过命令环境变量覆盖。
|
||||
|
||||
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖;此时 `--reuse-env-from` 不会再次导入旧配置,但本次命令的环境变量仍会覆盖已存在的 `.env`。
|
||||
|
||||
旧 `.env` 缺少新版本新增的键时,新包 `.env.example` 的默认值会保留在最终 `.env` 中;任一最终值为空则升级会在 Docker 操作前失败。
|
||||
|
||||
升级不会删除以下数据:
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
|
||||
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
|
||||
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
|
||||
MANIFEST_FILE="${MANIFEST_FILE:-$INSTALLER_ROOT/PACKAGE-MANIFEST.env}"
|
||||
declare -a ENV_TEMPLATE_KEYS=()
|
||||
declare -A ENV_TEMPLATE_KEY_SET=()
|
||||
declare -A INVOCATION_ENV_OVERRIDES=()
|
||||
declare -A REUSED_ENV_KEYS=()
|
||||
declare -A ENV_PARSED_VALUES=()
|
||||
|
||||
log() {
|
||||
printf '[ours-rp-installer] %s\n' "$*"
|
||||
@@ -48,20 +53,246 @@ env_file_has_key() {
|
||||
[[ -f "$env_path" ]] && grep -Eq "^${key}=" "$env_path"
|
||||
}
|
||||
|
||||
persist_env_default() {
|
||||
load_env_template_keys() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
local key
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment template: $env_path"
|
||||
ENV_TEMPLATE_KEYS=()
|
||||
ENV_TEMPLATE_KEY_SET=()
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || die "invalid environment assignment in $env_path: $line"
|
||||
key="${BASH_REMATCH[1]}"
|
||||
[[ -z "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]] || die "duplicate environment key in $env_path: $key"
|
||||
ENV_TEMPLATE_KEYS+=("$key")
|
||||
ENV_TEMPLATE_KEY_SET["$key"]=1
|
||||
done < "$env_path"
|
||||
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "no environment assignments found in $env_path"
|
||||
}
|
||||
|
||||
template_has_env_key() {
|
||||
local key="$1"
|
||||
[[ -n "${ENV_TEMPLATE_KEY_SET[$key]+x}" ]]
|
||||
}
|
||||
|
||||
env_file_assignment_keys() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]] || continue
|
||||
printf '%s\n' "${BASH_REMATCH[1]}"
|
||||
done < "$env_path"
|
||||
}
|
||||
|
||||
dotenv_decode_value() {
|
||||
local raw_value="$1"
|
||||
local quote
|
||||
local body
|
||||
local decoded=""
|
||||
local character
|
||||
local next_character
|
||||
local index=0
|
||||
|
||||
[[ -n "$raw_value" ]] || {
|
||||
printf '\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
quote="${raw_value:0:1}"
|
||||
case "$quote" in
|
||||
"'")
|
||||
[[ "${raw_value: -1}" == "'" && ${#raw_value} -ge 2 ]] || return 1
|
||||
body="${raw_value:1:${#raw_value}-2}"
|
||||
while (( index < ${#body} )); do
|
||||
character="${body:index:1}"
|
||||
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
|
||||
next_character="${body:index + 1:1}"
|
||||
case "$next_character" in
|
||||
"'")
|
||||
decoded+="$next_character"
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
decoded+="$character"
|
||||
((index += 1))
|
||||
done
|
||||
;;
|
||||
'"')
|
||||
[[ "${raw_value: -1}" == '"' && ${#raw_value} -ge 2 ]] || return 1
|
||||
body="${raw_value:1:${#raw_value}-2}"
|
||||
while (( index < ${#body} )); do
|
||||
character="${body:index:1}"
|
||||
if [[ "$character" == '$' && $((index + 1)) -lt ${#body} && "${body:index + 1:1}" == '$' ]]; then
|
||||
decoded+='$'
|
||||
((index += 2))
|
||||
continue
|
||||
fi
|
||||
if [[ "$character" == "\\" && $((index + 1)) -lt ${#body} ]]; then
|
||||
next_character="${body:index + 1:1}"
|
||||
case "$next_character" in
|
||||
'"'|\\|'$'|'`')
|
||||
decoded+="$next_character"
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
n)
|
||||
decoded+=$'\n'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
r)
|
||||
decoded+=$'\r'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
t)
|
||||
decoded+=$'\t'
|
||||
((index += 2))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
decoded+="$character"
|
||||
((index += 1))
|
||||
done
|
||||
;;
|
||||
*)
|
||||
decoded="$raw_value"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s' "$decoded"
|
||||
}
|
||||
|
||||
parse_env_file() {
|
||||
local env_path="$1"
|
||||
local line
|
||||
local parsed_key
|
||||
local raw_value
|
||||
local parsed_value
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
ENV_PARSED_VALUES=()
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line%$'\r'}"
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || return 2
|
||||
parsed_key="${BASH_REMATCH[1]}"
|
||||
raw_value="${BASH_REMATCH[2]}"
|
||||
parsed_value="$(dotenv_decode_value "$raw_value")" || return 2
|
||||
ENV_PARSED_VALUES["$parsed_key"]="$parsed_value"
|
||||
done < "$env_path"
|
||||
}
|
||||
|
||||
env_file_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
|
||||
parse_env_file "$env_path"
|
||||
|
||||
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || return 3
|
||||
printf '%s' "${ENV_PARSED_VALUES[$key]}"
|
||||
}
|
||||
|
||||
env_file_set_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp_env
|
||||
if env_file_has_key "$env_path" "$key"; then
|
||||
local encoded_value
|
||||
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value for $key contains an unsupported newline"
|
||||
encoded_value="$(env_file_encode_value "$value")"
|
||||
if ! env_file_has_key "$env_path" "$key"; then
|
||||
printf '%s=%s\n' "$key" "$encoded_value" >> "$env_path"
|
||||
return 0
|
||||
fi
|
||||
tmp_env="$(mktemp)"
|
||||
cat "$env_path" > "$tmp_env"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp_env"
|
||||
ENV_FILE_REPLACEMENT="$key=$encoded_value" awk -v key="$key" '
|
||||
BEGIN { done=0 }
|
||||
$0 ~ "^" key "=" { print ENVIRON["ENV_FILE_REPLACEMENT"]; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print ENVIRON["ENV_FILE_REPLACEMENT"] }
|
||||
' "$env_path" > "$tmp_env"
|
||||
mv "$tmp_env" "$env_path"
|
||||
}
|
||||
|
||||
env_file_encode_value() {
|
||||
local value="$1"
|
||||
local escaped="$value"
|
||||
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value contains an unsupported newline"
|
||||
escaped="${escaped//\\/\\\\}"
|
||||
escaped="${escaped//\"/\\\"}"
|
||||
escaped="${escaped//\$/\$\$}"
|
||||
printf '"%s"' "$escaped"
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
local env_path="$1"
|
||||
local key
|
||||
|
||||
parse_env_file "$env_path" || die "unable to parse environment file: $env_path"
|
||||
for key in "${!ENV_PARSED_VALUES[@]}"; do
|
||||
printf -v "$key" '%s' "${ENV_PARSED_VALUES[$key]}"
|
||||
export "$key"
|
||||
done
|
||||
}
|
||||
|
||||
validate_env_file_contract() {
|
||||
local env_path="$1"
|
||||
local invalid_keys=""
|
||||
local key
|
||||
|
||||
[[ -f "$env_path" ]] || die "missing environment file: $env_path"
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
|
||||
|
||||
if ! parse_env_file "$env_path"; then
|
||||
die "unable to parse environment file: $env_path"
|
||||
fi
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
if [[ -z "${ENV_PARSED_VALUES[$key]+x}" || -z "${ENV_PARSED_VALUES[$key]}" ]]; then
|
||||
invalid_keys+="$key"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -z "$invalid_keys" ]] || die "environment contract requires non-empty values in $env_path: ${invalid_keys//$'\n'/,}"
|
||||
}
|
||||
|
||||
capture_invocation_env_overrides() {
|
||||
local key
|
||||
|
||||
(( ${#ENV_TEMPLATE_KEYS[@]} > 0 )) || die "environment template keys were not loaded"
|
||||
INVOCATION_ENV_OVERRIDES=()
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
if [[ -v "$key" ]]; then
|
||||
INVOCATION_ENV_OVERRIDES["$key"]="${!key}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
log_env_key_group() {
|
||||
local group="$1"
|
||||
shift
|
||||
local rendered="none"
|
||||
if (( $# > 0 )); then
|
||||
local IFS=,
|
||||
rendered="$*"
|
||||
fi
|
||||
log "$group=$rendered"
|
||||
}
|
||||
|
||||
env_flag_enabled() {
|
||||
case "${1:-0}" in
|
||||
1|true|TRUE|yes|YES|on|ON)
|
||||
@@ -156,15 +387,15 @@ assert_arch_compatibility() {
|
||||
|
||||
load_env() {
|
||||
load_manifest
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
|
||||
load_env_template_keys "$ENV_EXAMPLE"
|
||||
validate_env_file_contract "$ENV_EXAMPLE"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log "created .env from .env.example"
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
validate_env_file_contract "$ENV_FILE"
|
||||
load_env_file "$ENV_FILE"
|
||||
|
||||
if [[ -n "${MANIFEST_PACKAGE_ARCH_RAW:-}" ]]; then
|
||||
manifest_package_arch="$(normalize_arch "$MANIFEST_PACKAGE_ARCH_RAW")" || die "unsupported manifest package arch: $MANIFEST_PACKAGE_ARCH_RAW"
|
||||
@@ -190,27 +421,6 @@ load_env() {
|
||||
PACKAGE_PLATFORM="${PACKAGE_PLATFORM:-$(platform_for_arch "$PACKAGE_ARCH")}"
|
||||
fi
|
||||
|
||||
HOST_DATA_DIR="${HOST_DATA_DIR:-/var/lib/ours-rp-${PACKAGE_ARCH}-installer}"
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ours-rp-${PACKAGE_ARCH}-installer}"
|
||||
RPKI_IMAGE="${RPKI_IMAGE:-ours-rp-runtime-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
|
||||
RPKI_PLATFORM="${RPKI_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
METRICS_IMAGE="${METRICS_IMAGE:-ours-rp-metrics-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
|
||||
METRICS_PLATFORM="${METRICS_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
RTR_REPORT_DIR="${RTR_REPORT_DIR:-$HOST_DATA_DIR/empty-rtr-report}"
|
||||
RTR_REPORT_CONTAINER_DIR="${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}"
|
||||
MONITOR_PLATFORM="${MONITOR_PLATFORM:-$PACKAGE_PLATFORM}"
|
||||
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
|
||||
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
|
||||
METRICS_INSTANCE="${METRICS_INSTANCE:-${PACKAGE_ARCH}-installer}"
|
||||
ALLOW_CROSS_ARCH="${ALLOW_CROSS_ARCH:-0}"
|
||||
RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}"
|
||||
FIRST_RUN_WAIT_TIMEOUT_SECS="${FIRST_RUN_WAIT_TIMEOUT_SECS:-7200}"
|
||||
|
||||
persist_env_default "$ENV_FILE" "METRICS_IMAGE" "$METRICS_IMAGE"
|
||||
persist_env_default "$ENV_FILE" "METRICS_PLATFORM" "$METRICS_PLATFORM"
|
||||
persist_env_default "$ENV_FILE" "RTR_REPORT_DIR" "$RTR_REPORT_DIR"
|
||||
persist_env_default "$ENV_FILE" "RTR_REPORT_CONTAINER_DIR" "$RTR_REPORT_CONTAINER_DIR"
|
||||
persist_env_default "$ENV_FILE" "RESOURCE_VALIDATION_MODE" "$RESOURCE_VALIDATION_MODE"
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
@@ -546,6 +756,18 @@ verify_metrics_image() {
|
||||
log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM"
|
||||
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics"
|
||||
verify_image_usage_output "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics" /opt/ours-rp/bin/rpki_artifact_metrics --help
|
||||
verify_metrics_image_rpki_client
|
||||
}
|
||||
|
||||
verify_metrics_image_rpki_client() {
|
||||
local output
|
||||
local status
|
||||
set +e
|
||||
output="$(docker run --rm --platform "$METRICS_PLATFORM" "$METRICS_IMAGE" /opt/ours-rp/bin/rpki-client -V 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" == "0" ]] || die "metrics image rpki-client -V failed: image=$METRICS_IMAGE exit=$status output=$output"
|
||||
grep -q "rpki-client" <<<"$output" || die "metrics image rpki-client -V unexpected output: $output"
|
||||
}
|
||||
|
||||
verify_image_platform() {
|
||||
|
||||
@@ -13,6 +13,8 @@ Usage: ./scripts/upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-imag
|
||||
|
||||
By default, --reuse-env-from creates the new .env from this package's
|
||||
.env.example first, then overlays existing user settings from the old .env.
|
||||
For declared configuration keys, explicit invocation variables have highest
|
||||
priority, for example: INTERVAL_SECS=300 ./scripts/upgrade.sh ...
|
||||
Image tags are intentionally kept from the new package so the upgraded service
|
||||
actually runs the new packaged runtime and monitor images. Use
|
||||
--keep-reused-image only when you intentionally want to keep previous image tags.
|
||||
@@ -39,56 +41,80 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
env_get_key() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
awk -F= -v key="$key" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' "$env_path"
|
||||
}
|
||||
|
||||
env_set_key() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp_env
|
||||
if grep -q "^${key}=" "$env_path"; then
|
||||
tmp_env="$(mktemp)"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { done=0 }
|
||||
$0 ~ "^" key "=" { print key "=" value; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print key "=" value }
|
||||
' "$env_path" > "$tmp_env"
|
||||
mv "$tmp_env" "$env_path"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$env_path"
|
||||
fi
|
||||
}
|
||||
|
||||
overlay_reused_env() {
|
||||
local source_env="$1"
|
||||
local target_env="$2"
|
||||
local key
|
||||
local value
|
||||
while IFS='=' read -r key _; do
|
||||
[[ -n "$key" ]] || continue
|
||||
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
local -a reused_keys=()
|
||||
local -a ignored_removed_keys=()
|
||||
local -a preserved_package_keys=()
|
||||
|
||||
REUSED_ENV_KEYS=()
|
||||
parse_env_file "$source_env" || die "unable to parse reused environment: $source_env"
|
||||
while IFS= read -r key; do
|
||||
if ! template_has_env_key "$key"; then
|
||||
ignored_removed_keys+=("$key")
|
||||
continue
|
||||
fi
|
||||
case "$key" in
|
||||
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
|
||||
preserved_package_keys+=("$key")
|
||||
continue
|
||||
;;
|
||||
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE)
|
||||
[[ "$UPDATE_PACKAGE_IMAGE" == "0" ]] || continue
|
||||
if [[ "$UPDATE_PACKAGE_IMAGE" != "0" ]]; then
|
||||
preserved_package_keys+=("$key")
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
value="$(env_get_key "$source_env" "$key")"
|
||||
env_set_key "$target_env" "$key" "$value"
|
||||
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$source_env" || true)
|
||||
[[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || die "unable to read $key from reused environment: $source_env"
|
||||
value="${ENV_PARSED_VALUES[$key]}"
|
||||
env_file_set_value "$target_env" "$key" "$value"
|
||||
REUSED_ENV_KEYS["$key"]=1
|
||||
reused_keys+=("$key")
|
||||
done < <(env_file_assignment_keys "$source_env")
|
||||
|
||||
log_env_key_group "reused_keys" "${reused_keys[@]}"
|
||||
log_env_key_group "ignored_removed_old_keys" "${ignored_removed_keys[@]}"
|
||||
log_env_key_group "preserved_package_keys" "${preserved_package_keys[@]}"
|
||||
}
|
||||
|
||||
apply_invocation_env_overrides() {
|
||||
local target_env="$1"
|
||||
local key
|
||||
local -a overridden_keys=()
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] || continue
|
||||
env_file_set_value "$target_env" "$key" "${INVOCATION_ENV_OVERRIDES[$key]}"
|
||||
overridden_keys+=("$key")
|
||||
done
|
||||
|
||||
log_env_key_group "command_override_keys" "${overridden_keys[@]}"
|
||||
}
|
||||
|
||||
log_template_only_keys() {
|
||||
local key
|
||||
local -a template_only_keys=()
|
||||
|
||||
for key in "${ENV_TEMPLATE_KEYS[@]}"; do
|
||||
[[ -n "${REUSED_ENV_KEYS[$key]+x}" ]] && continue
|
||||
[[ -n "${INVOCATION_ENV_OVERRIDES[$key]+x}" ]] && continue
|
||||
template_only_keys+=("$key")
|
||||
done
|
||||
log_env_key_group "template_only_keys" "${template_only_keys[@]}"
|
||||
}
|
||||
|
||||
load_env_template_keys "$ENV_EXAMPLE"
|
||||
validate_env_file_contract "$ENV_EXAMPLE"
|
||||
capture_invocation_env_overrides
|
||||
REUSED_ENV_KEYS=()
|
||||
|
||||
if [[ -n "$REUSE_ENV_FROM" ]]; then
|
||||
[[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
overlay_reused_env "$REUSE_ENV_FROM" "$ENV_FILE"
|
||||
log "created new package env from .env.example and overlaid user settings from $REUSE_ENV_FROM"
|
||||
@@ -97,6 +123,13 @@ if [[ -n "$REUSE_ENV_FROM" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
log "created new package env from .env.example"
|
||||
fi
|
||||
apply_invocation_env_overrides "$ENV_FILE"
|
||||
log_template_only_keys
|
||||
validate_env_file_contract "$ENV_FILE"
|
||||
load_env
|
||||
assert_arch_compatibility
|
||||
create_data_dirs
|
||||
|
||||
206
deploy/docker-installer/tests/upgrade_env_contract_test.sh
Normal file
206
deploy/docker-installer/tests/upgrade_env_contract_test.sh
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALLER_SOURCE="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ours-rp-installer-env-contract.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEST_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_equals() {
|
||||
local expected="$1"
|
||||
local actual="$2"
|
||||
local message="$3"
|
||||
[[ "$actual" == "$expected" ]] || fail "$message (expected=$expected actual=$actual)"
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local needle="$1"
|
||||
local path="$2"
|
||||
! grep -Fq "$needle" "$path" || fail "unexpected value in $path: $needle"
|
||||
}
|
||||
|
||||
expect_exit() {
|
||||
local expected_status="$1"
|
||||
local output_path="$2"
|
||||
shift 2
|
||||
local actual_status
|
||||
|
||||
set +e
|
||||
"$@" >"$output_path" 2>&1
|
||||
actual_status=$?
|
||||
set -e
|
||||
[[ "$actual_status" == "$expected_status" ]] || {
|
||||
cat "$output_path" >&2 || true
|
||||
fail "unexpected exit status (expected=$expected_status actual=$actual_status)"
|
||||
}
|
||||
}
|
||||
|
||||
host_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) printf 'amd64\n' ;;
|
||||
aarch64) printf 'arm64\n' ;;
|
||||
*) fail "unsupported test host architecture: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local env_path="$1"
|
||||
local key="$2"
|
||||
(
|
||||
# shellcheck disable=SC1090
|
||||
source "$INSTALLER_SOURCE/scripts/common.sh"
|
||||
env_file_value "$env_path" "$key"
|
||||
)
|
||||
}
|
||||
|
||||
create_fixture() {
|
||||
local name="$1"
|
||||
local fixture="$TEST_ROOT/$name"
|
||||
local arch
|
||||
arch="$(host_arch)"
|
||||
|
||||
mkdir -p "$fixture"
|
||||
cp -a "$INSTALLER_SOURCE"/. "$fixture"/
|
||||
rm -rf "$fixture/tests"
|
||||
mkdir -p "$fixture/images" "$fixture/fake-bin"
|
||||
sed -i \
|
||||
-e "s|__PACKAGE_ARCH__|$arch|g" \
|
||||
-e "s|__PACKAGE_PLATFORM__|linux/$arch|g" \
|
||||
-e "s|__RUNTIME_IMAGE__|ours-rp-runtime-$arch:new|g" \
|
||||
-e "s|__METRICS_IMAGE__|ours-rp-metrics-$arch:new|g" \
|
||||
-e "s|__HOST_DATA_DIR__|$fixture/data|g" \
|
||||
"$fixture/.env.example"
|
||||
cat > "$fixture/PACKAGE-MANIFEST.env" <<EOF
|
||||
PACKAGE_ARCH=$arch
|
||||
PACKAGE_PLATFORM=linux/$arch
|
||||
EOF
|
||||
cat > "$fixture/fake-bin/docker" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
|
||||
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 99
|
||||
EOF
|
||||
chmod +x "$fixture/fake-bin/docker"
|
||||
for command in jq rsync curl gzip tar; do
|
||||
cat > "$fixture/fake-bin/$command" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$fixture/fake-bin/$command"
|
||||
done
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
run_upgrade() {
|
||||
local fixture="$1"
|
||||
local old_env="$2"
|
||||
shift 2
|
||||
(
|
||||
cd "$fixture"
|
||||
export PATH="$fixture/fake-bin:$PATH"
|
||||
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
|
||||
"$@" ./scripts/upgrade.sh --reuse-env-from "$old_env"
|
||||
)
|
||||
}
|
||||
|
||||
run_upgrade_without_reuse() {
|
||||
local fixture="$1"
|
||||
(
|
||||
cd "$fixture"
|
||||
export PATH="$fixture/fake-bin:$PATH"
|
||||
export FAKE_DOCKER_LOG="$fixture/fake-docker.log"
|
||||
./scripts/upgrade.sh
|
||||
)
|
||||
}
|
||||
|
||||
new_fixture_with_old_env() {
|
||||
local name="$1"
|
||||
local fixture
|
||||
fixture="$(create_fixture "$name")"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
RPKI_IMAGE=ours-rp-runtime-old:old
|
||||
REMOVED_LEGACY_KEY=legacy-value
|
||||
EOF
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
fixture="$(create_fixture template-defaults)"
|
||||
expect_exit 99 "$fixture/template.log" run_upgrade_without_reuse "$fixture"
|
||||
assert_equals "600" "$(env_value "$fixture/.env" INTERVAL_SECS)" "template default must be retained without reuse"
|
||||
|
||||
fixture="$(new_fixture_with_old_env default-old)"
|
||||
expect_exit 99 "$fixture/default.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
assert_equals "120" "$(env_value "$fixture/.env" INTERVAL_SECS)" "old value must override template default"
|
||||
assert_equals "ours-rp-runtime-$(host_arch):new" "$(env_value "$fixture/.env" RPKI_IMAGE)" "standard upgrade must retain package image"
|
||||
assert_equals "10" "$(env_value "$fixture/.env" METRICS_POLL_SECS)" "template default must fill a key absent from old environment"
|
||||
! grep -q '^REMOVED_LEGACY_KEY=' "$fixture/.env" || fail "removed old key was copied into target environment"
|
||||
grep -q 'reused_keys=INTERVAL_SECS' "$fixture/default.log" || fail "reused key summary missing"
|
||||
grep -q 'ignored_removed_old_keys=REMOVED_LEGACY_KEY' "$fixture/default.log" || fail "removed key summary missing"
|
||||
grep -q 'template_only_keys=' "$fixture/default.log" || fail "template key summary missing"
|
||||
|
||||
fixture="$(new_fixture_with_old_env invocation-overrides)"
|
||||
expect_exit 99 "$fixture/override.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300 GRAFANA_ADMIN_PASSWORD='secret with space'
|
||||
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override old value"
|
||||
assert_equals "secret with space" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value must preserve shell-sensitive content"
|
||||
grep -q 'command_override_keys=INTERVAL_SECS,GRAFANA_ADMIN_PASSWORD' "$fixture/override.log" || fail "override key summary missing"
|
||||
assert_not_contains 'secret with space' "$fixture/override.log"
|
||||
|
||||
fixture="$(new_fixture_with_old_env invocation-special-characters)"
|
||||
special_password="O'Reilly"
|
||||
special_password+=' $literal `tick` slash\'
|
||||
expect_exit 99 "$fixture/special.log" run_upgrade "$fixture" "$fixture/old.env" env GRAFANA_ADMIN_PASSWORD="$special_password"
|
||||
assert_equals "$special_password" "$(env_value "$fixture/.env" GRAFANA_ADMIN_PASSWORD)" "invocation value with dotenv-sensitive characters must round-trip"
|
||||
assert_not_contains "$special_password" "$fixture/special.log"
|
||||
|
||||
fixture="$(create_fixture existing-target-env)"
|
||||
cp "$fixture/.env.example" "$fixture/.env"
|
||||
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=180/' "$fixture/.env"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
EOF
|
||||
expect_exit 99 "$fixture/existing.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=300
|
||||
assert_equals "300" "$(env_value "$fixture/.env" INTERVAL_SECS)" "invocation value must override an existing target environment"
|
||||
|
||||
fixture="$(new_fixture_with_old_env explicit-empty)"
|
||||
expect_exit 1 "$fixture/empty.log" run_upgrade "$fixture" "$fixture/old.env" env INTERVAL_SECS=
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "explicit empty value reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/empty.log" || fail "empty-value error did not identify key"
|
||||
|
||||
fixture="$(create_fixture empty-old-value)"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=
|
||||
EOF
|
||||
expect_exit 1 "$fixture/old-empty.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty reused value reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/old-empty.log" || fail "empty reused value error did not identify key"
|
||||
|
||||
fixture="$(create_fixture empty-template)"
|
||||
sed -i 's/^INTERVAL_SECS=.*/INTERVAL_SECS=/' "$fixture/.env.example"
|
||||
cat > "$fixture/old.env" <<'EOF'
|
||||
INTERVAL_SECS=120
|
||||
EOF
|
||||
expect_exit 1 "$fixture/template-empty.log" run_upgrade "$fixture" "$fixture/old.env"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "empty template default reached Docker"
|
||||
grep -q 'INTERVAL_SECS' "$fixture/template-empty.log" || fail "empty template error did not identify key"
|
||||
|
||||
fixture="$(new_fixture_with_old_env package-guard)"
|
||||
arch="$(host_arch)"
|
||||
other_arch="amd64"
|
||||
[[ "$arch" == "amd64" ]] && other_arch="arm64"
|
||||
expect_exit 1 "$fixture/arch.log" run_upgrade "$fixture" "$fixture/old.env" env PACKAGE_ARCH="$other_arch"
|
||||
[[ ! -s "$fixture/fake-docker.log" ]] || fail "package architecture mismatch reached Docker"
|
||||
grep -q 'mismatches manifest PACKAGE_ARCH' "$fixture/arch.log" || fail "package architecture guard did not reject override"
|
||||
|
||||
printf 'PASS: Docker installer upgrade environment contract\n'
|
||||
@@ -85,27 +85,66 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
mkdir -p /build-out/bin; \
|
||||
cp "target/$target_triple/release/rpki_artifact_metrics" /build-out/bin/
|
||||
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS rpki-client-builder
|
||||
|
||||
ARG RPKI_CLIENT_VERSION=9.8
|
||||
ARG RPKI_CLIENT_SHA256=42920aac5afd0996173fb9f7848691a2c49dd234e4f10478808c1aa475620861
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libexpat1-dev \
|
||||
libssl-dev \
|
||||
libtls-dev \
|
||||
pkg-config \
|
||||
rsync \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client
|
||||
|
||||
RUN set -eux; \
|
||||
curl -fsSL "https://ftp.openbsd.org/pub/OpenBSD/rpki-client/rpki-client-${RPKI_CLIENT_VERSION}.tar.gz" -o /tmp/rpki-client.tar.gz; \
|
||||
echo "${RPKI_CLIENT_SHA256} /tmp/rpki-client.tar.gz" | sha256sum -c -; \
|
||||
tar -xzf /tmp/rpki-client.tar.gz -C /tmp; \
|
||||
cd "/tmp/rpki-client-${RPKI_CLIENT_VERSION}"; \
|
||||
./configure --prefix=/opt/rpki-client; \
|
||||
make -j"$(nproc)"; \
|
||||
make install; \
|
||||
/opt/rpki-client/sbin/rpki-client -V
|
||||
|
||||
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libexpat1 \
|
||||
libssl3 \
|
||||
libtls26 \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin _rpki-client \
|
||||
&& mkdir -p /opt/rpki-client/var/cache/rpki-client
|
||||
|
||||
WORKDIR /opt/ours-rp
|
||||
|
||||
COPY --from=builder /build-out/bin/ /opt/ours-rp/bin/
|
||||
COPY --from=rpki-client-builder /opt/rpki-client/ /opt/rpki-client/
|
||||
COPY --from=rpki-client-builder /opt/rpki-client/sbin/rpki-client /opt/ours-rp/bin/rpki-client
|
||||
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
ARG SOURCE_DIRTY=unknown
|
||||
ARG BUILD_TIMESTAMP_UTC=unknown
|
||||
ARG RPKI_CLIENT_VERSION=9.8
|
||||
|
||||
LABEL org.opencontainers.image.title="ours-rp-metrics" \
|
||||
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
|
||||
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
|
||||
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}" \
|
||||
org.opencontainers.image.rpki-client.version="${RPKI_CLIENT_VERSION}"
|
||||
|
||||
RUN chmod +x /opt/ours-rp/bin/* \
|
||||
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs
|
||||
|
||||
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 ))
|
||||
@@ -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,10 +922,31 @@ 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")
|
||||
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)
|
||||
@@ -571,6 +964,9 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
|
||||
if code != 0:
|
||||
exit_code = code
|
||||
break
|
||||
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)
|
||||
@@ -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,7 +82,7 @@ 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"
|
||||
@@ -92,6 +92,8 @@ install -m 0755 "$SCRIPT_DIR/fixed_phase_loop.sh" "$STAGE_DIR/scripts/soak/fixed
|
||||
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"
|
||||
|
||||
|
||||
@@ -48,6 +48,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}"
|
||||
@@ -1080,6 +1081,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 +1111,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"
|
||||
@@ -1304,6 +1359,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 +1388,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"
|
||||
@@ -1360,7 +1422,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 +1448,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 +1537,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
|
||||
|
||||
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-compare.json`、`compare/ccr-state-compare.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,按不匹配状态集合继续排查。
|
||||
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"
|
||||
@@ -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, ValidationExplainRecord,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -544,6 +546,18 @@ fn route_request(
|
||||
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,6 +599,22 @@ fn route_request(
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["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.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)?;
|
||||
page_response(
|
||||
@@ -630,8 +660,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 +706,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 +874,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)?
|
||||
@@ -1150,6 +1244,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 +1687,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>,
|
||||
@@ -1878,6 +2087,93 @@ 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 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)
|
||||
}
|
||||
@@ -2223,6 +2519,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",
|
||||
|
||||
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,7 @@ pub struct ExternalRawStoreDb {
|
||||
pub struct ExternalRepoBytesDb {
|
||||
path: PathBuf,
|
||||
db: Arc<DB>,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
impl ExternalRawStoreDb {
|
||||
@@ -189,6 +190,7 @@ impl ExternalRepoBytesDb {
|
||||
Ok(Self {
|
||||
path,
|
||||
db: Arc::new(db),
|
||||
read_only: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,6 +203,7 @@ impl ExternalRepoBytesDb {
|
||||
Ok(Self {
|
||||
path,
|
||||
db: Arc::new(db),
|
||||
read_only: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,6 +211,12 @@ impl ExternalRepoBytesDb {
|
||||
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 +230,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);
|
||||
|
||||
@@ -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,7 +402,7 @@ 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,
|
||||
@@ -429,55 +450,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 +484,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]);
|
||||
}
|
||||
}
|
||||
|
||||
121
src/ccr/build.rs
121
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,7 +725,7 @@ 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,
|
||||
@@ -766,10 +755,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,7 +263,7 @@ 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,
|
||||
|
||||
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")]
|
||||
|
||||
338
src/cli.rs
338
src/cli.rs
@@ -38,7 +38,7 @@ use crate::validation::run_tree_from_tal::{
|
||||
run_tree_from_tal_url_parallel_phase2_audit,
|
||||
run_tree_from_tal_url_parallel_phase2_audit_with_timing,
|
||||
};
|
||||
use crate::validation::tree::TreeRunConfig;
|
||||
use crate::validation::tree::{DEFAULT_MAX_CA_DEPTH, TreeRunConfig};
|
||||
#[cfg(test)]
|
||||
use output::write_json;
|
||||
use output::{
|
||||
@@ -115,6 +115,12 @@ fn vcir_storage_summary_enabled() -> bool {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CliArgs {
|
||||
pub verification_only: bool,
|
||||
pub verification_source_run_dir: Option<PathBuf>,
|
||||
pub verification_source_state_root: Option<PathBuf>,
|
||||
pub verification_out_dir: Option<PathBuf>,
|
||||
pub keep_verification_scratch: bool,
|
||||
pub validation_contract_out_path: Option<PathBuf>,
|
||||
pub tal_urls: Vec<String>,
|
||||
pub tal_paths: Vec<PathBuf>,
|
||||
pub ta_paths: Vec<PathBuf>,
|
||||
@@ -168,7 +174,7 @@ pub struct CliArgs {
|
||||
pub rsync_mirror_root: Option<PathBuf>,
|
||||
pub rsync_scope_policy: RsyncScopePolicy,
|
||||
|
||||
pub max_depth: Option<usize>,
|
||||
pub max_ca_depth: usize,
|
||||
pub max_instances: Option<usize>,
|
||||
pub validation_time: Option<time::OffsetDateTime>,
|
||||
|
||||
@@ -184,8 +190,19 @@ fn usage() -> String {
|
||||
Usage:
|
||||
{bin} --db <path> --tal-url <url> [--tal-url <url> ...] [options]
|
||||
{bin} --db <path> --tal-path <path> --ta-path <path> [--tal-path <path> --ta-path <path> ...] [options]
|
||||
{bin} --verification-only --verification-source-run <path> --verification-source-state <path> --verification-out <path> [options]
|
||||
|
||||
Options:
|
||||
--verification-only Revalidate the latest successful run without validation caches or network access
|
||||
--verification-source-run <path>
|
||||
Source normal run directory containing input.cir/result.ccr/validation-contract.json
|
||||
--verification-source-state <path>
|
||||
Source state root containing db/work-db and db/repo-bytes.db
|
||||
--verification-out <path> Independent output directory for the one-shot verification run
|
||||
--keep-verification-scratch
|
||||
Keep the private work-db checkpoint after a successful verification
|
||||
--validation-contract-out <path>
|
||||
Write the effective normal-run validation contract (not valid in verification-only mode)
|
||||
--db <path> RocksDB directory path (required)
|
||||
--raw-store-db <path> External raw-by-hash store DB path (optional)
|
||||
--repo-bytes-db <path> External repo object bytes DB path (optional)
|
||||
@@ -259,7 +276,8 @@ Options:
|
||||
--rsync-timeout-secs <n> rsync I/O timeout seconds (default: 60)
|
||||
--rsync-mirror-root <path> Persist rsync mirrors under this directory (default: disabled)
|
||||
--rsync-scope <policy> rsync scope policy: host, publication-point, or module-root (default: module-root)
|
||||
--max-depth <n> Max CA instance depth (0 = root only)
|
||||
--max-ca-depth <n> Maximum CA depth from a trust anchor (root = 0, default: {DEFAULT_MAX_CA_DEPTH})
|
||||
--max-depth <n> Deprecated alias for --max-ca-depth
|
||||
--max-instances <n> Max number of CA instances to process
|
||||
--validation-time <rfc3339> Validation time in RFC3339 (default: now UTC)
|
||||
--analyze Write timing analysis JSON under target/live/analyze/<timestamp>/
|
||||
@@ -272,6 +290,12 @@ Options:
|
||||
}
|
||||
|
||||
pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
let mut verification_only = false;
|
||||
let mut verification_source_run_dir: Option<PathBuf> = None;
|
||||
let mut verification_source_state_root: Option<PathBuf> = None;
|
||||
let mut verification_out_dir: Option<PathBuf> = None;
|
||||
let mut keep_verification_scratch = false;
|
||||
let mut validation_contract_out_path: Option<PathBuf> = None;
|
||||
let mut tal_urls: Vec<String> = Vec::new();
|
||||
let mut tal_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut ta_paths: Vec<PathBuf> = Vec::new();
|
||||
@@ -319,7 +343,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
let mut rsync_timeout_secs: u64 = 30;
|
||||
let mut rsync_mirror_root: Option<PathBuf> = None;
|
||||
let mut rsync_scope_policy = RsyncScopePolicy::default();
|
||||
let mut max_depth: Option<usize> = None;
|
||||
let mut max_ca_depth: Option<usize> = None;
|
||||
let mut max_ca_depth_option: Option<&'static str> = None;
|
||||
let mut max_instances: Option<usize> = None;
|
||||
let mut validation_time: Option<time::OffsetDateTime> = None;
|
||||
let mut analyze: bool = false;
|
||||
@@ -331,6 +356,39 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
let arg = argv[i].as_str();
|
||||
match arg {
|
||||
"--help" | "-h" => return Err(usage()),
|
||||
"--verification-only" => {
|
||||
verification_only = true;
|
||||
}
|
||||
"--verification-source-run" => {
|
||||
i += 1;
|
||||
verification_source_run_dir = Some(PathBuf::from(
|
||||
argv.get(i)
|
||||
.ok_or("--verification-source-run requires a value")?,
|
||||
));
|
||||
}
|
||||
"--verification-source-state" => {
|
||||
i += 1;
|
||||
verification_source_state_root = Some(PathBuf::from(
|
||||
argv.get(i)
|
||||
.ok_or("--verification-source-state requires a value")?,
|
||||
));
|
||||
}
|
||||
"--verification-out" => {
|
||||
i += 1;
|
||||
verification_out_dir = Some(PathBuf::from(
|
||||
argv.get(i).ok_or("--verification-out requires a value")?,
|
||||
));
|
||||
}
|
||||
"--keep-verification-scratch" => {
|
||||
keep_verification_scratch = true;
|
||||
}
|
||||
"--validation-contract-out" => {
|
||||
i += 1;
|
||||
validation_contract_out_path = Some(PathBuf::from(
|
||||
argv.get(i)
|
||||
.ok_or("--validation-contract-out requires a value")?,
|
||||
));
|
||||
}
|
||||
"--tal-url" => {
|
||||
i += 1;
|
||||
let v = argv.get(i).ok_or("--tal-url requires a value")?;
|
||||
@@ -661,13 +719,26 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
let v = argv.get(i).ok_or("--rsync-scope requires a value")?;
|
||||
rsync_scope_policy = RsyncScopePolicy::parse_cli_value(v)?;
|
||||
}
|
||||
"--max-depth" => {
|
||||
"--max-ca-depth" | "--max-depth" => {
|
||||
i += 1;
|
||||
let v = argv.get(i).ok_or("--max-depth requires a value")?;
|
||||
max_depth = Some(
|
||||
let option = arg;
|
||||
let v = argv
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("{option} requires a value"))?;
|
||||
if let Some(previous_option) = max_ca_depth_option {
|
||||
return Err(format!(
|
||||
"{option} cannot be combined with {previous_option}; use only --max-ca-depth"
|
||||
));
|
||||
}
|
||||
max_ca_depth = Some(
|
||||
v.parse::<usize>()
|
||||
.map_err(|_| format!("invalid --max-depth: {v}"))?,
|
||||
.map_err(|_| format!("invalid {option}: {v}"))?,
|
||||
);
|
||||
max_ca_depth_option = Some(match option {
|
||||
"--max-ca-depth" => "--max-ca-depth",
|
||||
"--max-depth" => "--max-depth",
|
||||
_ => unreachable!("matched CA depth option"),
|
||||
});
|
||||
}
|
||||
"--max-instances" => {
|
||||
i += 1;
|
||||
@@ -702,10 +773,91 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if verification_only {
|
||||
if verification_source_run_dir.is_none() {
|
||||
return Err(format!(
|
||||
"--verification-only requires --verification-source-run\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if verification_source_state_root.is_none() {
|
||||
return Err(format!(
|
||||
"--verification-only requires --verification-source-state\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if verification_out_dir.is_none() {
|
||||
return Err(format!(
|
||||
"--verification-only requires --verification-out\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if db_path.is_some()
|
||||
|| raw_store_db.is_some()
|
||||
|| repo_bytes_db.is_some()
|
||||
|| !tal_urls.is_empty()
|
||||
|| !tal_paths.is_empty()
|
||||
|| !ta_paths.is_empty()
|
||||
|| policy_path.is_some()
|
||||
|| strict_policy.is_some()
|
||||
|| resource_validation_mode.is_some()
|
||||
|| validation_time.is_some()
|
||||
|| max_ca_depth_option.is_some()
|
||||
|| max_instances.is_some()
|
||||
|| enable_roa_validation_cache
|
||||
|| enable_child_certificate_validation_cache
|
||||
|| publication_point_cache_observe_only
|
||||
|| enable_publication_point_validation_cache
|
||||
|| enable_transport_request_prefetch
|
||||
|| rsync_local_dir.is_some()
|
||||
|| rsync_command.is_some()
|
||||
|| !http_root_cert_paths.is_empty()
|
||||
|| payload_replay_archive.is_some()
|
||||
|| payload_replay_locks.is_some()
|
||||
|| payload_base_archive.is_some()
|
||||
|| payload_base_locks.is_some()
|
||||
|| payload_delta_archive.is_some()
|
||||
|| payload_delta_locks.is_some()
|
||||
|| validation_contract_out_path.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"verification-only derives DB, TAL, validation time, policy and cache settings from the source run; conflicting normal-run options are not allowed\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if report_json_path.is_some()
|
||||
|| ccr_out_path.is_some()
|
||||
|| vrps_csv_out_path.is_some()
|
||||
|| vaps_csv_out_path.is_some()
|
||||
|| cir_enabled
|
||||
|| cir_out_path.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"verification-only output paths are derived from --verification-out\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
db_path = Some(
|
||||
verification_out_dir
|
||||
.as_ref()
|
||||
.expect("checked verification out")
|
||||
.join("scratch/work-db"),
|
||||
);
|
||||
} else if verification_source_run_dir.is_some()
|
||||
|| verification_source_state_root.is_some()
|
||||
|| verification_out_dir.is_some()
|
||||
|| keep_verification_scratch
|
||||
{
|
||||
return Err(format!(
|
||||
"verification source/output options require --verification-only\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
|
||||
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
|
||||
|
||||
let tal_mode_count = (!tal_urls.is_empty()) as u8 + (!tal_paths.is_empty()) as u8;
|
||||
if tal_mode_count != 1 {
|
||||
if !verification_only && tal_mode_count != 1 {
|
||||
return Err(format!(
|
||||
"must specify either one-or-more --tal-url or one-or-more --tal-path/--ta-path pairs\n\n{}",
|
||||
usage()
|
||||
@@ -759,13 +911,13 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if !tal_urls.is_empty() && !ta_paths.is_empty() {
|
||||
if !verification_only && !tal_urls.is_empty() && !ta_paths.is_empty() {
|
||||
return Err(format!(
|
||||
"--ta-path cannot be used with --tal-url mode\n\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
if !tal_paths.is_empty() {
|
||||
if !verification_only && !tal_paths.is_empty() {
|
||||
if !ta_paths.is_empty() {
|
||||
if ta_paths.len() != tal_paths.len() {
|
||||
return Err(format!(
|
||||
@@ -940,6 +1092,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
}
|
||||
|
||||
Ok(CliArgs {
|
||||
verification_only,
|
||||
verification_source_run_dir,
|
||||
verification_source_state_root,
|
||||
verification_out_dir,
|
||||
keep_verification_scratch,
|
||||
validation_contract_out_path,
|
||||
tal_urls,
|
||||
tal_paths,
|
||||
ta_paths,
|
||||
@@ -989,7 +1147,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
|
||||
rsync_timeout_secs,
|
||||
rsync_mirror_root,
|
||||
rsync_scope_policy,
|
||||
max_depth,
|
||||
max_ca_depth: max_ca_depth.unwrap_or(DEFAULT_MAX_CA_DEPTH),
|
||||
max_instances,
|
||||
validation_time,
|
||||
analyze,
|
||||
@@ -1854,7 +2012,7 @@ where
|
||||
H: crate::sync::rrdp::Fetcher + Clone + 'static,
|
||||
R: crate::fetch::rsync::RsyncFetcher + Clone + 'static,
|
||||
{
|
||||
if args.tal_inputs.len() > 1 {
|
||||
if args.verification_only || args.tal_inputs.len() > 1 {
|
||||
return if let Some(t) = timing {
|
||||
run_tree_from_multiple_tals_parallel_phase2_audit_with_timing(
|
||||
store,
|
||||
@@ -1995,9 +2153,54 @@ where
|
||||
}
|
||||
|
||||
pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
let args = parse_args(argv)?;
|
||||
let mut args = parse_args(argv)?;
|
||||
let mut prepared_verification = if args.verification_only {
|
||||
let prepared = crate::verification_only::prepare_verification(
|
||||
args.verification_source_run_dir
|
||||
.as_deref()
|
||||
.expect("validated by parse_args"),
|
||||
args.verification_source_state_root
|
||||
.as_deref()
|
||||
.expect("validated by parse_args"),
|
||||
args.verification_out_dir
|
||||
.as_deref()
|
||||
.expect("validated by parse_args"),
|
||||
)?;
|
||||
args.db_path = prepared.artifacts.scratch_work_db.clone();
|
||||
args.repo_bytes_db = Some(prepared.artifacts.source_repo_bytes_db.clone());
|
||||
args.report_json_path = Some(prepared.artifacts.report_json.clone());
|
||||
args.report_json_compact = true;
|
||||
args.skip_report_build = false;
|
||||
args.skip_vcir_persist = true;
|
||||
args.enable_roa_validation_cache = false;
|
||||
args.enable_child_certificate_validation_cache = false;
|
||||
args.publication_point_cache_observe_only = false;
|
||||
args.enable_publication_point_validation_cache = false;
|
||||
args.enable_transport_request_prefetch = false;
|
||||
args.ccr_out_path = Some(prepared.artifacts.result_ccr.clone());
|
||||
args.vrps_csv_out_path = Some(prepared.artifacts.vrps_csv.clone());
|
||||
args.vaps_csv_out_path = Some(prepared.artifacts.vaps_csv.clone());
|
||||
args.compare_view_trust_anchor = Some("verification-only".to_string());
|
||||
args.cir_enabled = true;
|
||||
args.cir_out_path = Some(prepared.artifacts.result_cir.clone());
|
||||
args.cir_tal_uris = crate::verification_only::cir_tal_uris(&prepared.cir);
|
||||
args.tal_inputs = crate::verification_only::tal_inputs_from_cir(&prepared.cir);
|
||||
args.validation_time = Some(prepared.cir.validation_time);
|
||||
args.max_ca_depth = prepared.contract.max_ca_depth;
|
||||
args.max_instances = prepared.contract.max_instances;
|
||||
args.disable_rrdp = true;
|
||||
args.rsync_local_dir = None;
|
||||
Some(prepared)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut policy = read_policy(args.policy_path.as_deref())?;
|
||||
let mut policy = if let Some(prepared) = prepared_verification.as_ref() {
|
||||
prepared.contract.policy.clone()
|
||||
} else {
|
||||
read_policy(args.policy_path.as_deref())?
|
||||
};
|
||||
if !args.verification_only {
|
||||
if let Some(strict_policy) = args.strict_policy {
|
||||
policy.strict = strict_policy;
|
||||
}
|
||||
@@ -2007,9 +2210,31 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
if args.disable_rrdp {
|
||||
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
|
||||
}
|
||||
} else {
|
||||
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
|
||||
policy.ca_failed_fetch_policy = crate::policy::CaFailedFetchPolicy::StopAllOutput;
|
||||
}
|
||||
let validation_time = args
|
||||
.validation_time
|
||||
.unwrap_or_else(time::OffsetDateTime::now_utc);
|
||||
let validation_time =
|
||||
time::OffsetDateTime::from_unix_timestamp(validation_time.unix_timestamp())
|
||||
.map_err(|error| format!("normalize validation time failed: {error}"))?;
|
||||
if let Some(path) = args.validation_contract_out_path.as_deref() {
|
||||
let contract = crate::verification_only::ValidationContract::for_current_binary(
|
||||
validation_time,
|
||||
policy.clone(),
|
||||
args.max_ca_depth,
|
||||
args.max_instances,
|
||||
crate::verification_only::ValidationCacheContract {
|
||||
publication_point: args.enable_publication_point_validation_cache,
|
||||
roa: args.enable_roa_validation_cache,
|
||||
child_certificate: args.enable_child_certificate_validation_cache,
|
||||
transport_prefetch: args.enable_transport_request_prefetch,
|
||||
},
|
||||
)?;
|
||||
crate::verification_only::write_validation_contract(path, &contract)?;
|
||||
}
|
||||
let http_root_certificates_pem = args
|
||||
.http_root_cert_paths
|
||||
.iter()
|
||||
@@ -2019,7 +2244,12 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let store = if args.raw_store_db.is_some() || args.repo_bytes_db.is_some() {
|
||||
let store = if let Some(prepared) = prepared_verification.as_mut() {
|
||||
prepared
|
||||
.store
|
||||
.take()
|
||||
.expect("prepared verification store must be available")
|
||||
} else if args.raw_store_db.is_some() || args.repo_bytes_db.is_some() {
|
||||
Arc::new(
|
||||
RocksStore::open_with_external_stores(
|
||||
&args.db_path,
|
||||
@@ -2032,7 +2262,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
Arc::new(RocksStore::open(&args.db_path).map_err(|e| e.to_string())?)
|
||||
};
|
||||
let config = TreeRunConfig {
|
||||
max_depth: args.max_depth,
|
||||
max_depth: Some(args.max_ca_depth),
|
||||
max_instances: args.max_instances,
|
||||
compact_audit: args.skip_report_build
|
||||
&& args.report_json_path.is_none()
|
||||
@@ -2122,7 +2352,23 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
);
|
||||
let validation_started = std::time::Instant::now();
|
||||
let collect_current_repo_objects = false;
|
||||
let out = if delta_replay_mode {
|
||||
let out = if args.verification_only {
|
||||
let http = crate::verification_only::NetworkDisabledHttpFetcher;
|
||||
let rsync = crate::fetch::current_repository::CurrentRepositoryViewRsyncFetcher::new(
|
||||
Arc::clone(&store),
|
||||
);
|
||||
run_online_validation_with_fetchers(
|
||||
Arc::clone(&store),
|
||||
&policy,
|
||||
&args,
|
||||
&http,
|
||||
&rsync,
|
||||
validation_time,
|
||||
&config,
|
||||
collect_current_repo_objects,
|
||||
timing.as_ref().map(|(_, t)| t),
|
||||
)?
|
||||
} else if delta_replay_mode {
|
||||
let tal_path = args
|
||||
.tal_path
|
||||
.as_ref()
|
||||
@@ -2651,6 +2897,62 @@ pub fn run(argv: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
print_summary_from_shared(validation_time, &shared);
|
||||
if let Some(prepared) = prepared_verification.as_ref() {
|
||||
std::fs::create_dir_all(&prepared.artifacts.compare_dir).map_err(|error| {
|
||||
format!(
|
||||
"create verification compare directory failed: {}: {error}",
|
||||
prepared.artifacts.compare_dir.display()
|
||||
)
|
||||
})?;
|
||||
let comparison = crate::verification_only::compare_ccr_files(
|
||||
&prepared.artifacts.source_ccr,
|
||||
&prepared.artifacts.result_ccr,
|
||||
)?;
|
||||
crate::verification_only::write_ccr_comparison(
|
||||
&prepared.artifacts.compare_dir.join("ccr-state-digest.json"),
|
||||
Some(&prepared.artifacts.compare_dir.join("ccr-state-digest.md")),
|
||||
&comparison,
|
||||
)?;
|
||||
let scratch_retained = !comparison.state_digest_match || args.keep_verification_scratch;
|
||||
let source_run_id = prepared
|
||||
.artifacts
|
||||
.source_run_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
crate::verification_only::write_verification_meta(
|
||||
&prepared.artifacts.verification_meta,
|
||||
&crate::verification_only::VerificationRunMeta {
|
||||
schema_version: 1,
|
||||
status: if comparison.state_digest_match {
|
||||
"success".to_string()
|
||||
} else {
|
||||
"mismatch".to_string()
|
||||
},
|
||||
source_run_id,
|
||||
validation_time: crate::verification_only::format_validation_time(validation_time)?,
|
||||
wall_ms: total_started.elapsed().as_millis() as u64,
|
||||
repository_objects_checked: prepared.blob_summary.current_objects,
|
||||
repository_bytes_checked: prepared.blob_summary.bytes_verified,
|
||||
state_digest_match: comparison.state_digest_match,
|
||||
scratch_retained,
|
||||
},
|
||||
)?;
|
||||
if !comparison.state_digest_match {
|
||||
return Err(format!(
|
||||
"verification-only CCR state digest mismatch: {}",
|
||||
comparison.mismatched_states.join(",")
|
||||
));
|
||||
}
|
||||
if !args.keep_verification_scratch {
|
||||
drop(shared);
|
||||
drop(store);
|
||||
std::fs::remove_dir_all(prepared.artifacts.output_dir.join("scratch")).map_err(
|
||||
|error| format!("remove verification scratch directory failed: {error}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
122
src/cli/tests.rs
122
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,8 @@ 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("--max-ca-depth"), "{err}");
|
||||
assert!(err.contains("default: 32"), "{err}");
|
||||
assert!(
|
||||
err.contains("--publication-point-cache-observe-only"),
|
||||
"{err}"
|
||||
@@ -47,6 +51,63 @@ 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 parse_rejects_both_tal_url_and_tal_path() {
|
||||
let argv = vec![
|
||||
@@ -80,6 +141,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![
|
||||
@@ -1103,7 +1209,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 +1724,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 +1734,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
|
||||
|
||||
178
src/fetch/current_repository.rs
Normal file
178
src/fetch/current_repository.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::fetch::rsync::{
|
||||
RsyncFetchError, RsyncFetchResult, RsyncFetcher, normalize_rsync_base_uri,
|
||||
};
|
||||
use crate::storage::{RepositoryViewState, RocksStore};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurrentRepositoryViewRsyncFetcher {
|
||||
store: Arc<RocksStore>,
|
||||
}
|
||||
|
||||
impl CurrentRepositoryViewRsyncFetcher {
|
||||
pub fn new(store: Arc<RocksStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
impl RsyncFetcher for CurrentRepositoryViewRsyncFetcher {
|
||||
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
|
||||
let base = normalize_rsync_base_uri(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 {
|
||||
normalize_rsync_base_uri(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);
|
||||
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);
|
||||
let error = fetcher
|
||||
.fetch_objects("rsync://example.test/repo/")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("blob bytes missing"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod current_repository;
|
||||
pub mod http;
|
||||
pub mod rsync;
|
||||
pub mod rsync_system;
|
||||
|
||||
@@ -42,3 +42,5 @@ pub mod sync;
|
||||
pub mod tools;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod validation;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod verification_only;
|
||||
|
||||
@@ -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,13 +145,37 @@ 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);
|
||||
let mut state = ObjectScanState::new_list(
|
||||
run_id,
|
||||
scope,
|
||||
filter.clone(),
|
||||
limit,
|
||||
start_pp_index,
|
||||
start_object_index,
|
||||
);
|
||||
ObjectScanSeed { state: &mut state }
|
||||
.deserialize(&mut deserializer)
|
||||
.map_err(QueryDbError::from)?;
|
||||
@@ -201,7 +289,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 +333,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,
|
||||
}
|
||||
@@ -530,6 +680,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 +695,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 +704,7 @@ impl<'a> ObjectScanState<'a> {
|
||||
run_id,
|
||||
scope,
|
||||
mode: ObjectScanMode::List,
|
||||
filter,
|
||||
limit,
|
||||
start_pp_index,
|
||||
start_object_index,
|
||||
@@ -568,6 +721,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,
|
||||
@@ -682,7 +836,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));
|
||||
@@ -1157,7 +1311,10 @@ mod tests {
|
||||
{"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"}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
265
src/query_db.rs
265
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";
|
||||
@@ -92,6 +95,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>,
|
||||
}
|
||||
@@ -369,6 +373,52 @@ impl QueryDb {
|
||||
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
|
||||
}
|
||||
|
||||
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 +523,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,
|
||||
@@ -628,6 +703,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 +949,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)
|
||||
@@ -943,6 +1138,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 +1211,7 @@ struct SingleRunIndexSummary {
|
||||
object_instances_indexed: u64,
|
||||
object_projections_indexed: u64,
|
||||
stats_indexed: u64,
|
||||
vrps_indexed: u64,
|
||||
latest_ready_run: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1056,6 +1253,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 +1297,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 +1337,7 @@ struct IndexWriteSummary {
|
||||
object_instances_indexed: u64,
|
||||
object_projections_indexed: u64,
|
||||
stats_indexed: u64,
|
||||
vrps_indexed: u64,
|
||||
}
|
||||
|
||||
fn write_summary_index_records(
|
||||
@@ -1150,6 +1350,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 +1388,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 +1660,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");
|
||||
|
||||
152
src/storage.rs
152
src/storage.rs
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine;
|
||||
use rocksdb::checkpoint::Checkpoint;
|
||||
use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -53,6 +54,13 @@ pub enum StorageError {
|
||||
|
||||
pub type StorageResult<T> = Result<T, StorageError>;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct RepositoryBlobVerificationSummary {
|
||||
pub current_objects: u64,
|
||||
pub bytes_verified: u64,
|
||||
pub batches: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ChildCertificateCacheMmapLookup {
|
||||
pub projections: Vec<Option<ChildCertificateCacheProjection>>,
|
||||
@@ -595,7 +603,7 @@ pub struct VcirFailedFetchReuseIdentity {
|
||||
pub ta_context_digest: [u8; 32],
|
||||
#[serde(rename = "p")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "f")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub policy_fingerprint: [u8; 32],
|
||||
@@ -1117,7 +1125,7 @@ pub struct RoaCacheObjectMeta {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RoaCacheProjectionContext {
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
pub policy_fingerprint: [u8; 32],
|
||||
pub object_meta: Vec<RoaCacheObjectMeta>,
|
||||
}
|
||||
@@ -1258,7 +1266,7 @@ pub struct RoaCacheProjection {
|
||||
pub issuer_ca_sha256_hex: Option<String>,
|
||||
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "serde_optional_bytes_32")]
|
||||
pub parent_context_digest: Option<[u8; 32]>,
|
||||
pub ca_validation_context_digest: Option<[u8; 32]>,
|
||||
#[serde(rename = "f", default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(with = "serde_optional_bytes_32")]
|
||||
pub policy_fingerprint: Option<[u8; 32]>,
|
||||
@@ -1400,7 +1408,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],
|
||||
@@ -1707,7 +1715,7 @@ pub struct PublicationPointCacheProjection {
|
||||
pub ta_context_digest: [u8; 32],
|
||||
#[serde(rename = "pc")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub parent_context_digest: [u8; 32],
|
||||
pub ca_validation_context_digest: [u8; 32],
|
||||
#[serde(rename = "pf")]
|
||||
#[serde(with = "serde_bytes_32")]
|
||||
pub validation_policy_fingerprint: [u8; 32],
|
||||
@@ -1743,7 +1751,7 @@ impl PublicationPointCacheProjection {
|
||||
ca_cert_sha256: [u8; 32],
|
||||
manifest_sha256: [u8; 32],
|
||||
ta_context_digest: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
validation_policy_fingerprint: [u8; 32],
|
||||
) -> StorageResult<Self> {
|
||||
let projection = Self {
|
||||
@@ -1756,7 +1764,7 @@ impl PublicationPointCacheProjection {
|
||||
manifest_sha256,
|
||||
tal_id: vcir.tal_id.clone(),
|
||||
ta_context_digest,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
validation_policy_fingerprint,
|
||||
instance_effective_not_before: vcir.last_successful_validation_time.clone(),
|
||||
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
|
||||
@@ -1811,7 +1819,7 @@ impl PublicationPointCacheProjection {
|
||||
tal_id: self.tal_id.clone(),
|
||||
ca_subject_name: String::from("publication-point-cache"),
|
||||
ca_ski: hex::encode(self.ca_cert_sha256),
|
||||
issuer_ski: hex::encode(self.parent_context_digest),
|
||||
issuer_ski: hex::encode(self.ca_validation_context_digest),
|
||||
last_successful_validation_time: validation_time.clone(),
|
||||
current_manifest_rsync_uri: self.manifest_rsync_uri.clone(),
|
||||
current_crl_rsync_uri: self
|
||||
@@ -2042,7 +2050,7 @@ impl RoaCacheProjection {
|
||||
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
|
||||
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest: Some(context.parent_context_digest),
|
||||
ca_validation_context_digest: Some(context.ca_validation_context_digest),
|
||||
policy_fingerprint: Some(context.policy_fingerprint),
|
||||
crl_sha256_by_uri,
|
||||
entries,
|
||||
@@ -2063,10 +2071,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(),
|
||||
});
|
||||
}
|
||||
@@ -2893,6 +2901,38 @@ fn write_publication_point_cache_projection_to_batch(
|
||||
}
|
||||
|
||||
impl RocksStore {
|
||||
pub fn create_read_only_checkpoint(source: &Path, destination: &Path) -> StorageResult<()> {
|
||||
if destination.exists() {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "work_db_checkpoint",
|
||||
detail: format!(
|
||||
"checkpoint destination already exists: {}",
|
||||
destination.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
}
|
||||
let mut options = Options::default();
|
||||
let blob_mode = work_db_blob_mode_from_env();
|
||||
let memory_profile = work_db_memory_profile_from_env();
|
||||
configure_work_db_options(&mut options, blob_mode, memory_profile);
|
||||
let db = DB::open_cf_descriptors_read_only(
|
||||
&options,
|
||||
source,
|
||||
column_family_descriptors_for_blob_mode(blob_mode),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
let checkpoint =
|
||||
Checkpoint::new(&db).map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
checkpoint
|
||||
.create_checkpoint(destination)
|
||||
.map_err(|error| StorageError::RocksDb(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn open(path: &Path) -> StorageResult<Self> {
|
||||
let mut base_opts = Options::default();
|
||||
base_opts.create_if_missing(true);
|
||||
@@ -2931,6 +2971,15 @@ impl RocksStore {
|
||||
Self::open_with_external_stores(path, None, Some(repo_bytes_path))
|
||||
}
|
||||
|
||||
pub fn open_with_external_repo_bytes_read_only(
|
||||
path: &Path,
|
||||
repo_bytes_path: &Path,
|
||||
) -> StorageResult<Self> {
|
||||
let mut store = Self::open(path)?;
|
||||
store.external_repo_bytes = Some(ExternalRepoBytesDb::open_read_only(repo_bytes_path)?);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn open_with_external_stores(
|
||||
path: &Path,
|
||||
raw_store_path: Option<&Path>,
|
||||
@@ -3147,6 +3196,53 @@ impl RocksStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn verify_current_repository_blobs(
|
||||
&self,
|
||||
batch_size: usize,
|
||||
) -> StorageResult<RepositoryBlobVerificationSummary> {
|
||||
if batch_size == 0 {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: "batch_size must be greater than zero".to_string(),
|
||||
});
|
||||
}
|
||||
let cf = self.cf(CF_REPOSITORY_VIEW)?;
|
||||
let prefix = repository_view_prefix("");
|
||||
let mode = IteratorMode::From(prefix.as_bytes(), Direction::Forward);
|
||||
let mut summary = RepositoryBlobVerificationSummary::default();
|
||||
let mut batch = Vec::with_capacity(batch_size);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item.map_err(|error| StorageError::RocksDb(error.to_string()))?;
|
||||
if !key.starts_with(prefix.as_bytes()) {
|
||||
break;
|
||||
}
|
||||
let entry = decode_cbor::<RepositoryViewEntry>(&value, "repository_view")?;
|
||||
entry.validate_internal()?;
|
||||
if !matches!(
|
||||
entry.state,
|
||||
RepositoryViewState::Present | RepositoryViewState::Replaced
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let hash = entry
|
||||
.current_hash
|
||||
.clone()
|
||||
.ok_or(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!("current hash missing for {}", entry.rsync_uri),
|
||||
})?;
|
||||
batch.push((entry.rsync_uri, hash));
|
||||
if batch.len() >= batch_size {
|
||||
verify_repository_blob_batch(self, &batch, &mut summary)?;
|
||||
batch.clear();
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
verify_repository_blob_batch(self, &batch, &mut summary)?;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
|
||||
entry.validate_internal()?;
|
||||
if let Some(raw_store) = self.external_raw_store.as_ref() {
|
||||
@@ -3206,6 +3302,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() {
|
||||
@@ -4682,6 +4781,37 @@ impl RocksStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_repository_blob_batch(
|
||||
store: &RocksStore,
|
||||
batch: &[(String, String)],
|
||||
summary: &mut RepositoryBlobVerificationSummary,
|
||||
) -> StorageResult<()> {
|
||||
let hashes = batch
|
||||
.iter()
|
||||
.map(|(_, hash)| hash.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let blobs = store.get_blob_bytes_batch(&hashes)?;
|
||||
for ((uri, expected_hash), blob) in batch.iter().zip(blobs.into_iter()) {
|
||||
let bytes = blob.as_ref().ok_or(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!("blob missing for URI {uri} (hash={expected_hash})"),
|
||||
})?;
|
||||
let actual_hash = hex::encode(compute_sha256_32(bytes));
|
||||
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
||||
return Err(StorageError::InvalidData {
|
||||
entity: "repository_blob_verification",
|
||||
detail: format!(
|
||||
"blob hash mismatch for URI {uri}: expected={expected_hash}, actual={actual_hash}"
|
||||
),
|
||||
});
|
||||
}
|
||||
summary.current_objects += 1;
|
||||
summary.bytes_verified += bytes.len() as u64;
|
||||
}
|
||||
summary.batches += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "storage/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -29,7 +29,7 @@ fn sample_child_certificate_cache_projection(
|
||||
issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"),
|
||||
issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(),
|
||||
issuer_crl_sha256_hex: sha256_hex(b"issuer-crl"),
|
||||
parent_context_digest: sha256_32(b"parent-context"),
|
||||
ca_validation_context_digest: sha256_32(b"parent-context"),
|
||||
validation_policy_fingerprint: sha256_32(b"policy"),
|
||||
effective_not_before: pack_time(0),
|
||||
effective_until: pack_time(24),
|
||||
@@ -294,7 +294,7 @@ fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCachePro
|
||||
})
|
||||
.expect("sample VCIR has a ROA output");
|
||||
RoaCacheProjectionContext {
|
||||
parent_context_digest: [0x31; 32],
|
||||
ca_validation_context_digest: [0x31; 32],
|
||||
policy_fingerprint: [0x32; 32],
|
||||
object_meta: vec![RoaCacheObjectMeta {
|
||||
source_object_uri: roa_output.source_object_uri.clone(),
|
||||
@@ -2276,3 +2276,121 @@ fn pack_file_can_lazy_load_bytes_from_external_repo_bytes_store() {
|
||||
assert_eq!(file.bytes_cloned().expect("cloned repo bytes"), bytes);
|
||||
assert_eq!(file.compute_sha256().expect("compute sha256"), file.sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_checkpoint_isolated_from_source_work_db() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let source_path = td.path().join("source-work-db");
|
||||
let checkpoint_path = td.path().join("checkpoint-work-db");
|
||||
let uri = "rsync://example.test/repo/a.roa";
|
||||
let bytes = b"checkpoint-object";
|
||||
let hash = sha256_hex(bytes);
|
||||
{
|
||||
let source = RocksStore::open(&source_path).expect("open source");
|
||||
source
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes.to_vec())])
|
||||
.expect("put blob");
|
||||
source
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: uri.to_string(),
|
||||
current_hash: Some(hash),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
})
|
||||
.expect("put view");
|
||||
}
|
||||
|
||||
RocksStore::create_read_only_checkpoint(&source_path, &checkpoint_path)
|
||||
.expect("create checkpoint");
|
||||
let checkpoint = RocksStore::open(&checkpoint_path).expect("open checkpoint");
|
||||
checkpoint
|
||||
.delete_repository_view_entry(uri)
|
||||
.expect("delete checkpoint entry");
|
||||
drop(checkpoint);
|
||||
|
||||
let source = RocksStore::open(&source_path).expect("reopen source");
|
||||
assert!(
|
||||
source
|
||||
.get_repository_view_entry(uri)
|
||||
.expect("get source entry")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_external_repo_bytes_rejects_writes() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let path = td.path().join("repo-bytes.db");
|
||||
let hash = sha256_hex(b"repo-object");
|
||||
{
|
||||
let writable = ExternalRepoBytesDb::open(&path).expect("open writable repo bytes");
|
||||
writable
|
||||
.put_blob_bytes_batch(&[(hash.clone(), b"repo-object".to_vec())])
|
||||
.expect("seed repo bytes");
|
||||
}
|
||||
let read_only = ExternalRepoBytesDb::open_read_only(&path).expect("open read-only repo bytes");
|
||||
assert_eq!(
|
||||
read_only
|
||||
.get_blob_bytes(&hash)
|
||||
.expect("read repo bytes")
|
||||
.expect("blob"),
|
||||
b"repo-object"
|
||||
);
|
||||
assert!(
|
||||
read_only
|
||||
.put_blob_bytes_batch(&[(hash, b"repo-object".to_vec())])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_store_accepts_only_idempotent_existing_blob_apply() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let work_db_path = td.path().join("work-db");
|
||||
let repo_bytes_path = td.path().join("repo-bytes.db");
|
||||
let bytes = b"repo-object".to_vec();
|
||||
let hash = sha256_hex(&bytes);
|
||||
{
|
||||
let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("open repo bytes");
|
||||
repo_bytes
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes.clone())])
|
||||
.expect("seed repo bytes");
|
||||
}
|
||||
let store =
|
||||
RocksStore::open_with_external_repo_bytes_read_only(&work_db_path, &repo_bytes_path)
|
||||
.expect("open read-only store");
|
||||
store
|
||||
.put_blob_bytes_batch(&[(hash.clone(), bytes)])
|
||||
.expect("idempotent apply");
|
||||
assert!(
|
||||
store
|
||||
.put_blob_bytes_batch(&[(hash, b"different".to_vec())])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_blob_verification_detects_missing_external_blob() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let repo_bytes_path = td.path().join("repo-bytes.db");
|
||||
ExternalRepoBytesDb::open(&repo_bytes_path).expect("create repo bytes");
|
||||
let store = RocksStore::open_with_external_repo_bytes_read_only(
|
||||
&td.path().join("work-db"),
|
||||
&repo_bytes_path,
|
||||
)
|
||||
.expect("open work db");
|
||||
store
|
||||
.put_repository_view_entry(&RepositoryViewEntry {
|
||||
rsync_uri: "rsync://example.test/repo/missing.roa".to_string(),
|
||||
current_hash: Some(sha256_hex(b"missing")),
|
||||
repository_source: Some("fixture".to_string()),
|
||||
object_type: Some("roa".to_string()),
|
||||
state: RepositoryViewState::Present,
|
||||
})
|
||||
.expect("put view");
|
||||
let error = store
|
||||
.verify_current_repository_blobs(16)
|
||||
.expect_err("missing blob must fail");
|
||||
assert!(error.to_string().contains("missing.roa"), "{error}");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -26,10 +27,13 @@ struct Args {
|
||||
once: bool,
|
||||
out_metrics: Option<PathBuf>,
|
||||
out_status: Option<PathBuf>,
|
||||
ccr_check_bin: Option<PathBuf>,
|
||||
ccr_check_timeout_secs: u64,
|
||||
ccr_check_max_per_scan: u64,
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>]"
|
||||
"Usage: rpki_artifact_metrics --run-root <path> [--listen <addr:port>] [--poll-secs <n>] [--instance <name>] [--once] [--out-metrics <path>] [--out-status <path>] [--ccr-check-bin <path>] [--ccr-check-timeout-secs <n>] [--ccr-check-max-per-scan <n>]"
|
||||
}
|
||||
|
||||
pub fn main_entry() -> Result<(), String> {
|
||||
@@ -38,8 +42,16 @@ pub fn main_entry() -> Result<(), String> {
|
||||
|
||||
fn real_main() -> Result<(), String> {
|
||||
let args = parse_args(&std::env::args().collect::<Vec<_>>())?;
|
||||
let ccr_check_config = CcrCheckConfig::from_args(&args);
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
if args.once {
|
||||
let snapshot = scan_run_root(&args.run_root, &args.instance)?;
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
&args.run_root,
|
||||
&args.instance,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
)?;
|
||||
let metrics = render_metrics(&snapshot);
|
||||
let status = render_status_json(&snapshot)?;
|
||||
if let Some(path) = args.out_metrics.as_ref() {
|
||||
@@ -53,7 +65,13 @@ fn real_main() -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let shared = Arc::new(RwLock::new(scan_run_root(&args.run_root, &args.instance)?));
|
||||
let shared = Arc::new(RwLock::new(scan_run_root_incremental_with_rtr_dir(
|
||||
&args.run_root,
|
||||
&args.instance,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
)?));
|
||||
let scanner = Arc::clone(&shared);
|
||||
let run_root = args.run_root.clone();
|
||||
let instance = args.instance.clone();
|
||||
@@ -62,8 +80,13 @@ fn real_main() -> Result<(), String> {
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(poll_secs));
|
||||
let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone();
|
||||
let next =
|
||||
match scan_run_root_incremental(&run_root, &instance, Some(&previous_snapshot)) {
|
||||
let next = match scan_run_root_incremental_with_rtr_dir(
|
||||
&run_root,
|
||||
&instance,
|
||||
Some(&previous_snapshot),
|
||||
rtr_report_dir.as_deref(),
|
||||
&ccr_check_config,
|
||||
) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(err) => {
|
||||
let mut previous = scanner.write().expect("metrics lock poisoned");
|
||||
@@ -91,6 +114,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
let mut once = false;
|
||||
let mut out_metrics = None;
|
||||
let mut out_status = None;
|
||||
let mut ccr_check_bin = None;
|
||||
let mut ccr_check_timeout_secs = 120u64;
|
||||
let mut ccr_check_max_per_scan = 2u64;
|
||||
let mut index = 1usize;
|
||||
while index < argv.len() {
|
||||
match argv[index].as_str() {
|
||||
@@ -122,6 +148,24 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
index += 1;
|
||||
out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?));
|
||||
}
|
||||
"--ccr-check-bin" => {
|
||||
index += 1;
|
||||
ccr_check_bin = Some(PathBuf::from(value_at(argv, index, "--ccr-check-bin")?));
|
||||
}
|
||||
"--ccr-check-timeout-secs" => {
|
||||
index += 1;
|
||||
let value = value_at(argv, index, "--ccr-check-timeout-secs")?;
|
||||
ccr_check_timeout_secs = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("invalid --ccr-check-timeout-secs: {value}"))?;
|
||||
}
|
||||
"--ccr-check-max-per-scan" => {
|
||||
index += 1;
|
||||
let value = value_at(argv, index, "--ccr-check-max-per-scan")?;
|
||||
ccr_check_max_per_scan = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("invalid --ccr-check-max-per-scan: {value}"))?;
|
||||
}
|
||||
"-h" | "--help" => return Err(usage().to_string()),
|
||||
other => return Err(format!("unknown argument: {other}\n{}", usage())),
|
||||
}
|
||||
@@ -135,6 +179,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
|
||||
once,
|
||||
out_metrics,
|
||||
out_status,
|
||||
ccr_check_bin,
|
||||
ccr_check_timeout_secs,
|
||||
ccr_check_max_per_scan,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,9 +208,67 @@ struct MetricsSnapshot {
|
||||
top_pp_by_sync_duration: Vec<TopPublicationPoint>,
|
||||
cir: Option<CirMetrics>,
|
||||
ccr: Option<CcrMetrics>,
|
||||
ccr_check: CcrCheckMetrics,
|
||||
rtr: RtrMetrics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CcrCheckMetrics {
|
||||
enabled: bool,
|
||||
up: bool,
|
||||
bin: Option<String>,
|
||||
pass_total: u64,
|
||||
ccr_parse_error_total: u64,
|
||||
tool_error_total: u64,
|
||||
consecutive_failures: u64,
|
||||
last: Option<CcrCheckLastResult>,
|
||||
checked_run_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CcrCheckLastResult {
|
||||
run_dir: String,
|
||||
run_seq: Option<u64>,
|
||||
outcome: String,
|
||||
duration_seconds: f64,
|
||||
checked_at_seconds: f64,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CcrCheckConfig {
|
||||
bin: Option<PathBuf>,
|
||||
timeout_secs: u64,
|
||||
max_per_scan: u64,
|
||||
}
|
||||
|
||||
impl CcrCheckConfig {
|
||||
fn disabled() -> Self {
|
||||
Self {
|
||||
bin: None,
|
||||
timeout_secs: 120,
|
||||
max_per_scan: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_args(args: &Args) -> Self {
|
||||
let bin = args.ccr_check_bin.clone().or_else(|| {
|
||||
std::env::var("RPKI_METRICS_CCR_CHECK_BIN")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
});
|
||||
Self {
|
||||
bin,
|
||||
timeout_secs: args.ccr_check_timeout_secs,
|
||||
max_per_scan: args.ccr_check_max_per_scan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServiceMetrics {
|
||||
@@ -493,21 +598,13 @@ struct RunRecord {
|
||||
}
|
||||
|
||||
fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> {
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
scan_run_root_incremental_with_rtr_dir(input_root, instance, None, rtr_report_dir.as_deref())
|
||||
}
|
||||
|
||||
fn scan_run_root_incremental(
|
||||
input_root: &Path,
|
||||
instance: &str,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
) -> Result<MetricsSnapshot, String> {
|
||||
let rtr_report_dir = configured_rtr_report_dir();
|
||||
scan_run_root_incremental_with_rtr_dir(
|
||||
input_root,
|
||||
instance,
|
||||
previous,
|
||||
None,
|
||||
rtr_report_dir.as_deref(),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,6 +613,7 @@ fn scan_run_root_incremental_with_rtr_dir(
|
||||
instance: &str,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
rtr_report_dir: Option<&Path>,
|
||||
ccr_check_config: &CcrCheckConfig,
|
||||
) -> Result<MetricsSnapshot, String> {
|
||||
let started = Instant::now();
|
||||
let runs_root = resolve_runs_root(input_root);
|
||||
@@ -570,6 +668,7 @@ fn scan_run_root_incremental_with_rtr_dir(
|
||||
}
|
||||
|
||||
snapshot.rtr = scan_rtr_reports(rtr_report_dir, previous.map(|snapshot| &snapshot.rtr));
|
||||
run_pending_ccr_checks(&records, previous, ccr_check_config, &mut snapshot);
|
||||
snapshot.service.last_scan_timestamp_seconds = unix_now_seconds();
|
||||
snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
|
||||
snapshot.service.last_reload_success =
|
||||
@@ -975,6 +1074,218 @@ fn consecutive_failures(records: &[RunRecord]) -> u64 {
|
||||
count
|
||||
}
|
||||
|
||||
const MAX_CHECKED_RUN_DIRS: usize = 512;
|
||||
|
||||
fn run_pending_ccr_checks(
|
||||
records: &[RunRecord],
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
config: &CcrCheckConfig,
|
||||
snapshot: &mut MetricsSnapshot,
|
||||
) {
|
||||
let mut check = previous
|
||||
.map(|prev| prev.ccr_check.clone())
|
||||
.unwrap_or_default();
|
||||
check.enabled = config.bin.is_some();
|
||||
check.bin = config.bin.as_ref().map(|bin| bin.display().to_string());
|
||||
check.up = false;
|
||||
|
||||
let Some(bin) = config.bin.as_ref() else {
|
||||
snapshot.ccr_check = check;
|
||||
return;
|
||||
};
|
||||
if !bin.is_file() {
|
||||
snapshot
|
||||
.service
|
||||
.parse_errors
|
||||
.push(format!("ccr check bin is not a file: {}", bin.display()));
|
||||
snapshot.ccr_check = check;
|
||||
return;
|
||||
}
|
||||
check.up = true;
|
||||
|
||||
let mut pending: Vec<&RunRecord> = records
|
||||
.iter()
|
||||
.filter(|record| record.status == "success")
|
||||
.filter(|record| record.path.join("result.ccr").is_file())
|
||||
.filter(|record| {
|
||||
let dir = record.path.display().to_string();
|
||||
!check.checked_run_dirs.iter().any(|seen| seen == &dir)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if previous.is_none() {
|
||||
// On service start only the latest existing run is checked; older
|
||||
// history is marked as seen without executing checks.
|
||||
let latest_path = pending.last().map(|record| record.path.clone());
|
||||
for record in &pending {
|
||||
if Some(&record.path) != latest_path.as_ref() {
|
||||
check
|
||||
.checked_run_dirs
|
||||
.push(record.path.display().to_string());
|
||||
}
|
||||
}
|
||||
pending.retain(|record| Some(&record.path) == latest_path.as_ref());
|
||||
}
|
||||
|
||||
for record in pending
|
||||
.into_iter()
|
||||
.take(config.max_per_scan.max(1) as usize)
|
||||
{
|
||||
let ccr_path = record.path.join("result.ccr");
|
||||
let outcome = execute_ccr_check(bin, &ccr_path, Duration::from_secs(config.timeout_secs));
|
||||
let run_seq = record
|
||||
.summary
|
||||
.as_ref()
|
||||
.and_then(|v| json_u64(v, &["runSeq"]))
|
||||
.or_else(|| {
|
||||
record
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|v| json_u64(v, &["run_index"]))
|
||||
});
|
||||
let passed = outcome.outcome == "pass";
|
||||
match outcome.outcome.as_str() {
|
||||
"pass" => check.pass_total += 1,
|
||||
"ccr_parse_error" => check.ccr_parse_error_total += 1,
|
||||
_ => check.tool_error_total += 1,
|
||||
}
|
||||
check.consecutive_failures = if passed {
|
||||
0
|
||||
} else {
|
||||
check.consecutive_failures + 1
|
||||
};
|
||||
check.last = Some(CcrCheckLastResult {
|
||||
run_dir: record.path.display().to_string(),
|
||||
run_seq,
|
||||
outcome: outcome.outcome,
|
||||
duration_seconds: outcome.duration_seconds,
|
||||
checked_at_seconds: unix_now_seconds(),
|
||||
detail: outcome.detail,
|
||||
});
|
||||
check
|
||||
.checked_run_dirs
|
||||
.push(record.path.display().to_string());
|
||||
}
|
||||
if check.checked_run_dirs.len() > MAX_CHECKED_RUN_DIRS {
|
||||
let drain = check.checked_run_dirs.len() - MAX_CHECKED_RUN_DIRS;
|
||||
check.checked_run_dirs.drain(..drain);
|
||||
}
|
||||
snapshot.ccr_check = check;
|
||||
}
|
||||
|
||||
struct CcrCheckOutcome {
|
||||
outcome: String,
|
||||
duration_seconds: f64,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
fn execute_ccr_check(bin: &Path, ccr_path: &Path, timeout: Duration) -> CcrCheckOutcome {
|
||||
let started = Instant::now();
|
||||
let log_path = std::env::temp_dir().join(format!(
|
||||
"rpki-artifact-metrics-ccr-check-{}-{}.log",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let (outcome, detail) = execute_ccr_check_logged(bin, ccr_path, timeout, &log_path);
|
||||
let _ = fs::remove_file(&log_path);
|
||||
CcrCheckOutcome {
|
||||
outcome,
|
||||
duration_seconds: started.elapsed().as_secs_f64(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_ccr_check_logged(
|
||||
bin: &Path,
|
||||
ccr_path: &Path,
|
||||
timeout: Duration,
|
||||
log_path: &Path,
|
||||
) -> (String, Option<String>) {
|
||||
let log_file = match fs::File::create(log_path) {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("create ccr check log failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let log_err = match log_file.try_clone() {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("clone ccr check log handle failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut child = match Command::new(bin)
|
||||
.arg("-f")
|
||||
.arg(ccr_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(log_file))
|
||||
.stderr(Stdio::from(log_err))
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("spawn ccr check failed: {err}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
let started = Instant::now();
|
||||
let exit_code = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status.code(),
|
||||
Ok(None) => {
|
||||
if started.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("ccr check timed out after {}s", timeout.as_secs())),
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("wait ccr check failed: {err}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = fs::read_to_string(log_path).unwrap_or_default();
|
||||
classify_ccr_check_output(exit_code, ccr_path, &output)
|
||||
}
|
||||
|
||||
fn classify_ccr_check_output(
|
||||
exit_code: Option<i32>,
|
||||
ccr_path: &Path,
|
||||
output: &str,
|
||||
) -> (String, Option<String>) {
|
||||
if exit_code != Some(0) {
|
||||
return (
|
||||
"tool_error".to_string(),
|
||||
Some(format!("rpki-client exited with code {exit_code:?}")),
|
||||
);
|
||||
}
|
||||
// rpki-client exits 0 even when the CCR file itself fails to parse, so
|
||||
// the file-level error line is the authoritative failure signal. This
|
||||
// mirrors scripts/ccr/check_rpki_client_ccr.sh.
|
||||
let marker = format!("rpki-client: {}:", ccr_path.display());
|
||||
if let Some(line) = output.lines().find(|line| line.contains(&marker)) {
|
||||
return ("ccr_parse_error".to_string(), Some(line.trim().to_string()));
|
||||
}
|
||||
("pass".to_string(), None)
|
||||
}
|
||||
|
||||
fn read_json_optional(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
@@ -1634,10 +1945,60 @@ fn render_metrics(snapshot: &MetricsSnapshot) -> String {
|
||||
if let Some(ccr) = snapshot.ccr.as_ref() {
|
||||
render_ccr_metrics(&mut writer, instance, ccr);
|
||||
}
|
||||
render_ccr_check_metrics(&mut writer, instance, &snapshot.ccr_check);
|
||||
render_rtr_metrics(&mut writer, instance, snapshot);
|
||||
out
|
||||
}
|
||||
|
||||
fn render_ccr_check_metrics(writer: &mut PromWriter<'_>, instance: &str, check: &CcrCheckMetrics) {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_up",
|
||||
"Whether the CCR rpki-client format checker binary is configured and present",
|
||||
&[label("instance", instance)],
|
||||
bool_value(check.up),
|
||||
);
|
||||
for (result, value) in [
|
||||
("pass", check.pass_total),
|
||||
("ccr_parse_error", check.ccr_parse_error_total),
|
||||
("tool_error", check.tool_error_total),
|
||||
] {
|
||||
writer.counter(
|
||||
"ours_rp_ccr_format_check_total",
|
||||
"CCR rpki-client format check results by classification",
|
||||
&[label("instance", instance), label("result", result)],
|
||||
value as f64,
|
||||
);
|
||||
}
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_consecutive_failures",
|
||||
"Consecutive failed CCR rpki-client format checks",
|
||||
&[label("instance", instance)],
|
||||
check.consecutive_failures as f64,
|
||||
);
|
||||
if let Some(last) = check.last.as_ref() {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_success",
|
||||
"Whether the latest CCR rpki-client format check passed",
|
||||
&[label("instance", instance)],
|
||||
bool_value(last.outcome == "pass"),
|
||||
);
|
||||
if let Some(run_seq) = last.run_seq {
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_run_sequence",
|
||||
"Run sequence of the latest checked CCR artifact",
|
||||
&[label("instance", instance)],
|
||||
run_seq as f64,
|
||||
);
|
||||
}
|
||||
writer.gauge(
|
||||
"ours_rp_ccr_format_check_last_duration_seconds",
|
||||
"Duration of the latest CCR rpki-client format check",
|
||||
&[label("instance", instance)],
|
||||
last.duration_seconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
|
||||
let rtr = &snapshot.rtr;
|
||||
let scan_now = snapshot.service.last_scan_timestamp_seconds;
|
||||
@@ -2525,6 +2886,7 @@ fn render_status_json(snapshot: &MetricsSnapshot) -> Result<String, String> {
|
||||
"latestRun": snapshot.latest_run,
|
||||
"cir": snapshot.cir,
|
||||
"ccr": snapshot.ccr,
|
||||
"ccrCheck": snapshot.ccr_check,
|
||||
"rtr": snapshot.rtr,
|
||||
"topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
|
||||
"topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
|
||||
@@ -3162,7 +3524,13 @@ mod tests {
|
||||
)
|
||||
.expect("clients");
|
||||
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
td.path(),
|
||||
"test",
|
||||
None,
|
||||
Some(&rtr),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
.expect("scan");
|
||||
assert!(snapshot.rtr.enabled);
|
||||
assert_eq!(snapshot.rtr.files["source"].seen, 1);
|
||||
@@ -3233,7 +3601,13 @@ mod tests {
|
||||
)
|
||||
.expect("clients");
|
||||
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
|
||||
let snapshot = scan_run_root_incremental_with_rtr_dir(
|
||||
td.path(),
|
||||
"test",
|
||||
None,
|
||||
Some(&rtr),
|
||||
&CcrCheckConfig::disabled(),
|
||||
)
|
||||
.expect("scan");
|
||||
assert!(snapshot.rtr.enabled);
|
||||
assert_eq!(snapshot.rtr.files["source"].seen, 1);
|
||||
@@ -3247,6 +3621,223 @@ mod tests {
|
||||
assert!(metrics.contains(r#"ours_rp_rtr_metrics_enabled{instance="test"} 1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_args_accepts_ccr_check_options() {
|
||||
let args = parse_args(&[
|
||||
"rpki_artifact_metrics".to_string(),
|
||||
"--run-root".to_string(),
|
||||
"root".to_string(),
|
||||
"--ccr-check-bin".to_string(),
|
||||
"/opt/ours-rp/bin/rpki-client".to_string(),
|
||||
"--ccr-check-timeout-secs".to_string(),
|
||||
"60".to_string(),
|
||||
"--ccr-check-max-per-scan".to_string(),
|
||||
"3".to_string(),
|
||||
])
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
args.ccr_check_bin.as_deref(),
|
||||
Some(Path::new("/opt/ours-rp/bin/rpki-client"))
|
||||
);
|
||||
assert_eq!(args.ccr_check_timeout_secs, 60);
|
||||
assert_eq!(args.ccr_check_max_per_scan, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_disabled_without_bin() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let snapshot = scan_run_root(td.path(), "test").expect("scan");
|
||||
assert!(!snapshot.ccr_check.enabled);
|
||||
assert!(!snapshot.ccr_check.up);
|
||||
assert!(snapshot.ccr_check.last.is_none());
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
|
||||
assert!(!metrics.contains("ours_rp_ccr_format_check_last_success"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_pass_records_metrics() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert!(snapshot.ccr_check.up);
|
||||
assert_eq!(snapshot.ccr_check.pass_total, 1);
|
||||
assert_eq!(snapshot.ccr_check.consecutive_failures, 0);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "pass");
|
||||
assert_eq!(last.run_seq, Some(1));
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(
|
||||
metrics.contains(r#"ours_rp_ccr_format_check_total{instance="test",result="pass"} 1"#)
|
||||
);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 1"#));
|
||||
assert!(
|
||||
metrics.contains(r#"ours_rp_ccr_format_check_last_run_sequence{instance="test"} 1"#)
|
||||
);
|
||||
let status = render_status_json(&snapshot).expect("status");
|
||||
assert!(status.contains(r#""ccrCheck""#));
|
||||
assert!(status.contains(r#""passTotal": 1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_parse_error_detected_from_output() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(
|
||||
td.path(),
|
||||
"#!/bin/sh\necho \"rpki-client: $2: unexpected number of locations\" >&2\nexit 0\n",
|
||||
);
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(snapshot.ccr_check.ccr_parse_error_total, 1);
|
||||
assert_eq!(snapshot.ccr_check.consecutive_failures, 1);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "ccr_parse_error");
|
||||
assert!(
|
||||
last.detail
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("unexpected number of locations")
|
||||
);
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(
|
||||
r#"ours_rp_ccr_format_check_total{instance="test",result="ccr_parse_error"} 1"#
|
||||
));
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_last_success{instance="test"} 0"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_nonzero_exit_is_tool_error() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 3\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(snapshot.ccr_check.tool_error_total, 1);
|
||||
assert_eq!(
|
||||
snapshot.ccr_check.last.as_ref().expect("last").outcome,
|
||||
"tool_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_timeout_is_tool_error() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nsleep 30\n");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &bin, 1);
|
||||
let last = snapshot.ccr_check.last.as_ref().expect("last result");
|
||||
assert_eq!(last.outcome, "tool_error");
|
||||
assert!(
|
||||
last.detail
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("timed out")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_startup_only_checks_latest_and_new_runs_once() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
write_success_run(td.path(), "run_0002", 2);
|
||||
let bin = write_fake_rpki_client(td.path(), "#!/bin/sh\nexit 0\n");
|
||||
|
||||
// First scan at service start: only the latest existing run is
|
||||
// checked, older history is marked as seen without executing checks.
|
||||
let first = scan_with_ccr_check(td.path(), None, &bin, 30);
|
||||
assert_eq!(first.ccr_check.pass_total, 1);
|
||||
assert!(
|
||||
first
|
||||
.ccr_check
|
||||
.last
|
||||
.as_ref()
|
||||
.expect("last")
|
||||
.run_dir
|
||||
.contains("run_0002")
|
||||
);
|
||||
|
||||
// Second scan: pre-existing history must not be backfilled.
|
||||
let second = scan_with_ccr_check(td.path(), Some(&first), &bin, 30);
|
||||
assert_eq!(second.ccr_check.pass_total, 1);
|
||||
|
||||
// A newly completed run is checked exactly once.
|
||||
write_success_run(td.path(), "run_0003", 3);
|
||||
let third = scan_with_ccr_check(td.path(), Some(&second), &bin, 30);
|
||||
assert_eq!(third.ccr_check.pass_total, 2);
|
||||
assert_eq!(
|
||||
third.ccr_check.last.as_ref().expect("last").run_seq,
|
||||
Some(3)
|
||||
);
|
||||
let fourth = scan_with_ccr_check(td.path(), Some(&third), &bin, 30);
|
||||
assert_eq!(fourth.ccr_check.pass_total, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccr_check_missing_bin_marks_not_up() {
|
||||
let td = TempDir::new().expect("tempdir");
|
||||
write_success_run(td.path(), "run_0001", 1);
|
||||
let missing = td.path().join("no-such-rpki-client");
|
||||
let snapshot = scan_with_ccr_check(td.path(), None, &missing, 30);
|
||||
assert!(snapshot.ccr_check.enabled);
|
||||
assert!(!snapshot.ccr_check.up);
|
||||
assert!(snapshot.ccr_check.last.is_none());
|
||||
assert!(
|
||||
snapshot
|
||||
.service
|
||||
.parse_errors
|
||||
.iter()
|
||||
.any(|error| error.contains("ccr check bin is not a file"))
|
||||
);
|
||||
let metrics = render_metrics(&snapshot);
|
||||
assert!(metrics.contains(r#"ours_rp_ccr_format_check_up{instance="test"} 0"#));
|
||||
}
|
||||
|
||||
fn write_success_run(root: &Path, name: &str, seq: u64) {
|
||||
let run = root.join("runs").join(name);
|
||||
fs::create_dir_all(&run).expect("create run");
|
||||
fs::write(
|
||||
run.join("run-meta.json"),
|
||||
format!(
|
||||
r#"{{"status":"success","run_index":{seq},"run_id":"{name}","sync_mode":"snapshot","snapshot_reason":"first_run","started_at_rfc3339_utc":"2026-07-21T00:00:00Z","completed_at_rfc3339_utc":"2026-07-21T00:00:10Z"}}"#
|
||||
),
|
||||
)
|
||||
.expect("meta");
|
||||
fs::write(
|
||||
run.join("run-summary.json"),
|
||||
format!(
|
||||
r#"{{"runSeq":{seq},"runId":"{name}","startedAtRfc3339Utc":"2026-07-21T00:00:00Z","finishedAtRfc3339Utc":"2026-07-21T00:00:10Z","wallMs":10000,"status":"success","exitCode":0}}"#
|
||||
),
|
||||
)
|
||||
.expect("summary");
|
||||
fs::write(run.join("result.ccr"), sample_ccr()).expect("ccr");
|
||||
}
|
||||
|
||||
fn write_fake_rpki_client(root: &Path, body: &str) -> PathBuf {
|
||||
let path = root.join("fake-rpki-client.sh");
|
||||
fs::write(&path, body).expect("write fake rpki-client");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn scan_with_ccr_check(
|
||||
root: &Path,
|
||||
previous: Option<&MetricsSnapshot>,
|
||||
bin: &Path,
|
||||
timeout_secs: u64,
|
||||
) -> MetricsSnapshot {
|
||||
let config = CcrCheckConfig {
|
||||
bin: Some(bin.to_path_buf()),
|
||||
timeout_secs,
|
||||
max_per_scan: 2,
|
||||
};
|
||||
scan_run_root_incremental_with_rtr_dir(root, "test", previous, None, &config).expect("scan")
|
||||
}
|
||||
|
||||
fn sample_cir() -> Vec<u8> {
|
||||
let rejected = vec![CirRejectedObject {
|
||||
object_uri: "rsync://repo.example/a/bad.roa".to_string(),
|
||||
|
||||
@@ -180,7 +180,7 @@ pub struct RoaValidationCacheStats {
|
||||
pub struct RoaValidationCacheInput<'a> {
|
||||
enabled: bool,
|
||||
view: Option<&'a RoaValidationCacheView>,
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ impl<'a> RoaValidationCacheInput<'a> {
|
||||
Self {
|
||||
enabled: false,
|
||||
view: None,
|
||||
parent_context_digest: None,
|
||||
ca_validation_context_digest: None,
|
||||
policy_fingerprint: None,
|
||||
}
|
||||
}
|
||||
@@ -198,20 +198,20 @@ impl<'a> RoaValidationCacheInput<'a> {
|
||||
Self {
|
||||
enabled: true,
|
||||
view,
|
||||
parent_context_digest: None,
|
||||
ca_validation_context_digest: None,
|
||||
policy_fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled_with_context(
|
||||
view: Option<&'a RoaValidationCacheView>,
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
) -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
view,
|
||||
parent_context_digest: Some(parent_context_digest),
|
||||
ca_validation_context_digest: Some(ca_validation_context_digest),
|
||||
policy_fingerprint: Some(policy_fingerprint),
|
||||
}
|
||||
}
|
||||
@@ -495,7 +495,7 @@ fn evaluate_roa_cache_crl_gate(
|
||||
pub struct RoaValidationCacheView {
|
||||
entries_by_uri: HashMap<String, CachedRoaValidationResult>,
|
||||
issuer_ca_sha256_hex: Option<String>,
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
crl_sha256_by_uri: HashMap<String, String>,
|
||||
blocked: bool,
|
||||
@@ -519,7 +519,7 @@ impl RoaValidationCacheView {
|
||||
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
|
||||
HashMap::with_capacity(projection.entries.len());
|
||||
let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone();
|
||||
let parent_context_digest = projection.parent_context_digest;
|
||||
let ca_validation_context_digest = projection.ca_validation_context_digest;
|
||||
let policy_fingerprint = projection.policy_fingerprint;
|
||||
let crl_sha256_by_uri = projection
|
||||
.crl_sha256_by_uri
|
||||
@@ -536,7 +536,7 @@ impl RoaValidationCacheView {
|
||||
return Self {
|
||||
entries_by_uri,
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
policy_fingerprint,
|
||||
crl_sha256_by_uri,
|
||||
blocked,
|
||||
@@ -580,7 +580,7 @@ impl RoaValidationCacheView {
|
||||
Self {
|
||||
entries_by_uri,
|
||||
issuer_ca_sha256_hex,
|
||||
parent_context_digest,
|
||||
ca_validation_context_digest,
|
||||
policy_fingerprint,
|
||||
crl_sha256_by_uri,
|
||||
blocked,
|
||||
@@ -590,7 +590,7 @@ impl RoaValidationCacheView {
|
||||
fn matches_current_context(
|
||||
&self,
|
||||
issuer_ca_der: &[u8],
|
||||
parent_context_digest: Option<[u8; 32]>,
|
||||
ca_validation_context_digest: Option<[u8; 32]>,
|
||||
policy_fingerprint: Option<[u8; 32]>,
|
||||
) -> bool {
|
||||
if self.blocked {
|
||||
@@ -604,8 +604,8 @@ impl RoaValidationCacheView {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(expected_parent_context) = self.parent_context_digest {
|
||||
if Some(expected_parent_context) != parent_context_digest {
|
||||
if let Some(expected_ca_validation_context) = self.ca_validation_context_digest {
|
||||
if Some(expected_ca_validation_context) != ca_validation_context_digest {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
@@ -797,7 +797,7 @@ fn active_roa_cache_view<'a>(
|
||||
let gate_started = Instant::now();
|
||||
if view.matches_current_context(
|
||||
issuer_ca_der,
|
||||
roa_cache.parent_context_digest,
|
||||
roa_cache.ca_validation_context_digest,
|
||||
roa_cache.policy_fingerprint,
|
||||
) {
|
||||
stats.context_gate_nanos = stats
|
||||
@@ -3914,7 +3914,7 @@ mod tests {
|
||||
OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time")
|
||||
}
|
||||
|
||||
const TEST_PARENT_CONTEXT: [u8; 32] = [0x70; 32];
|
||||
const TEST_CA_VALIDATION_CONTEXT: [u8; 32] = [0x70; 32];
|
||||
const TEST_POLICY_FINGERPRINT: [u8; 32] = [0x71; 32];
|
||||
const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl";
|
||||
const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa";
|
||||
@@ -3933,7 +3933,7 @@ mod tests {
|
||||
RoaCacheProjection::from_vcir_with_context(
|
||||
vcir,
|
||||
Some(&RoaCacheProjectionContext {
|
||||
parent_context_digest: TEST_PARENT_CONTEXT,
|
||||
ca_validation_context_digest: TEST_CA_VALIDATION_CONTEXT,
|
||||
policy_fingerprint: TEST_POLICY_FINGERPRINT,
|
||||
object_meta: vec![RoaCacheObjectMeta {
|
||||
source_object_uri: TEST_ROA_URI.to_string(),
|
||||
@@ -4071,7 +4071,7 @@ mod tests {
|
||||
|
||||
assert!(view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
|
||||
@@ -4202,7 +4202,10 @@ mod tests {
|
||||
fixed_time("2026-06-08T00:00:00Z"),
|
||||
);
|
||||
let projection = sample_roa_cache_projection(&vcir, roa_hash);
|
||||
assert_eq!(projection.parent_context_digest, Some(TEST_PARENT_CONTEXT));
|
||||
assert_eq!(
|
||||
projection.ca_validation_context_digest,
|
||||
Some(TEST_CA_VALIDATION_CONTEXT)
|
||||
);
|
||||
assert_eq!(projection.policy_fingerprint, Some(TEST_POLICY_FINGERPRINT));
|
||||
assert_eq!(
|
||||
projection.entries[0].outputs_effective_until_unix,
|
||||
@@ -4245,7 +4248,7 @@ mod tests {
|
||||
let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
|
||||
assert!(!issuer_changed.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
@@ -4258,7 +4261,7 @@ mod tests {
|
||||
));
|
||||
assert!(!parent_changed.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some([0x98; 32])
|
||||
));
|
||||
|
||||
@@ -4283,7 +4286,7 @@ mod tests {
|
||||
RoaValidationCacheView::from_projection(&expired_projection, validation_time);
|
||||
assert!(!expired_view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
|
||||
@@ -4563,7 +4566,7 @@ mod tests {
|
||||
|
||||
assert!(view.matches_current_context(
|
||||
issuer_der,
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -4595,43 +4598,43 @@ mod tests {
|
||||
let mut view = RoaValidationCacheView {
|
||||
entries_by_uri: HashMap::new(),
|
||||
issuer_ca_sha256_hex: None,
|
||||
parent_context_digest: Some(TEST_PARENT_CONTEXT),
|
||||
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
|
||||
crl_sha256_by_uri: HashMap::new(),
|
||||
blocked: false,
|
||||
};
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
view.issuer_ca_sha256_hex = Some("00".repeat(32));
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
|
||||
view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
|
||||
assert!(view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
assert!(!view.matches_current_context(b"issuer-ca", None, Some(TEST_POLICY_FINGERPRINT)));
|
||||
|
||||
view.parent_context_digest = None;
|
||||
view.ca_validation_context_digest = None;
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
view.parent_context_digest = Some(TEST_PARENT_CONTEXT);
|
||||
view.ca_validation_context_digest = Some(TEST_CA_VALIDATION_CONTEXT);
|
||||
view.policy_fingerprint = None;
|
||||
assert!(!view.matches_current_context(
|
||||
b"issuer-ca",
|
||||
Some(TEST_PARENT_CONTEXT),
|
||||
Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
Some(TEST_POLICY_FINGERPRINT)
|
||||
));
|
||||
}
|
||||
@@ -4661,7 +4664,7 @@ mod tests {
|
||||
let mut view = RoaValidationCacheView {
|
||||
entries_by_uri: HashMap::new(),
|
||||
issuer_ca_sha256_hex: Some(sha256_hex(issuer_der)),
|
||||
parent_context_digest: Some(TEST_PARENT_CONTEXT),
|
||||
ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
|
||||
policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
|
||||
crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]),
|
||||
blocked: false,
|
||||
@@ -4849,7 +4852,7 @@ mod tests {
|
||||
false,
|
||||
RoaValidationCacheInput::enabled_with_context(
|
||||
Some(&view),
|
||||
TEST_PARENT_CONTEXT,
|
||||
TEST_CA_VALIDATION_CONTEXT,
|
||||
TEST_POLICY_FINGERPRINT,
|
||||
),
|
||||
) {
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::validation::publication_point::PublicationPointSnapshot;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const DEFAULT_MAX_CA_DEPTH: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TreeRunConfig {
|
||||
/// Max CA instance depth to process (0 = root only).
|
||||
@@ -37,7 +39,7 @@ pub struct TreeRunConfig {
|
||||
impl Default for TreeRunConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_depth: None,
|
||||
max_depth: Some(DEFAULT_MAX_CA_DEPTH),
|
||||
max_instances: None,
|
||||
compact_audit: false,
|
||||
persist_vcir: true,
|
||||
@@ -51,6 +53,15 @@ impl Default for TreeRunConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ca_depth_is_allowed(config: &TreeRunConfig, depth: usize) -> bool {
|
||||
config.max_depth.is_none_or(|max_depth| depth <= max_depth)
|
||||
}
|
||||
|
||||
pub(crate) fn next_allowed_ca_depth(config: &TreeRunConfig, parent_depth: usize) -> Option<usize> {
|
||||
let child_depth = parent_depth.checked_add(1)?;
|
||||
ca_depth_is_allowed(config, child_depth).then_some(child_depth)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CaCertificateRef {
|
||||
InlineDer(Vec<u8>),
|
||||
@@ -347,11 +358,9 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_depth) = config.max_depth {
|
||||
if ca.depth > max_depth {
|
||||
if !ca_depth_is_allowed(config, ca.depth) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let res = match runner.run_publication_point(ca) {
|
||||
Ok(v) => v,
|
||||
@@ -393,6 +402,9 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
}
|
||||
publication_points.push(audit);
|
||||
|
||||
let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else {
|
||||
continue;
|
||||
};
|
||||
let mut children = res.discovered_children;
|
||||
children.sort_by(|a, b| {
|
||||
a.handle
|
||||
@@ -413,7 +425,7 @@ pub fn run_tree_serial_audit_multi_root(
|
||||
for child in children {
|
||||
queue.push_back(QueuedCaInstance {
|
||||
id: next_id,
|
||||
handle: child.handle.with_depth(ca.depth + 1),
|
||||
handle: child.handle.with_depth(child_depth),
|
||||
parent_id: Some(node.id),
|
||||
discovered_from: Some(child.discovered_from),
|
||||
});
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::validation::objects::{
|
||||
};
|
||||
use crate::validation::tree::{
|
||||
CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner,
|
||||
TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput,
|
||||
run_tree_serial_audit_multi_root,
|
||||
TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput, ca_depth_is_allowed,
|
||||
next_allowed_ca_depth, run_tree_serial_audit_multi_root,
|
||||
};
|
||||
use crate::validation::tree_runner::{
|
||||
FreshPublicationPointFinalizeOutput, FreshPublicationPointStage, Rpkiv1PublicationPointRunner,
|
||||
@@ -659,6 +659,7 @@ pub fn run_tree_parallel_phase2_audit_multi_root(
|
||||
&mut finished,
|
||||
ready,
|
||||
ready_queue_len_after_pop,
|
||||
config,
|
||||
config.compact_audit,
|
||||
);
|
||||
ready_batch_metrics.record(metrics);
|
||||
@@ -916,11 +917,9 @@ fn start_queued_ca_instances(
|
||||
if !visited_manifest_uris.insert(node.handle.manifest_rsync_uri.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(max_depth) = config.max_depth {
|
||||
if node.handle.depth > max_depth {
|
||||
if !ca_depth_is_allowed(config, node.handle.depth) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
*instances_started += 1;
|
||||
match repo_runtime.request_publication_point_repo(&node.handle, 0) {
|
||||
Ok(RepoSyncRequestStatus::Ready { mut outcome, .. }) => {
|
||||
@@ -959,6 +958,7 @@ fn stage_ready_publication_point(
|
||||
finished: &mut Vec<FinishedPublicationPoint>,
|
||||
ready: ReadyCaInstance,
|
||||
ready_queue_len_after_pop: usize,
|
||||
config: &TreeRunConfig,
|
||||
compact_audit: bool,
|
||||
) -> ReadyStageMetrics {
|
||||
let publication_point_started = Instant::now();
|
||||
@@ -991,6 +991,7 @@ fn stage_ready_publication_point(
|
||||
next_id,
|
||||
ca_queue,
|
||||
&ready.node,
|
||||
config,
|
||||
result.discovered_children.clone(),
|
||||
);
|
||||
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
|
||||
@@ -1057,6 +1058,7 @@ fn stage_ready_publication_point(
|
||||
next_id,
|
||||
ca_queue,
|
||||
&ready.node,
|
||||
config,
|
||||
result.discovered_children.clone(),
|
||||
);
|
||||
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
|
||||
@@ -1142,6 +1144,7 @@ fn stage_ready_publication_point(
|
||||
next_id,
|
||||
ca_queue,
|
||||
&ready.node,
|
||||
config,
|
||||
fresh_stage.discovered_children.clone(),
|
||||
);
|
||||
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
|
||||
@@ -1175,7 +1178,7 @@ fn stage_ready_publication_point(
|
||||
let roa_cache = if runner.enable_roa_validation_cache && has_roa {
|
||||
RoaValidationCacheInput::enabled_with_context(
|
||||
roa_cache_view.as_ref(),
|
||||
crate::validation::tree_runner::parent_context_digest_for_ca(&ready.node.handle),
|
||||
crate::validation::tree_runner::ca_validation_context_digest_for_ca(&ready.node.handle),
|
||||
crate::validation::tree_runner::publication_point_cache_policy_fingerprint(
|
||||
runner.policy,
|
||||
),
|
||||
@@ -1412,8 +1415,13 @@ fn enqueue_discovered_children(
|
||||
next_id: &mut u64,
|
||||
ca_queue: &mut VecDeque<QueuedCaInstance>,
|
||||
parent: &QueuedCaInstance,
|
||||
config: &TreeRunConfig,
|
||||
mut children: Vec<DiscoveredChildCaInstance>,
|
||||
) {
|
||||
let Some(child_depth) = next_allowed_ca_depth(config, parent.handle.depth) else {
|
||||
return;
|
||||
};
|
||||
|
||||
children.sort_by(|a, b| {
|
||||
a.handle
|
||||
.manifest_rsync_uri
|
||||
@@ -1428,7 +1436,7 @@ fn enqueue_discovered_children(
|
||||
let _ = runtime.prefetch_discovered_children(&children);
|
||||
}
|
||||
for child in children {
|
||||
let mut handle = child.handle.with_depth(parent.handle.depth + 1);
|
||||
let mut handle = child.handle.with_depth(child_depth);
|
||||
handle.parent_manifest_rsync_uri = Some(parent.handle.manifest_rsync_uri.clone());
|
||||
ca_queue.push_back(QueuedCaInstance {
|
||||
id: *next_id,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
mod vcir_der;
|
||||
|
||||
use crate::analysis::timing::TimingHandle;
|
||||
use crate::audit::{
|
||||
AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit,
|
||||
@@ -69,7 +67,7 @@ use base64::Engine as _;
|
||||
use x509_parser::prelude::FromDer;
|
||||
use x509_parser::x509::SubjectPublicKeyInfo;
|
||||
|
||||
use vcir_der::encode_access_description_der_for_vcir_ccr_projection;
|
||||
use crate::ccr::manifest_location::select_manifest_signed_object_location;
|
||||
|
||||
const PUBLICATION_POINT_CACHE_CHILD_RESTORE_PARALLEL_MIN_CHILDREN: usize = 256;
|
||||
const PUBLICATION_POINT_CACHE_CHILD_RESTORE_MAX_WORKERS: usize = 16;
|
||||
@@ -304,7 +302,8 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
self.finish_publication_point_cache_miss(ca, "ta_context_mismatch", lookup_started);
|
||||
return None;
|
||||
}
|
||||
if projection.parent_context_digest != current_identity.parent_context_digest {
|
||||
if projection.ca_validation_context_digest != current_identity.ca_validation_context_digest
|
||||
{
|
||||
self.finish_publication_point_cache_miss(ca, "parent_context_mismatch", lookup_started);
|
||||
return None;
|
||||
}
|
||||
@@ -505,7 +504,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
ca_cert_sha256,
|
||||
manifest_sha256,
|
||||
ta_context_digest: ta_context_digest_for_ca(ca),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
|
||||
})
|
||||
}
|
||||
@@ -938,7 +937,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
|
||||
Some(ChildCertificateValidationCacheContext {
|
||||
store: self.store,
|
||||
issuer_ca_sha256: sha256_digest_32(issuer_ca_der.as_ref()),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
|
||||
})
|
||||
} else {
|
||||
@@ -1530,7 +1529,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
|
||||
let roa_cache = if self.enable_roa_validation_cache && has_roa {
|
||||
RoaValidationCacheInput::enabled_with_context(
|
||||
roa_cache_view.as_ref(),
|
||||
parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest_for_ca(ca),
|
||||
publication_point_cache_policy_fingerprint(self.policy),
|
||||
)
|
||||
} else {
|
||||
@@ -1946,7 +1945,7 @@ struct PublicationPointCacheIdentity {
|
||||
ca_cert_sha256: [u8; 32],
|
||||
manifest_sha256: [u8; 32],
|
||||
ta_context_digest: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
}
|
||||
|
||||
@@ -1982,7 +1981,7 @@ fn ta_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn parent_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
pub(crate) fn ca_validation_context_digest_for_ca(ca: &CaInstanceHandle) -> [u8; 32] {
|
||||
hash_serialized_parts(&[
|
||||
(
|
||||
"version",
|
||||
@@ -2058,7 +2057,7 @@ fn pack_time_window_contains(
|
||||
struct ChildCertificateValidationCacheContext<'a> {
|
||||
store: &'a RocksStore,
|
||||
issuer_ca_sha256: [u8; 32],
|
||||
parent_context_digest: [u8; 32],
|
||||
ca_validation_context_digest: [u8; 32],
|
||||
policy_fingerprint: [u8; 32],
|
||||
}
|
||||
|
||||
@@ -2066,7 +2065,7 @@ fn child_certificate_cache_key_sha256_hex(
|
||||
child_cert_uri: &str,
|
||||
child_cert_sha256: &[u8; 32],
|
||||
issuer_ca_sha256: &[u8; 32],
|
||||
parent_context_digest: &[u8; 32],
|
||||
ca_validation_context_digest: &[u8; 32],
|
||||
policy_fingerprint: &[u8; 32],
|
||||
) -> String {
|
||||
let digest = hash_serialized_parts(&[
|
||||
@@ -2074,7 +2073,11 @@ fn child_certificate_cache_key_sha256_hex(
|
||||
("child_cert_uri", child_cert_uri.as_bytes().to_vec()),
|
||||
("child_cert_sha256", child_cert_sha256.to_vec()),
|
||||
("issuer_ca_sha256", issuer_ca_sha256.to_vec()),
|
||||
("parent_context_digest", parent_context_digest.to_vec()),
|
||||
// Keep the persisted cache-key label stable; this change only renames Rust identifiers.
|
||||
(
|
||||
"parent_context_digest",
|
||||
ca_validation_context_digest.to_vec(),
|
||||
),
|
||||
("policy_fingerprint", policy_fingerprint.to_vec()),
|
||||
]);
|
||||
sha256_hex_from_32(&digest)
|
||||
@@ -2469,7 +2472,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
));
|
||||
}
|
||||
@@ -3022,7 +3025,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
);
|
||||
let projection = ChildCertificateCacheProjection {
|
||||
@@ -3040,7 +3043,8 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
),
|
||||
issuer_crl_uri: issuer_crl_uri.clone(),
|
||||
issuer_crl_sha256_hex: verified_crl.sha256_hex.clone(),
|
||||
parent_context_digest: cache.parent_context_digest,
|
||||
ca_validation_context_digest: cache
|
||||
.ca_validation_context_digest,
|
||||
validation_policy_fingerprint: cache.policy_fingerprint,
|
||||
effective_not_before,
|
||||
effective_until,
|
||||
@@ -3239,7 +3243,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
&f.rsync_uri,
|
||||
&f.sha256,
|
||||
&cache.issuer_ca_sha256,
|
||||
&cache.parent_context_digest,
|
||||
&cache.ca_validation_context_digest,
|
||||
&cache.policy_fingerprint,
|
||||
);
|
||||
let projection = ChildCertificateCacheProjection {
|
||||
@@ -3255,7 +3259,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
|
||||
issuer_ca_sha256_hex: sha256_hex_from_32(&cache.issuer_ca_sha256),
|
||||
issuer_crl_uri: issuer_crl_uri.clone(),
|
||||
issuer_crl_sha256_hex: verified_crl.sha256_hex.clone(),
|
||||
parent_context_digest: cache.parent_context_digest,
|
||||
ca_validation_context_digest: cache.ca_validation_context_digest,
|
||||
validation_policy_fingerprint: cache.policy_fingerprint,
|
||||
effective_not_before,
|
||||
effective_until,
|
||||
@@ -4395,7 +4399,7 @@ fn failed_fetch_reuse_identity_for_fresh_result(
|
||||
let identity = VcirFailedFetchReuseIdentity {
|
||||
current_ca_sha256,
|
||||
ta_context_digest: ta_context_digest_for_ca(ca),
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
|
||||
effective_not_before: PackTime::from_utc_offset_datetime(validation_time),
|
||||
effective_until,
|
||||
@@ -4418,7 +4422,7 @@ fn failed_fetch_reuse_identity_matches_current(
|
||||
};
|
||||
cached.current_ca_sha256 == current_ca_sha256
|
||||
&& cached.ta_context_digest == ta_context_digest_for_ca(ca)
|
||||
&& cached.parent_context_digest == parent_context_digest_for_ca(ca)
|
||||
&& cached.ca_validation_context_digest == ca_validation_context_digest_for_ca(ca)
|
||||
&& cached.policy_fingerprint == publication_point_cache_policy_fingerprint(policy)
|
||||
&& cached.effective_until == vcir.instance_gate.instance_effective_until
|
||||
&& cached.contains_validation_time(validation_time)
|
||||
@@ -5347,7 +5351,7 @@ fn persist_vcir_for_fresh_result_with_timing(
|
||||
.replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
|
||||
&vcir,
|
||||
Some(&RoaCacheProjectionContext {
|
||||
parent_context_digest: parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(ca),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
|
||||
object_meta: objects.roa_cache_object_meta.clone(),
|
||||
}),
|
||||
@@ -5378,7 +5382,7 @@ fn build_publication_point_cache_projection_from_fresh(
|
||||
ca_cert_sha256,
|
||||
manifest_sha256,
|
||||
ta_context_digest_for_ca(ca),
|
||||
parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest_for_ca(ca),
|
||||
publication_point_cache_policy_fingerprint(policy),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -5671,11 +5675,10 @@ fn build_vcir_ccr_manifest_projection_from_fresh(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())?
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(encode_access_description_der_for_vcir_ccr_projection)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![select_manifest_signed_object_location(
|
||||
&ca.manifest_rsync_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)?],
|
||||
SubjectInfoAccess::Ca(_) => {
|
||||
return Err(
|
||||
"manifest EE certificate Subject Information Access has CA variant".to_string(),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::data_model::rc::{AsIdOrRange, AsIdentifierChoice, AsResourceSet, ResourceCertificate};
|
||||
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
|
||||
use crate::data_model::rc::{
|
||||
AccessDescription, AsIdOrRange, AsIdentifierChoice, AsResourceSet, ResourceCertificate,
|
||||
};
|
||||
use crate::data_model::roa::RoaAfi;
|
||||
use crate::fetch::rsync::LocalDirRsyncFetcher;
|
||||
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
|
||||
@@ -603,10 +606,13 @@ fn sample_vcir_for_projection(
|
||||
manifest_ee_aki: vec![0x11; 20],
|
||||
manifest_number_be: vec![1],
|
||||
manifest_this_update: PackTime::from_utc_offset_datetime(now),
|
||||
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.clone(),
|
||||
})
|
||||
.expect("encode signedObject"),
|
||||
],
|
||||
subordinate_skis: vec![vec![0x33; 20]],
|
||||
};
|
||||
ValidatedCaInstanceResult {
|
||||
@@ -1252,12 +1258,13 @@ fn build_vcir_ccr_manifest_projection_from_fresh_real_snapshot_matches_manifest_
|
||||
.as_ref()
|
||||
.expect("manifest sia")
|
||||
{
|
||||
SubjectInfoAccess::Ee(ee_sia) => ee_sia
|
||||
.access_descriptions
|
||||
.iter()
|
||||
.map(encode_access_description_der_for_vcir_ccr_projection)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("encode locations"),
|
||||
SubjectInfoAccess::Ee(ee_sia) => vec![
|
||||
crate::ccr::manifest_location::select_manifest_signed_object_location(
|
||||
&pack.manifest_rsync_uri,
|
||||
&ee_sia.access_descriptions,
|
||||
)
|
||||
.expect("select manifest signedObject"),
|
||||
],
|
||||
SubjectInfoAccess::Ca(_) => panic!("manifest ee SIA should not be CA variant"),
|
||||
};
|
||||
|
||||
@@ -1601,7 +1608,7 @@ fn discover_children_child_certificate_cache_reuses_successful_child_ca() {
|
||||
let cache_context = ChildCertificateValidationCacheContext {
|
||||
store: &store,
|
||||
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(),
|
||||
parent_context_digest: parent_context_digest_for_ca(&issuer),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(&issuer),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
|
||||
};
|
||||
let validation_time = time::OffsetDateTime::now_utc();
|
||||
@@ -1625,7 +1632,7 @@ fn discover_children_child_certificate_cache_reuses_successful_child_ca() {
|
||||
&child_file.rsync_uri,
|
||||
&child_file.sha256,
|
||||
&cache_context.issuer_ca_sha256,
|
||||
&cache_context.parent_context_digest,
|
||||
&cache_context.ca_validation_context_digest,
|
||||
&cache_context.policy_fingerprint,
|
||||
);
|
||||
assert!(
|
||||
@@ -1724,7 +1731,7 @@ fn discover_children_child_certificate_cache_rechecks_changed_valid_crl() {
|
||||
let cache_context = ChildCertificateValidationCacheContext {
|
||||
store: &store,
|
||||
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(),
|
||||
parent_context_digest: parent_context_digest_for_ca(&issuer),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(&issuer),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
|
||||
};
|
||||
let validation_time = time::OffsetDateTime::now_utc();
|
||||
@@ -1745,7 +1752,7 @@ fn discover_children_child_certificate_cache_rechecks_changed_valid_crl() {
|
||||
&child_file.rsync_uri,
|
||||
&child_file.sha256,
|
||||
&cache_context.issuer_ca_sha256,
|
||||
&cache_context.parent_context_digest,
|
||||
&cache_context.ca_validation_context_digest,
|
||||
&cache_context.policy_fingerprint,
|
||||
);
|
||||
let projection = store
|
||||
@@ -1832,7 +1839,7 @@ fn discover_children_child_certificate_cache_misses_when_current_crl_invalid() {
|
||||
let cache_context = ChildCertificateValidationCacheContext {
|
||||
store: &store,
|
||||
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(),
|
||||
parent_context_digest: parent_context_digest_for_ca(&issuer),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(&issuer),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
|
||||
};
|
||||
let validation_time = time::OffsetDateTime::now_utc();
|
||||
@@ -1907,7 +1914,7 @@ fn discover_children_child_certificate_cache_misses_when_unchanged_crl_expired()
|
||||
let cache_context = ChildCertificateValidationCacheContext {
|
||||
store: &store,
|
||||
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(),
|
||||
parent_context_digest: parent_context_digest_for_ca(&issuer),
|
||||
ca_validation_context_digest: ca_validation_context_digest_for_ca(&issuer),
|
||||
policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
|
||||
};
|
||||
let validation_time = time::OffsetDateTime::now_utc();
|
||||
@@ -2436,7 +2443,7 @@ fn seed_publication_point_cache_projection(
|
||||
ca.ca_certificate_sha256_32().unwrap(),
|
||||
sha256_32(b"manifest-bytes"),
|
||||
ta_context_digest_for_ca(ca),
|
||||
parent_context_digest_for_ca(ca),
|
||||
ca_validation_context_digest_for_ca(ca),
|
||||
publication_point_cache_policy_fingerprint(policy),
|
||||
)
|
||||
.expect("build publication point projection");
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
use crate::data_model::rc::AccessDescription;
|
||||
|
||||
pub(super) fn encode_access_description_der_for_vcir_ccr_projection(
|
||||
access_description: &AccessDescription,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let oid = encode_oid_der_for_vcir_ccr_projection(&access_description.access_method_oid)?;
|
||||
let uri = encode_tlv_for_vcir_ccr_projection(
|
||||
0x86,
|
||||
access_description.access_location.as_bytes().to_vec(),
|
||||
);
|
||||
Ok(encode_sequence_for_vcir_ccr_projection(&[oid, uri]))
|
||||
}
|
||||
|
||||
fn encode_oid_der_for_vcir_ccr_projection(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 {
|
||||
return Err(format!("unsupported accessMethod OID: {oid}"));
|
||||
}
|
||||
if arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40) {
|
||||
return Err(format!("unsupported accessMethod OID: {oid}"));
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
body.push((arcs[0] * 40 + arcs[1]) as u8);
|
||||
for arc in &arcs[2..] {
|
||||
encode_base128_for_vcir_ccr_projection(*arc, &mut body);
|
||||
}
|
||||
Ok(encode_tlv_for_vcir_ccr_projection(0x06, body))
|
||||
}
|
||||
|
||||
fn encode_base128_for_vcir_ccr_projection(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_vcir_ccr_projection(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_vcir_ccr_projection(0x30, buf)
|
||||
}
|
||||
|
||||
fn encode_tlv_for_vcir_ccr_projection(tag: u8, value: Vec<u8>) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(1 + 9 + value.len());
|
||||
out.push(tag);
|
||||
encode_length_for_vcir_ccr_projection(value.len(), &mut out);
|
||||
out.extend_from_slice(&value);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_length_for_vcir_ccr_projection(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);
|
||||
}
|
||||
1040
src/verification_only.rs
Normal file
1040
src/verification_only.rs
Normal file
File diff suppressed because it is too large
Load Diff
139
tests/test_ccr_manifest_location_interop.rs
Normal file
139
tests/test_ccr_manifest_location_interop.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use rpki::ccr::{CcrAccumulator, build_roa_payload_state};
|
||||
use rpki::data_model::roa::{IpPrefix, RoaAfi};
|
||||
use rpki::storage::{PackTime, VcirCcrManifestProjection};
|
||||
use rpki::validation::objects::Vrp;
|
||||
|
||||
const MANIFEST_URI: &str = "rsync://example.test/repo/current.mft";
|
||||
const OID_AD_SIGNED_OBJECT: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x0b];
|
||||
const OID_AD_RPKI_NOTIFY: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x0d];
|
||||
|
||||
fn der_length(len: usize) -> Vec<u8> {
|
||||
if len < 0x80 {
|
||||
return vec![len as u8];
|
||||
}
|
||||
let mut bytes = len.to_be_bytes().to_vec();
|
||||
let first = bytes
|
||||
.iter()
|
||||
.position(|byte| *byte != 0)
|
||||
.expect("non-zero length");
|
||||
bytes.drain(..first);
|
||||
let mut out = vec![0x80 | bytes.len() as u8];
|
||||
out.extend_from_slice(&bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![tag];
|
||||
out.extend(der_length(value.len()));
|
||||
out.extend_from_slice(value);
|
||||
out
|
||||
}
|
||||
|
||||
fn access_description_der(access_method_oid: &[u8], uri: &str) -> Vec<u8> {
|
||||
let oid = der_tlv(0x06, access_method_oid);
|
||||
let location = der_tlv(0x86, uri.as_bytes());
|
||||
let mut body = oid;
|
||||
body.extend(location);
|
||||
der_tlv(0x30, &body)
|
||||
}
|
||||
|
||||
fn projection(locations: Vec<Vec<u8>>) -> VcirCcrManifestProjection {
|
||||
VcirCcrManifestProjection {
|
||||
manifest_rsync_uri: MANIFEST_URI.to_string(),
|
||||
manifest_sha256: vec![0x11; 32],
|
||||
manifest_size: 1000,
|
||||
manifest_ee_aki: vec![0x22; 20],
|
||||
manifest_number_be: vec![1],
|
||||
manifest_this_update: PackTime::from_utc_offset_datetime(time::OffsetDateTime::now_utc()),
|
||||
manifest_sia_locations_der: locations,
|
||||
subordinate_skis: vec![vec![0x33; 20]],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_projection_normalizes_locations_and_accounts_memory() {
|
||||
let signed_object = access_description_der(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
|
||||
let notify = access_description_der(
|
||||
OID_AD_RPKI_NOTIFY,
|
||||
"https://rrdp.example.test/notification.xml",
|
||||
);
|
||||
let projection = projection(vec![notify, signed_object]);
|
||||
|
||||
let mut accumulator = CcrAccumulator::new(Vec::new());
|
||||
accumulator
|
||||
.append_manifest_projection(&projection)
|
||||
.expect("append normalized historical projection");
|
||||
accumulator
|
||||
.append_manifest_projection(&projection)
|
||||
.expect("duplicate matching projection is idempotent");
|
||||
|
||||
assert_eq!(accumulator.manifest_count(), 1);
|
||||
let stats = accumulator.memory_stats();
|
||||
assert_eq!(stats.manifest_count, 1);
|
||||
assert_eq!(stats.locations_der_count, 1);
|
||||
assert_eq!(stats.subordinate_ski_count, 1);
|
||||
assert!(stats.estimated_heap_bytes > 0);
|
||||
assert!(stats.btree_entry_shallow_bytes > 0);
|
||||
|
||||
let mut conflicting = projection;
|
||||
conflicting.subordinate_skis.push(vec![0x44; 20]);
|
||||
let error = accumulator
|
||||
.append_manifest_projection(&conflicting)
|
||||
.expect_err("same hash with different projection must fail");
|
||||
assert!(error.contains("duplicate manifest hash"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_projection_rejects_invalid_location_sets() {
|
||||
let mut accumulator = CcrAccumulator::new(Vec::new());
|
||||
|
||||
let empty_error = accumulator
|
||||
.append_manifest_projection(&projection(Vec::new()))
|
||||
.expect_err("empty location set must fail");
|
||||
assert!(empty_error.contains("contains 0"), "{empty_error}");
|
||||
|
||||
let signed_object = access_description_der(OID_AD_SIGNED_OBJECT, MANIFEST_URI);
|
||||
let duplicate_error = accumulator
|
||||
.append_manifest_projection(&projection(vec![signed_object.clone(), signed_object]))
|
||||
.expect_err("duplicate matching locations must fail");
|
||||
assert!(duplicate_error.contains("contains 2"), "{duplicate_error}");
|
||||
|
||||
let malformed_error = accumulator
|
||||
.append_manifest_projection(&projection(vec![vec![0x30, 0x01, 0x06]]))
|
||||
.expect_err("malformed location DER must fail");
|
||||
assert!(
|
||||
malformed_error.contains("truncated DER"),
|
||||
"{malformed_error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roa_payload_builder_encodes_large_non_byte_aligned_family() {
|
||||
let mut vrps = Vec::new();
|
||||
for octet in 0..=255 {
|
||||
vrps.push(Vrp {
|
||||
asn: 64_496,
|
||||
prefix: IpPrefix {
|
||||
afi: RoaAfi::Ipv4,
|
||||
prefix_len: 24,
|
||||
addr: [10, octet, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
max_length: 24,
|
||||
});
|
||||
}
|
||||
vrps.push(Vrp {
|
||||
asn: 64_496,
|
||||
prefix: IpPrefix {
|
||||
afi: RoaAfi::Ipv4,
|
||||
prefix_len: 13,
|
||||
addr: [10, 0x1f, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
max_length: 24,
|
||||
});
|
||||
|
||||
let state = build_roa_payload_state(&vrps).expect("build ROA payload state");
|
||||
assert_eq!(state.rps.len(), 1);
|
||||
assert_eq!(state.rps[0].as_id, 64_496);
|
||||
assert_eq!(state.rps[0].ip_addr_blocks.len(), 1);
|
||||
assert!(state.rps[0].ip_addr_blocks[0].len() > 128);
|
||||
}
|
||||
@@ -253,6 +253,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
|
||||
fn tree_respects_max_depth_and_max_instances() {
|
||||
let root_manifest = "rsync://example.test/repo/root.mft";
|
||||
let child_manifest = "rsync://example.test/repo/child.mft";
|
||||
let grandchild_manifest = "rsync://example.test/repo/grandchild.mft";
|
||||
|
||||
let runner = MockRunner::default()
|
||||
.with(
|
||||
@@ -301,6 +302,32 @@ fn tree_respects_max_depth_and_max_instances() {
|
||||
audit: PublicationPointAudit::default(),
|
||||
cir_fresh_objects: Vec::new(),
|
||||
cir_cached_objects: Vec::new(),
|
||||
discovered_children: vec![discovered_child(child_manifest, grandchild_manifest)],
|
||||
},
|
||||
)
|
||||
.with(
|
||||
grandchild_manifest,
|
||||
PublicationPointRunResult {
|
||||
source: PublicationPointSource::Fresh,
|
||||
snapshot: Some(empty_snapshot(
|
||||
grandchild_manifest,
|
||||
"rsync://example.test/repo/grandchild/",
|
||||
)),
|
||||
warnings: Vec::new(),
|
||||
objects: ObjectsOutput {
|
||||
vrps: Vec::new(),
|
||||
aspas: Vec::new(),
|
||||
router_keys: Vec::new(),
|
||||
local_outputs_cache: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
stats: ObjectsStats::default(),
|
||||
audit: Vec::new(),
|
||||
roa_cache_stats: Default::default(),
|
||||
roa_cache_object_meta: Vec::new(),
|
||||
},
|
||||
audit: PublicationPointAudit::default(),
|
||||
cir_fresh_objects: Vec::new(),
|
||||
cir_cached_objects: Vec::new(),
|
||||
discovered_children: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -324,6 +351,62 @@ fn tree_respects_max_depth_and_max_instances() {
|
||||
.expect("run tree depth-limited");
|
||||
assert_eq!(out.instances_processed, 1);
|
||||
assert_eq!(out.instances_failed, 0);
|
||||
assert_eq!(runner.called(), vec![root_manifest]);
|
||||
|
||||
let out = run_tree_serial(
|
||||
ca_handle(root_manifest),
|
||||
&runner,
|
||||
&TreeRunConfig {
|
||||
max_depth: Some(1),
|
||||
max_instances: None,
|
||||
compact_audit: false,
|
||||
persist_vcir: true,
|
||||
build_ccr_accumulator: true,
|
||||
enable_roa_validation_cache: false,
|
||||
enable_child_certificate_validation_cache: false,
|
||||
publication_point_cache_observe_only: false,
|
||||
enable_publication_point_validation_cache: false,
|
||||
enable_transport_request_prefetch: false,
|
||||
},
|
||||
)
|
||||
.expect("run tree depth-one-limited");
|
||||
assert_eq!(out.instances_processed, 2);
|
||||
assert_eq!(out.instances_failed, 0);
|
||||
assert_eq!(
|
||||
runner.called(),
|
||||
vec![root_manifest, root_manifest, child_manifest]
|
||||
);
|
||||
|
||||
let out = run_tree_serial(
|
||||
ca_handle(root_manifest),
|
||||
&runner,
|
||||
&TreeRunConfig {
|
||||
max_depth: Some(2),
|
||||
max_instances: None,
|
||||
compact_audit: false,
|
||||
persist_vcir: true,
|
||||
build_ccr_accumulator: true,
|
||||
enable_roa_validation_cache: false,
|
||||
enable_child_certificate_validation_cache: false,
|
||||
publication_point_cache_observe_only: false,
|
||||
enable_publication_point_validation_cache: false,
|
||||
enable_transport_request_prefetch: false,
|
||||
},
|
||||
)
|
||||
.expect("run tree depth-two-limited");
|
||||
assert_eq!(out.instances_processed, 3);
|
||||
assert_eq!(out.instances_failed, 0);
|
||||
assert_eq!(
|
||||
runner.called(),
|
||||
vec![
|
||||
root_manifest,
|
||||
root_manifest,
|
||||
child_manifest,
|
||||
root_manifest,
|
||||
child_manifest,
|
||||
grandchild_manifest,
|
||||
]
|
||||
);
|
||||
|
||||
let out = run_tree_serial(
|
||||
ca_handle(root_manifest),
|
||||
|
||||
@@ -1,81 +1,117 @@
|
||||
# RPKI Explorer
|
||||
|
||||
RPKI Explorer is the SPA frontend for browsing ours RP `rpki_query_service` outputs.
|
||||
RPKI Explorer is the web UI for browsing the outputs of the ours RP
|
||||
`rpki_query_service` — runs, repositories, publication points, objects,
|
||||
validation issues and evidence exports. It is a read-only query layer: it does
|
||||
not change validation logic or RP state.
|
||||
|
||||
## Scope
|
||||
## Features
|
||||
|
||||
Current MVP covers:
|
||||
- **Overview** — latest-run health at a glance: VRP/ASPA/object/PP counts,
|
||||
validation result and object type charts, top repositories, top reject
|
||||
reasons, all with drill-down links.
|
||||
- **Repositories** — paginated repository list, repository detail with sync
|
||||
phases/terminal states, publication points and scoped object browsing.
|
||||
- **Publication Points** — global PP list and PP detail (URIs, sync state,
|
||||
thisUpdate/nextUpdate, objects).
|
||||
- **Objects** — global object browser with **server-side filters**
|
||||
(`type`, `result`, `rejected`, `q` URI substring) and cursor paging.
|
||||
- **Object detail** — structured parsed projections (ROA prefixes, ASPA
|
||||
providers, manifest file list, CRL revocations, certificate fields),
|
||||
validation summary, on-demand *explain* (audit projection, non-authoritative),
|
||||
chain edges, raw DER download, object-set export.
|
||||
- **Validation** — result distribution, reject-reason distribution with
|
||||
click-to-filter, paginated issue list (`/issues`).
|
||||
- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex
|
||||
prefix), repository host/URI substring, PP URI substring — plus **VRP
|
||||
lookup**: enter an IPv4/IPv6 address or CIDR prefix to list the covering
|
||||
VRPs (prefix queries use covering semantics: VRP prefix must cover the whole
|
||||
queried prefix with `maxLength` ≥ queried length), with an optional ASN
|
||||
filter and cursor paging. ASN-only queries are not supported by the API and
|
||||
the page says so.
|
||||
- **Exports** — export job list with live polling and tarball download.
|
||||
- **Runs** — indexed run history; pin any run as the browsing context via the
|
||||
run selector in the top bar (`?run=` URL param, deep-linkable everywhere).
|
||||
- **API** — service health and endpoint reference.
|
||||
|
||||
- Overview dashboard from latest run, stats, validation reasons, and top repositories.
|
||||
- Repository -> publication point -> object lazy browser.
|
||||
- Object detail with live object record, validation summary, lazy parsed/chain/list tabs, and validation explain.
|
||||
- Exact URI lookup, raw download action, and object / publication point export actions.
|
||||
## Backend requirements
|
||||
|
||||
Known backend-dependent limits:
|
||||
`rpki_query_service` (this repository, `src/bin/rpki_query_service.rs`) with a
|
||||
query-db built by `rpki_query_indexer`. Some features need extra service flags:
|
||||
|
||||
- Parsed projection, raw download, and export success require `rpki_query_service --repo-bytes-db <path>`.
|
||||
- Without repo bytes, the UI shows explicit unavailable/error states instead of fabricating data.
|
||||
- VRP IP/prefix lookup is not part of this MVP and is tracked separately.
|
||||
- Query service does not provide CORS headers; use a same-origin proxy.
|
||||
| Feature | Requirement |
|
||||
| --- | --- |
|
||||
| Parsed projections, raw download, exports | `--repo-bytes-db <path>` |
|
||||
| New runs appear automatically | `--watch-run-root <path>` |
|
||||
|
||||
The service has **no CORS headers and no authentication** — the UI must be
|
||||
served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse
|
||||
proxy). VRP lookup needs runs indexed at query-db schema version 2 (`#122`);
|
||||
re-index older runs with `rpki_query_indexer` (or the service
|
||||
`--watch-run-root` backfill), otherwise lookup results are empty.
|
||||
|
||||
## Development
|
||||
|
||||
Start query service first:
|
||||
Start the query service (example with a local query-db):
|
||||
|
||||
```bash
|
||||
rpki_2/rpki/target/llvm-cov-target/debug/rpki_query_service \
|
||||
--query-db .scratch/rpki_explorer_m3_api/query-db \
|
||||
--listen 127.0.0.1:19571
|
||||
rpki_2/rpki/target/debug/rpki_query_service \
|
||||
--query-db /path/to/query-db \
|
||||
--repo-bytes-db /path/to/repo-bytes.db \
|
||||
--listen 127.0.0.1:9557
|
||||
```
|
||||
|
||||
Start the Vite dev server with a proxy:
|
||||
Start the Vite dev server:
|
||||
|
||||
```bash
|
||||
cd rpki_2/rpki/ui/rpki-explorer
|
||||
npm install
|
||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run dev -- --port 5173
|
||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`; default target is `http://127.0.0.1:9557`.
|
||||
The dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`
|
||||
(default `http://127.0.0.1:9557`) and serves the UI on `http://127.0.0.1:5173`.
|
||||
|
||||
## Production Build Preview
|
||||
## Production
|
||||
|
||||
```bash
|
||||
cd rpki_2/rpki/ui/rpki-explorer
|
||||
npm run build
|
||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run preview -- --port 4173
|
||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run preview # smoke-test the build
|
||||
```
|
||||
|
||||
For real deployment, serve `dist/` through a static server or reverse proxy and route `/api/v1/*` to `rpki_query_service` on the same origin.
|
||||
For real deployments serve `dist/` with any static server / reverse proxy and
|
||||
forward `/api/v1/*` to `rpki_query_service` on the same origin, e.g. nginx:
|
||||
|
||||
```nginx
|
||||
location /api/v1/ { proxy_pass http://127.0.0.1:9557; }
|
||||
location / { root /srv/rpki-explorer/dist; try_files $uri /index.html; }
|
||||
```
|
||||
|
||||
`try_files ... /index.html` (SPA fallback) is required because routing is
|
||||
client-side.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
npm audit --audit-level=high
|
||||
npm run test:e2e
|
||||
npm run typecheck # tsc project references
|
||||
npm run lint # eslint
|
||||
npm test # vitest unit tests (lib + api contract)
|
||||
npm run build # production build
|
||||
npm run test:e2e # Playwright, fully mocked backend (no service needed)
|
||||
```
|
||||
|
||||
Playwright is intentionally configured with `workers: 1`. The current query service object endpoints use lazy `report.json` scans, so parallel browser tests can create artificial backend contention and false failures.
|
||||
E2E tests intercept every `/api/v1/**` request with a fixture-backed mock
|
||||
(`tests/e2e/mockApi.ts`), so they are deterministic and need no running query
|
||||
service. They start the Vite dev server automatically (`workers: 1`).
|
||||
|
||||
## Screenshot Evidence
|
||||
|
||||
Milestone screenshots are stored under:
|
||||
## Directory layout
|
||||
|
||||
```text
|
||||
specs/develop/20260617/m*_playwright/
|
||||
```
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
src/api/ query-service client and TypeScript records
|
||||
src/app/ app shell and top-level navigation state
|
||||
src/components/layout/ sidebar/topbar shell
|
||||
src/features/overview/ latest run overview dashboard
|
||||
src/features/repositories repo -> PP -> object browser
|
||||
src/features/object-detail live object detail and workflows
|
||||
src/styles/globals.css MVP visual system
|
||||
src/api/ fetch client, zod schemas, typed endpoint functions
|
||||
src/app/ providers, router, react-query client
|
||||
src/components/ shell, data table, pills, pager, tabs, object table, …
|
||||
src/lib/ formatting, cursor pager state machine, run context, guards
|
||||
src/pages/ one file per route
|
||||
src/styles/ design tokens + base/shell/components/pages CSS
|
||||
tests/e2e/ Playwright specs + fixture-backed API mock
|
||||
```
|
||||
|
||||
517
ui/rpki-explorer/package-lock.json
generated
517
ui/rpki-explorer/package-lock.json
generated
@@ -1,17 +1,14 @@
|
||||
{
|
||||
"name": "rpki-explorer",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rpki-explorer",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@xyflow/react": "^12.8.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -22,8 +19,6 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
@@ -38,50 +33,6 @@
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -747,142 +698,6 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
|
||||
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
|
||||
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -894,14 +709,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -925,15 +732,6 @@
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
@@ -964,12 +762,6 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -991,25 +783,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -1055,7 +828,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -1501,48 +1274,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
|
||||
"integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.77",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.77",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
|
||||
"integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -1583,17 +1314,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -1617,16 +1337,6 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1692,12 +1402,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1769,13 +1473,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1804,28 +1501,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
@@ -1881,15 +1556,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -1935,41 +1601,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2001,16 +1632,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2021,14 +1642,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
@@ -2450,16 +2063,6 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -2499,14 +2102,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
@@ -2880,17 +2475,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2901,16 +2485,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -3152,36 +2726,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3311,20 +2855,6 @@
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
@@ -3456,19 +2986,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -3912,34 +3429,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rpki-explorer",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,13 +9,11 @@
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@xyflow/react": "^12.8.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -26,8 +24,6 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
|
||||
@@ -3,18 +3,24 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
outputDir: "./test-results",
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure"
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --port 5173 --strictPort",
|
||||
url: "http://127.0.0.1:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] }
|
||||
}
|
||||
]
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
102
ui/rpki-explorer/src/api/client.test.ts
Normal file
102
ui/rpki-explorer/src/api/client.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { withQuery } from "./client";
|
||||
import {
|
||||
envelopeSchema,
|
||||
objectInstanceRecordSchema,
|
||||
runRecordSchema,
|
||||
searchResultSchema,
|
||||
} from "./schemas";
|
||||
|
||||
describe("withQuery", () => {
|
||||
it("builds a query string skipping empty values", () => {
|
||||
expect(
|
||||
withQuery("/api/v1/runs/latest/objects", {
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
type: "roa",
|
||||
rejected: false,
|
||||
q: "",
|
||||
reason: undefined,
|
||||
}),
|
||||
).toBe("/api/v1/runs/latest/objects?limit=50&type=roa&rejected=false");
|
||||
});
|
||||
|
||||
it("URL-encodes values", () => {
|
||||
expect(withQuery("/x", { uri: "rsync://a/b.cer" })).toBe(
|
||||
"/x?uri=rsync%3A%2F%2Fa%2Fb.cer",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the bare path without params", () => {
|
||||
expect(withQuery("/x")).toBe("/x");
|
||||
expect(withQuery("/x", {})).toBe("/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope + record schemas", () => {
|
||||
const objectInstance = {
|
||||
objectInstanceId: "obj-1",
|
||||
uri: "rsync://repo.example/ca/roa.roa",
|
||||
sha256: "ab".repeat(32),
|
||||
objectType: "roa",
|
||||
result: "ok",
|
||||
repoId: "repo-1",
|
||||
ppId: "pp-1",
|
||||
sourceSection: "fresh",
|
||||
rejected: false,
|
||||
};
|
||||
|
||||
it("parses a list envelope and exposes the cursor", () => {
|
||||
const parsed = envelopeSchema(objectInstanceRecordSchema.array()).parse({
|
||||
data: [objectInstance],
|
||||
page: { nextCursor: "r1:0:50", limit: 50 },
|
||||
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||
});
|
||||
expect(parsed.data).toHaveLength(1);
|
||||
expect(parsed.page?.nextCursor).toBe("r1:0:50");
|
||||
});
|
||||
|
||||
it("accepts a null meta.runId (healthz and other run-less endpoints)", () => {
|
||||
const parsed = envelopeSchema(z.object({ status: z.string() })).parse({
|
||||
data: { status: "ok" },
|
||||
page: null,
|
||||
meta: { runId: null, schemaVersion: 1 },
|
||||
});
|
||||
expect(parsed.data.status).toBe("ok");
|
||||
expect(parsed.meta?.runId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unknown fields (passthrough) for forward compatibility", () => {
|
||||
const parsed = objectInstanceRecordSchema.parse({
|
||||
...objectInstance,
|
||||
futureField: { nested: true },
|
||||
});
|
||||
expect((parsed as Record<string, unknown>).futureField).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it("rejects records missing required identity fields", () => {
|
||||
expect(() =>
|
||||
objectInstanceRecordSchema.parse({ uri: "rsync://x" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("parses run records with partial counts", () => {
|
||||
const run = runRecordSchema.parse({
|
||||
runId: "run_0002",
|
||||
runSeq: 2,
|
||||
counts: { objects: 10 },
|
||||
});
|
||||
expect(run.counts?.objects).toBe(10);
|
||||
expect(run.counts?.vrps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses the unified search result", () => {
|
||||
const result = searchResultSchema.parse({
|
||||
objects: [objectInstance],
|
||||
repos: [],
|
||||
publicationPoints: [],
|
||||
});
|
||||
expect(result.objects[0]?.objectInstanceId).toBe("obj-1");
|
||||
});
|
||||
});
|
||||
@@ -1,101 +1,101 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
page: { nextCursor: string | null; limit: number } | null;
|
||||
meta: { runId: string | null; schemaVersion: number };
|
||||
}
|
||||
/**
|
||||
* Low-level fetch helpers for the rpki_query_service HTTP API.
|
||||
*
|
||||
* All JSON endpoints share the envelope shape:
|
||||
* { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null,
|
||||
* "meta": { "runId": string, "schemaVersion": number } }
|
||||
* Errors are `{ "error": string }` with a non-2xx status.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type JsonQuery = Record<string, string | number | boolean | null | undefined>;
|
||||
export type QueryParams = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
export function withQuery(path: string, query?: JsonQuery): string {
|
||||
if (!query) {
|
||||
return path;
|
||||
}
|
||||
/** Build a URL with a query string, skipping empty values. */
|
||||
export function withQuery(path: string, params?: QueryParams): string {
|
||||
if (!params) return path;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const queryString = search.toString();
|
||||
return queryString ? `${path}?${queryString}` : path;
|
||||
const qs = search.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
export async function getJson<T>(path: string, query?: JsonQuery): Promise<ApiEnvelope<T>> {
|
||||
const response = await fetch(withQuery(path, query));
|
||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
|
||||
async function parseErrorBody(res: Response): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
if (
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
"error" in body &&
|
||||
typeof (body as { error: unknown }).error === "string"
|
||||
) {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
return normalizeEnvelope<T>(body);
|
||||
} catch {
|
||||
// fall through to status text
|
||||
}
|
||||
return `HTTP ${res.status} ${res.statusText}`.trim();
|
||||
}
|
||||
|
||||
export async function postJson<T>(path: string, body?: unknown): Promise<ApiEnvelope<T>> {
|
||||
const response = await fetch(path, {
|
||||
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch (err) {
|
||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await parseErrorBody(res));
|
||||
}
|
||||
return (await res.json()) as Promise<T>;
|
||||
}
|
||||
|
||||
/** Fetch binary content (raw object bytes, export tarballs). */
|
||||
export async function apiFetchBlob(path: string): Promise<Blob> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path);
|
||||
} catch (err) {
|
||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await parseErrorBody(res));
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** POST a JSON body, returning the parsed JSON response. */
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
return apiFetch<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST"
|
||||
});
|
||||
const responseBody = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new ApiError(responseBody.error ?? `Request failed: ${response.status}`, response.status);
|
||||
}
|
||||
return normalizeEnvelope<T>(responseBody);
|
||||
}
|
||||
|
||||
export async function getBinary(path: string): Promise<Blob> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export function normalizeEnvelope<T>(value: unknown): ApiEnvelope<T> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return {
|
||||
data: value as T,
|
||||
page: null,
|
||||
meta: { runId: null, schemaVersion: 1 }
|
||||
};
|
||||
}
|
||||
const record = value as {
|
||||
data?: T;
|
||||
page?: { nextCursor?: string | null; limit?: number } | null;
|
||||
meta?: { runId?: string | null; schemaVersion?: number } | null;
|
||||
};
|
||||
return {
|
||||
data: record.data as T,
|
||||
page: record.page
|
||||
? {
|
||||
nextCursor: record.page.nextCursor ?? null,
|
||||
limit: record.page.limit ?? 0
|
||||
}
|
||||
: null,
|
||||
meta: {
|
||||
runId: record.meta?.runId ?? null,
|
||||
schemaVersion: record.meta?.schemaVersion ?? 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeApiError(error: unknown): Error {
|
||||
if (error instanceof ApiError) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error("Unknown API error");
|
||||
/** Trigger a browser download for a blob. */
|
||||
export function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { getBinary, getJson, postJson } from "./client";
|
||||
|
||||
export interface ServiceInfo {
|
||||
service: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RunRecord {
|
||||
schemaVersion: number;
|
||||
runId: string;
|
||||
runSeq: number | null;
|
||||
runDir: string;
|
||||
validationTime: string | null;
|
||||
syncMode: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
wallMs: number | null;
|
||||
artifactPaths: Record<string, string>;
|
||||
counts: {
|
||||
publicationPoints: number;
|
||||
objects: number;
|
||||
freshObjects: number;
|
||||
cachedObjects: number;
|
||||
rejectedObjects: number;
|
||||
freshRejectedObjects: number;
|
||||
cachedRejectedObjects: number;
|
||||
trustAnchors: number;
|
||||
vrps: number;
|
||||
aspas: number;
|
||||
warnings: number;
|
||||
};
|
||||
indexStatus: string;
|
||||
indexError: string | null;
|
||||
}
|
||||
|
||||
export interface RepositoryRecord {
|
||||
schemaVersion: number;
|
||||
runId: string;
|
||||
repoId: string;
|
||||
uri: string;
|
||||
host: string;
|
||||
transport: string;
|
||||
publicationPoints: number;
|
||||
objects: number;
|
||||
rejectedObjects: number;
|
||||
downloadBytes: number | null;
|
||||
syncDurationMsTotal: number;
|
||||
phases: Record<string, number>;
|
||||
terminalStates: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PublicationPointRecord {
|
||||
schemaVersion: number;
|
||||
runId: string;
|
||||
ppId: string;
|
||||
repoId: string;
|
||||
nodeId: number | null;
|
||||
parentNodeId: number | null;
|
||||
rsyncBaseUri: string | null;
|
||||
manifestRsyncUri: string | null;
|
||||
publicationPointRsyncUri: string | null;
|
||||
rrdpNotificationUri: string | null;
|
||||
source: string | null;
|
||||
repoSyncSource: string | null;
|
||||
repoSyncPhase: string | null;
|
||||
repoSyncDurationMs: number | null;
|
||||
repoSyncError: string | null;
|
||||
repoTerminalState: string | null;
|
||||
thisUpdate: string | null;
|
||||
nextUpdate: string | null;
|
||||
verifiedAt: string | null;
|
||||
objects: number;
|
||||
rejectedObjects: number;
|
||||
warnings: number;
|
||||
}
|
||||
|
||||
export interface ObjectInstanceRecord {
|
||||
schemaVersion: number;
|
||||
runId: string;
|
||||
objectInstanceId: string;
|
||||
uri: string;
|
||||
uriHash: string;
|
||||
sha256: string;
|
||||
objectType: string;
|
||||
result: string;
|
||||
detailSummary: string | null;
|
||||
repoId: string;
|
||||
ppId: string;
|
||||
sourceSection: string;
|
||||
rejected: boolean;
|
||||
rejectReason: string | null;
|
||||
}
|
||||
|
||||
export interface ObjectProjectionRecord {
|
||||
schemaVersion?: number;
|
||||
objectType?: string;
|
||||
sha256?: string;
|
||||
uri?: string;
|
||||
parseStatus?: string;
|
||||
errorSummary?: string | null;
|
||||
projection?: unknown;
|
||||
}
|
||||
|
||||
export interface ValidationIssueRecord {
|
||||
stage?: string;
|
||||
severity?: string;
|
||||
reasonCode?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ObjectValidationRecord {
|
||||
objectInstanceId: string;
|
||||
uri: string;
|
||||
sha256: string;
|
||||
objectType: string;
|
||||
finalStatus: string;
|
||||
auditResult: string;
|
||||
detailSummary: string | null;
|
||||
rejected: boolean;
|
||||
rejectReason: string | null;
|
||||
sourceSection: string;
|
||||
explainAvailable: boolean;
|
||||
authoritative: boolean;
|
||||
fileValidation: {
|
||||
status: string;
|
||||
stage: string;
|
||||
issues: ValidationIssueRecord[];
|
||||
detailSummary: string | null;
|
||||
};
|
||||
chainValidation: {
|
||||
status: string;
|
||||
stage: string;
|
||||
issues: ValidationIssueRecord[];
|
||||
note?: string;
|
||||
edgesPage?: { nextCursor: string | null; limit: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChainEdgeRecord {
|
||||
relation: string;
|
||||
fromUri: string;
|
||||
toUri: string;
|
||||
toObjectInstanceId: string | null;
|
||||
toSha256: string | null;
|
||||
status: string;
|
||||
evidence: unknown;
|
||||
}
|
||||
|
||||
export interface ValidationExplainRecord {
|
||||
schemaVersion: number;
|
||||
explainVersion: number;
|
||||
runId: string;
|
||||
objectInstanceId: string;
|
||||
uri: string;
|
||||
sha256: string;
|
||||
objectType: string;
|
||||
finalStatus: string;
|
||||
auditResult: string;
|
||||
detailSummary: string | null;
|
||||
authoritative: boolean;
|
||||
explainMode: string;
|
||||
generatedAt: string;
|
||||
parsevalidate: unknown;
|
||||
chainvalidate: unknown;
|
||||
chainEdges: ChainEdgeRecord[];
|
||||
}
|
||||
|
||||
export interface ObjectUriIndexRecord {
|
||||
runId: string;
|
||||
uri: string;
|
||||
sha256: string;
|
||||
objectInstanceId: string;
|
||||
repoId: string;
|
||||
ppId: string;
|
||||
}
|
||||
|
||||
export interface ExportJobRecord {
|
||||
schemaVersion: number;
|
||||
jobId: string;
|
||||
runId: string;
|
||||
scope: string;
|
||||
repoId: string | null;
|
||||
ppId: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
outputPath: string | null;
|
||||
objectCount: number;
|
||||
bytesWritten: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type CountsByKey = Record<string, number>;
|
||||
|
||||
export async function getServiceInfo() {
|
||||
return getJson<ServiceInfo>("/api/v1");
|
||||
}
|
||||
|
||||
export async function getLatestRun() {
|
||||
return getJson<RunRecord>("/api/v1/latest_run");
|
||||
}
|
||||
|
||||
export async function getRunSummary(runId: string) {
|
||||
return getJson<Record<string, number>>(`/api/v1/runs/${runId}/summary`);
|
||||
}
|
||||
|
||||
export async function getStatsObjectTypes(runId: string) {
|
||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/object-types`);
|
||||
}
|
||||
|
||||
export async function getStatsValidation(runId: string) {
|
||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/validation`);
|
||||
}
|
||||
|
||||
export async function getStatsReasons(runId: string) {
|
||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/reasons`);
|
||||
}
|
||||
|
||||
export async function getStatsDownloads(runId: string) {
|
||||
return getJson<Record<string, unknown>>(`/api/v1/runs/${runId}/stats/downloads`);
|
||||
}
|
||||
|
||||
export async function listRepos(runId: string, limit = 5, cursor?: string | null) {
|
||||
return getJson<RepositoryRecord[]>(`/api/v1/runs/${runId}/repos`, { limit, cursor: cursor ?? undefined });
|
||||
}
|
||||
|
||||
export async function listPublicationPointsForRepo(runId: string, repoId: string, limit = 50, cursor?: string | null) {
|
||||
return getJson<PublicationPointRecord[]>(`/api/v1/runs/${runId}/repos/${repoId}/publication-points`, {
|
||||
limit,
|
||||
cursor: cursor ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function listObjectsForPublicationPoint(runId: string, ppId: string, limit = 50, cursor?: string | null) {
|
||||
return getJson<ObjectInstanceRecord[]>(`/api/v1/runs/${runId}/publication-points/${ppId}/objects`, {
|
||||
limit,
|
||||
cursor: cursor ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getObject(runId: string, objectInstanceId: string) {
|
||||
return getJson<ObjectInstanceRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}`);
|
||||
}
|
||||
|
||||
export async function getObjectByUri(runId: string, uri: string) {
|
||||
return getJson<ObjectUriIndexRecord>(`/api/v1/runs/${runId}/objects/by-uri`, { uri });
|
||||
}
|
||||
|
||||
export async function getObjectParsed(runId: string, objectInstanceId: string) {
|
||||
return getJson<ObjectProjectionRecord | null>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed`);
|
||||
}
|
||||
|
||||
export async function getObjectValidation(runId: string, objectInstanceId: string) {
|
||||
return getJson<ObjectValidationRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation`);
|
||||
}
|
||||
|
||||
export async function getObjectChain(runId: string, objectInstanceId: string) {
|
||||
return getJson<ChainEdgeRecord[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/chain`);
|
||||
}
|
||||
|
||||
export async function listManifestFiles(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
|
||||
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/manifest-files`, {
|
||||
limit,
|
||||
cursor: cursor ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRevokedCertificates(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
|
||||
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/revoked-certs`, {
|
||||
limit,
|
||||
cursor: cursor ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function explainObjectValidation(runId: string, objectInstanceId: string, forceRefresh = false) {
|
||||
return postJson<ValidationExplainRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation/explain`, {
|
||||
forceRefresh
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadRawObject(runId: string, objectInstanceId: string) {
|
||||
return getBinary(`/api/v1/runs/${runId}/objects/${objectInstanceId}/raw`);
|
||||
}
|
||||
|
||||
export async function createObjectSetExport(runId: string, objectInstanceIds: string[]) {
|
||||
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
|
||||
objectInstanceIds,
|
||||
scope: "object_set"
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPublicationPointExport(runId: string, ppId: string) {
|
||||
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
|
||||
ppId,
|
||||
scope: "publication_point"
|
||||
});
|
||||
}
|
||||
|
||||
export async function getExportJob(runId: string, jobId: string) {
|
||||
return getJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports/${jobId}`);
|
||||
}
|
||||
318
ui/rpki-explorer/src/api/schemas.ts
Normal file
318
ui/rpki-explorer/src/api/schemas.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Zod schemas describing the rpki_query_service API contract.
|
||||
*
|
||||
* Schemas are intentionally `.passthrough()` so additive backend changes do
|
||||
* not break the UI; fields the UI depends on are typed strictly.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Records */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const runCountsSchema = z
|
||||
.object({
|
||||
publicationPoints: z.number().optional(),
|
||||
objects: z.number().optional(),
|
||||
freshObjects: z.number().optional(),
|
||||
cachedObjects: z.number().optional(),
|
||||
rejectedObjects: z.number().optional(),
|
||||
freshRejectedObjects: z.number().optional(),
|
||||
cachedRejectedObjects: z.number().optional(),
|
||||
trustAnchors: z.number().optional(),
|
||||
vrps: z.number().optional(),
|
||||
aspas: z.number().optional(),
|
||||
warnings: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const runRecordSchema = z
|
||||
.object({
|
||||
schemaVersion: z.number().optional(),
|
||||
runId: z.string(),
|
||||
runSeq: z.number().nullish(),
|
||||
runDir: z.string().optional(),
|
||||
validationTime: z.string().nullish(),
|
||||
syncMode: z.string().nullish(),
|
||||
startedAt: z.string().nullish(),
|
||||
finishedAt: z.string().nullish(),
|
||||
wallMs: z.number().nullish(),
|
||||
maxRssKb: z.number().nullish(),
|
||||
artifactPaths: z.record(z.string()).optional(),
|
||||
counts: runCountsSchema.optional(),
|
||||
indexStatus: z.string().optional(),
|
||||
indexError: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const repositoryRecordSchema = z
|
||||
.object({
|
||||
repoId: z.string(),
|
||||
uri: z.string(),
|
||||
host: z.string(),
|
||||
transport: z.string().nullish(),
|
||||
publicationPoints: z.number().nullish(),
|
||||
objects: z.number().nullish(),
|
||||
rejectedObjects: z.number().nullish(),
|
||||
downloadBytes: z.number().nullish(),
|
||||
syncDurationMsTotal: z.number().nullish(),
|
||||
phases: z.record(z.number()).optional(),
|
||||
terminalStates: z.record(z.number()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const publicationPointRecordSchema = z
|
||||
.object({
|
||||
ppId: z.string(),
|
||||
repoId: z.string(),
|
||||
nodeId: z.number().nullish(),
|
||||
parentNodeId: z.number().nullish(),
|
||||
rsyncBaseUri: z.string().nullish(),
|
||||
manifestRsyncUri: z.string().nullish(),
|
||||
publicationPointRsyncUri: z.string().nullish(),
|
||||
rrdpNotificationUri: z.string().nullish(),
|
||||
source: z.string().nullish(),
|
||||
repoSyncSource: z.string().nullish(),
|
||||
repoSyncPhase: z.string().nullish(),
|
||||
repoSyncDurationMs: z.number().nullish(),
|
||||
repoSyncError: z.string().nullish(),
|
||||
repoTerminalState: z.string().nullish(),
|
||||
thisUpdate: z.string().nullish(),
|
||||
nextUpdate: z.string().nullish(),
|
||||
verifiedAt: z.string().nullish(),
|
||||
objects: z.number().nullish(),
|
||||
rejectedObjects: z.number().nullish(),
|
||||
warnings: z.number().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const objectInstanceRecordSchema = z
|
||||
.object({
|
||||
objectInstanceId: z.string(),
|
||||
uri: z.string(),
|
||||
uriHash: z.string().optional(),
|
||||
sha256: z.string().optional(),
|
||||
objectType: z.string(),
|
||||
result: z.string().nullish(),
|
||||
detailSummary: z.string().nullish(),
|
||||
repoId: z.string(),
|
||||
ppId: z.string(),
|
||||
sourceSection: z.string().nullish(),
|
||||
rejected: z.boolean().optional(),
|
||||
rejectReason: z.string().nullish(),
|
||||
sizeBytes: z.number().nullish(),
|
||||
rawAvailable: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const chainEdgeRecordSchema = z
|
||||
.object({
|
||||
relation: z.string(),
|
||||
fromUri: z.string().nullish(),
|
||||
toUri: z.string().nullish(),
|
||||
toObjectInstanceId: z.string().nullish(),
|
||||
toSha256: z.string().nullish(),
|
||||
status: z.string().nullish(),
|
||||
evidence: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Sparse record returned by /objects/by-uri (index hit, no lazy scan). */
|
||||
export const objectUriIndexRecordSchema = z
|
||||
.object({
|
||||
uri: z.string(),
|
||||
sha256: z.string(),
|
||||
objectInstanceId: z.string(),
|
||||
repoId: z.string(),
|
||||
ppId: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const exportJobRecordSchema = z
|
||||
.object({
|
||||
jobId: z.string(),
|
||||
runId: z.string(),
|
||||
scope: z.string(),
|
||||
repoId: z.string().nullish(),
|
||||
ppId: z.string().nullish(),
|
||||
status: z.string(),
|
||||
createdAt: z.string().nullish(),
|
||||
finishedAt: z.string().nullish(),
|
||||
outputPath: z.string().nullish(),
|
||||
objectCount: z.number().nullish(),
|
||||
bytesWritten: z.number().nullish(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const projectionRecordSchema = z
|
||||
.object({
|
||||
schemaVersion: z.number().optional(),
|
||||
sha256: z.string().optional(),
|
||||
objectType: z.string().optional(),
|
||||
parseStatus: z.string().optional(),
|
||||
errorSummary: z.string().nullish(),
|
||||
projection: z.record(z.unknown()).nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const validationIssueSchema = z
|
||||
.object({
|
||||
severity: z.string().nullish(),
|
||||
reasonCode: z.string().nullish(),
|
||||
reasonText: z.string().nullish(),
|
||||
rfcRefs: z.array(z.string()).optional(),
|
||||
evidence: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const validationStageSchema = z
|
||||
.object({
|
||||
status: z.string().nullish(),
|
||||
stage: z.string().nullish(),
|
||||
mode: z.string().nullish(),
|
||||
projectionStatus: z.string().nullish(),
|
||||
issues: z.array(validationIssueSchema).optional(),
|
||||
detailSummary: z.string().nullish(),
|
||||
edgesCount: z.number().nullish(),
|
||||
note: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const validationSummarySchema = z
|
||||
.object({
|
||||
finalStatus: z.string().nullish(),
|
||||
auditResult: z.string().nullish(),
|
||||
detailSummary: z.string().nullish(),
|
||||
parsevalidate: validationStageSchema.nullish(),
|
||||
chainvalidate: validationStageSchema.nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const explainRecordSchema = z
|
||||
.object({
|
||||
finalStatus: z.string().nullish(),
|
||||
auditResult: z.string().nullish(),
|
||||
authoritative: z.boolean().optional(),
|
||||
explainMode: z.string().nullish(),
|
||||
parsevalidate: validationStageSchema.nullish(),
|
||||
chainvalidate: validationStageSchema.nullish(),
|
||||
chainEdges: z.array(chainEdgeRecordSchema).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const searchResultSchema = z
|
||||
.object({
|
||||
objects: z.array(objectInstanceRecordSchema),
|
||||
repos: z.array(repositoryRecordSchema),
|
||||
publicationPoints: z.array(publicationPointRecordSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const vrpRecordSchema = z
|
||||
.object({
|
||||
runId: z.string(),
|
||||
asn: z.number(),
|
||||
prefix: z.string(),
|
||||
maxLength: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const healthSchema = z
|
||||
.object({
|
||||
status: z.string(),
|
||||
service: z.string().optional(),
|
||||
version: z.number().optional(),
|
||||
latestReadyRun: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const serviceInfoSchema = z
|
||||
.object({
|
||||
service: z.string(),
|
||||
version: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const manifestFileEntrySchema = z
|
||||
.object({
|
||||
fileName: z.string(),
|
||||
hashHex: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const revokedCertEntrySchema = z
|
||||
.object({
|
||||
serialNumberHex: z.string().nullish(),
|
||||
serialNumber: z.string().nullish(),
|
||||
revocationDate: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const runSummarySchema = z
|
||||
.object({
|
||||
publicationPoints: z.number().optional(),
|
||||
objects: z.number().optional(),
|
||||
repos: z.number().optional(),
|
||||
vrps: z.number().optional(),
|
||||
aspas: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const statsMapSchema = z.record(z.number());
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Envelope */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const pageSchema = z.object({
|
||||
nextCursor: z.string().nullable(),
|
||||
limit: z.number(),
|
||||
});
|
||||
|
||||
const metaSchema = z
|
||||
.object({
|
||||
runId: z.string().nullish(),
|
||||
schemaVersion: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export function envelopeSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||
return z
|
||||
.object({
|
||||
data: dataSchema,
|
||||
page: pageSchema.nullish(),
|
||||
meta: metaSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inferred types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type RunCounts = z.infer<typeof runCountsSchema>;
|
||||
export type RunRecord = z.infer<typeof runRecordSchema>;
|
||||
export type RepositoryRecord = z.infer<typeof repositoryRecordSchema>;
|
||||
export type PublicationPointRecord = z.infer<typeof publicationPointRecordSchema>;
|
||||
export type ObjectInstanceRecord = z.infer<typeof objectInstanceRecordSchema>;
|
||||
export type ChainEdgeRecord = z.infer<typeof chainEdgeRecordSchema>;
|
||||
export type ExportJobRecord = z.infer<typeof exportJobRecordSchema>;
|
||||
export type ProjectionRecord = z.infer<typeof projectionRecordSchema>;
|
||||
export type ValidationIssue = z.infer<typeof validationIssueSchema>;
|
||||
export type ValidationStage = z.infer<typeof validationStageSchema>;
|
||||
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
|
||||
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
|
||||
export type SearchResult = z.infer<typeof searchResultSchema>;
|
||||
export type VrpRecord = z.infer<typeof vrpRecordSchema>;
|
||||
export type HealthStatus = z.infer<typeof healthSchema>;
|
||||
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
|
||||
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;
|
||||
export type RevokedCertEntry = z.infer<typeof revokedCertEntrySchema>;
|
||||
export type RunSummary = z.infer<typeof runSummarySchema>;
|
||||
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
runId: string | undefined;
|
||||
}
|
||||
472
ui/rpki-explorer/src/api/service.ts
Normal file
472
ui/rpki-explorer/src/api/service.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Typed endpoint functions for the rpki_query_service API.
|
||||
* Every function validates the response envelope with zod before returning.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { apiFetch, apiPost, withQuery, type QueryParams } from "./client";
|
||||
import {
|
||||
chainEdgeRecordSchema,
|
||||
envelopeSchema,
|
||||
exportJobRecordSchema,
|
||||
explainRecordSchema,
|
||||
healthSchema,
|
||||
manifestFileEntrySchema,
|
||||
objectInstanceRecordSchema,
|
||||
projectionRecordSchema,
|
||||
publicationPointRecordSchema,
|
||||
repositoryRecordSchema,
|
||||
revokedCertEntrySchema,
|
||||
runRecordSchema,
|
||||
runSummarySchema,
|
||||
searchResultSchema,
|
||||
serviceInfoSchema,
|
||||
statsMapSchema,
|
||||
validationSummarySchema,
|
||||
vrpRecordSchema,
|
||||
type ChainEdgeRecord,
|
||||
type ExportJobRecord,
|
||||
type ExplainRecord,
|
||||
type HealthStatus,
|
||||
type ListResult,
|
||||
type ManifestFileEntry,
|
||||
type ObjectInstanceRecord,
|
||||
type ProjectionRecord,
|
||||
type PublicationPointRecord,
|
||||
type RepositoryRecord,
|
||||
type RevokedCertEntry,
|
||||
type RunRecord,
|
||||
type RunSummary,
|
||||
type SearchResult,
|
||||
type ServiceInfo,
|
||||
type ValidationSummary,
|
||||
type VrpRecord,
|
||||
} from "./schemas";
|
||||
|
||||
const API = "/api/v1";
|
||||
|
||||
function runBase(runId: string): string {
|
||||
return `${API}/runs/${encodeURIComponent(runId)}`;
|
||||
}
|
||||
|
||||
async function getData<S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
params?: QueryParams,
|
||||
): Promise<z.infer<S>> {
|
||||
const raw = await apiFetch<unknown>(withQuery(path, params));
|
||||
return envelopeSchema(schema).parse(raw).data;
|
||||
}
|
||||
|
||||
async function getList<S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
params?: QueryParams,
|
||||
): Promise<ListResult<z.infer<S>>> {
|
||||
const raw = await apiFetch<unknown>(withQuery(path, params));
|
||||
const parsed = envelopeSchema(z.array(schema)).parse(raw);
|
||||
return {
|
||||
items: parsed.data,
|
||||
nextCursor: parsed.page?.nextCursor ?? null,
|
||||
runId: parsed.meta?.runId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Service / health */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function getServiceInfo(): Promise<ServiceInfo> {
|
||||
return getData(`${API}`, serviceInfoSchema);
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
return getData(`${API}/healthz`, healthSchema);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Runs */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type PageParams = {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
};
|
||||
|
||||
export function listRuns(params: PageParams = {}): Promise<ListResult<RunRecord>> {
|
||||
return getList(`${API}/runs`, runRecordSchema, params);
|
||||
}
|
||||
|
||||
export function getLatestRun(): Promise<RunRecord> {
|
||||
return getData(`${API}/latest_run`, runRecordSchema);
|
||||
}
|
||||
|
||||
export function getRun(runId: string): Promise<RunRecord> {
|
||||
return getData(runBase(runId), runRecordSchema);
|
||||
}
|
||||
|
||||
export function getRunArtifacts(runId: string): Promise<Record<string, string>> {
|
||||
return getData(`${runBase(runId)}/artifacts`, z.record(z.string()));
|
||||
}
|
||||
|
||||
export function getRunSummary(runId: string): Promise<RunSummary> {
|
||||
return getData(`${runBase(runId)}/summary`, runSummarySchema);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Repositories */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function listRepos(
|
||||
runId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<RepositoryRecord>> {
|
||||
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
||||
}
|
||||
|
||||
export function getRepo(runId: string, repoId: string): Promise<RepositoryRecord> {
|
||||
return getData(
|
||||
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}`,
|
||||
repositoryRecordSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export interface RepoStats {
|
||||
publicationPoints?: number;
|
||||
objects?: number;
|
||||
rejectedObjects?: number;
|
||||
syncDurationMsTotal?: number;
|
||||
phases?: Record<string, number>;
|
||||
terminalStates?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function getRepoStats(runId: string, repoId: string): Promise<RepoStats> {
|
||||
return getData(
|
||||
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/stats`,
|
||||
z
|
||||
.object({
|
||||
publicationPoints: z.number().optional(),
|
||||
objects: z.number().optional(),
|
||||
rejectedObjects: z.number().optional(),
|
||||
syncDurationMsTotal: z.number().optional(),
|
||||
phases: z.record(z.number()).optional(),
|
||||
terminalStates: z.record(z.number()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Publication points */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function listPublicationPoints(
|
||||
runId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<PublicationPointRecord>> {
|
||||
return getList(`${runBase(runId)}/publication-points`, publicationPointRecordSchema, params);
|
||||
}
|
||||
|
||||
export function listRepoPublicationPoints(
|
||||
runId: string,
|
||||
repoId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<PublicationPointRecord>> {
|
||||
return getList(
|
||||
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/publication-points`,
|
||||
publicationPointRecordSchema,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicationPoint(
|
||||
runId: string,
|
||||
ppId: string,
|
||||
): Promise<PublicationPointRecord> {
|
||||
return getData(
|
||||
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}`,
|
||||
publicationPointRecordSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PpStats {
|
||||
objects?: number;
|
||||
rejectedObjects?: number;
|
||||
warnings?: number;
|
||||
repoSyncSource?: string;
|
||||
repoSyncPhase?: string;
|
||||
repoSyncDurationMs?: number;
|
||||
repoTerminalState?: string;
|
||||
}
|
||||
|
||||
export function getPublicationPointStats(runId: string, ppId: string): Promise<PpStats> {
|
||||
return getData(
|
||||
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/stats`,
|
||||
z
|
||||
.object({
|
||||
objects: z.number().optional(),
|
||||
rejectedObjects: z.number().optional(),
|
||||
warnings: z.number().optional(),
|
||||
repoSyncSource: z.string().optional(),
|
||||
repoSyncPhase: z.string().optional(),
|
||||
repoSyncDurationMs: z.number().optional(),
|
||||
repoTerminalState: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Objects */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ObjectFilters extends PageParams {
|
||||
type?: string;
|
||||
result?: string;
|
||||
rejected?: boolean;
|
||||
reason?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
function filterParams(filters: ObjectFilters): QueryParams {
|
||||
return {
|
||||
limit: filters.limit,
|
||||
cursor: filters.cursor,
|
||||
type: filters.type,
|
||||
result: filters.result,
|
||||
rejected: filters.rejected === undefined ? undefined : String(filters.rejected),
|
||||
reason: filters.reason,
|
||||
q: filters.q,
|
||||
};
|
||||
}
|
||||
|
||||
export function listObjects(
|
||||
runId: string,
|
||||
filters: ObjectFilters = {},
|
||||
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||
return getList(`${runBase(runId)}/objects`, objectInstanceRecordSchema, filterParams(filters));
|
||||
}
|
||||
|
||||
export function listRepoObjects(
|
||||
runId: string,
|
||||
repoId: string,
|
||||
filters: ObjectFilters = {},
|
||||
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||
return getList(
|
||||
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/objects`,
|
||||
objectInstanceRecordSchema,
|
||||
filterParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
export function listPublicationPointObjects(
|
||||
runId: string,
|
||||
ppId: string,
|
||||
filters: ObjectFilters = {},
|
||||
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||
return getList(
|
||||
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/objects`,
|
||||
objectInstanceRecordSchema,
|
||||
filterParams(filters),
|
||||
);
|
||||
}
|
||||
|
||||
export interface IssueFilters extends PageParams {
|
||||
reason?: string;
|
||||
type?: string;
|
||||
repoId?: string;
|
||||
ppId?: string;
|
||||
}
|
||||
|
||||
export function listIssues(
|
||||
runId: string,
|
||||
filters: IssueFilters = {},
|
||||
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||
return getList(`${runBase(runId)}/issues`, objectInstanceRecordSchema, {
|
||||
limit: filters.limit,
|
||||
cursor: filters.cursor,
|
||||
reason: filters.reason,
|
||||
type: filters.type,
|
||||
repoId: filters.repoId,
|
||||
ppId: filters.ppId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getObjectByUri(
|
||||
runId: string,
|
||||
uri: string,
|
||||
): Promise<ObjectInstanceRecord> {
|
||||
return getData(`${runBase(runId)}/objects/by-uri`, objectInstanceRecordSchema, { uri });
|
||||
}
|
||||
|
||||
export function getObject(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
): Promise<ObjectInstanceRecord> {
|
||||
return getData(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}`,
|
||||
objectInstanceRecordSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export function getObjectProjection(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
): Promise<ProjectionRecord | null> {
|
||||
return getData(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed`,
|
||||
projectionRecordSchema.nullable(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getObjectValidation(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
): Promise<ValidationSummary> {
|
||||
return getData(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation`,
|
||||
validationSummarySchema,
|
||||
);
|
||||
}
|
||||
|
||||
export function getObjectChain(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
): Promise<ChainEdgeRecord[]> {
|
||||
return getData(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/chain`,
|
||||
z.array(chainEdgeRecordSchema),
|
||||
);
|
||||
}
|
||||
|
||||
export function listManifestFiles(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<ManifestFileEntry>> {
|
||||
return getList(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/manifest-files`,
|
||||
manifestFileEntrySchema,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export function listRevokedCertificates(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<RevokedCertEntry>> {
|
||||
return getList(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/revoked-certs`,
|
||||
revokedCertEntrySchema,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export function explainObjectValidation(
|
||||
runId: string,
|
||||
objectInstanceId: string,
|
||||
options: { forceRefresh?: boolean } = {},
|
||||
): Promise<ExplainRecord> {
|
||||
return apiPost<unknown>(
|
||||
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation/explain`,
|
||||
{ forceRefresh: options.forceRefresh ?? false },
|
||||
).then((raw) => envelopeSchema(explainRecordSchema).parse(raw).data);
|
||||
}
|
||||
|
||||
export function rawObjectUrl(runId: string, objectInstanceId: string): string {
|
||||
return `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/raw`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Search */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function searchRun(
|
||||
runId: string,
|
||||
q: string,
|
||||
limit = 10,
|
||||
): Promise<SearchResult> {
|
||||
return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VRP lookup */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type VrpLookupParams = PageParams & {
|
||||
/** Exact IPv4/IPv6 address (mutually exclusive with `prefix`). */
|
||||
ip?: string;
|
||||
/** CIDR; returns VRPs covering the whole prefix (maxLength semantics). */
|
||||
prefix?: string;
|
||||
/** Optional ASN filter applied on top of ip/prefix. */
|
||||
asn?: number;
|
||||
};
|
||||
|
||||
export function lookupVrps(
|
||||
runId: string,
|
||||
params: VrpLookupParams,
|
||||
): Promise<ListResult<VrpRecord>> {
|
||||
return getList(`${runBase(runId)}/vrps/lookup`, vrpRecordSchema, params);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Stats */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function getStatsObjectTypes(runId: string): Promise<Record<string, number>> {
|
||||
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
||||
}
|
||||
|
||||
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
||||
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
||||
}
|
||||
|
||||
export function getStatsReasons(runId: string): Promise<Record<string, number>> {
|
||||
return getData(`${runBase(runId)}/stats/reasons`, statsMapSchema);
|
||||
}
|
||||
|
||||
export function getStatsDownloads(runId: string): Promise<Record<string, unknown>> {
|
||||
return getData(`${runBase(runId)}/stats/downloads`, z.record(z.unknown()));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ExportRequest {
|
||||
scope: "repo" | "publication_point" | "object_set";
|
||||
repoId?: string;
|
||||
ppId?: string;
|
||||
objectInstanceIds?: string[];
|
||||
}
|
||||
|
||||
export function createExport(
|
||||
runId: string,
|
||||
request: ExportRequest,
|
||||
): Promise<ExportJobRecord> {
|
||||
const body: Record<string, unknown> = { scope: request.scope };
|
||||
if (request.repoId) body.repoId = request.repoId;
|
||||
if (request.ppId) body.ppId = request.ppId;
|
||||
if (request.objectInstanceIds) body.objectInstanceIds = request.objectInstanceIds;
|
||||
return apiPost<unknown>(`${runBase(runId)}/exports`, body).then(
|
||||
(raw) => envelopeSchema(exportJobRecordSchema).parse(raw).data,
|
||||
);
|
||||
}
|
||||
|
||||
export function listExports(
|
||||
runId: string,
|
||||
params: PageParams = {},
|
||||
): Promise<ListResult<ExportJobRecord>> {
|
||||
return getList(`${runBase(runId)}/exports`, exportJobRecordSchema, params);
|
||||
}
|
||||
|
||||
export function getExportJob(runId: string, jobId: string): Promise<ExportJobRecord> {
|
||||
return getData(
|
||||
`${runBase(runId)}/exports/${encodeURIComponent(jobId)}`,
|
||||
exportJobRecordSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export function exportDownloadUrl(runId: string, jobId: string): string {
|
||||
return `${runBase(runId)}/exports/${encodeURIComponent(jobId)}/download`;
|
||||
}
|
||||
@@ -1,103 +1,48 @@
|
||||
import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Shell } from "../components/layout/Shell";
|
||||
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
|
||||
import { OverviewPage } from "../features/overview/OverviewPage";
|
||||
import { RepositoriesPage } from "../features/repositories/RepositoriesPage";
|
||||
import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Shell } from "../components/Shell";
|
||||
import { LoadingBlock } from "../components/StateBlock";
|
||||
|
||||
type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api";
|
||||
|
||||
const navigationItems = [
|
||||
{ id: "overview", label: "Overview", icon: Home },
|
||||
{ id: "repositories", label: "Repositories", icon: Database },
|
||||
{ id: "publication-points", label: "Publication Points", icon: GitBranch },
|
||||
{ id: "objects", label: "Objects", icon: Braces },
|
||||
{ id: "validation", label: "Validation", icon: ShieldCheck },
|
||||
{ id: "exports", label: "Exports", icon: PackageOpen },
|
||||
{ id: "runs", label: "Runs", icon: Activity },
|
||||
{ id: "api", label: "API", icon: FileSearch }
|
||||
] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>;
|
||||
|
||||
const comingSoonCopy: Record<Exclude<ViewId, "overview" | "repositories" | "objects">, { title: string; description: string; alternative: string }> = {
|
||||
"publication-points": {
|
||||
title: "Publication Points",
|
||||
description: "Dedicated publication point analytics are not implemented yet.",
|
||||
alternative: "Use Repositories to drill down from repository to publication point and object rows."
|
||||
},
|
||||
validation: {
|
||||
title: "Validation",
|
||||
description: "A dedicated validation investigation workspace is planned but not available in this MVP.",
|
||||
alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence."
|
||||
},
|
||||
exports: {
|
||||
title: "Exports",
|
||||
description: "A standalone export job browser is planned but not available yet.",
|
||||
alternative: "Use Object Detail to trigger object or publication point export workflows."
|
||||
},
|
||||
runs: {
|
||||
title: "Runs",
|
||||
description: "Historical run navigation is not implemented in the current Explorer UI.",
|
||||
alternative: "The current view always uses the latest indexed run from query service."
|
||||
},
|
||||
api: {
|
||||
title: "API",
|
||||
description: "The API reference page is planned but not implemented yet.",
|
||||
alternative: "Use the project README and query service endpoints while the UI reference page is pending."
|
||||
}
|
||||
};
|
||||
const OverviewPage = lazy(() => import("../pages/OverviewPage"));
|
||||
const RepositoriesPage = lazy(() => import("../pages/RepositoriesPage"));
|
||||
const RepositoryDetailPage = lazy(() => import("../pages/RepositoryDetailPage"));
|
||||
const PublicationPointsPage = lazy(() => import("../pages/PublicationPointsPage"));
|
||||
const PublicationPointDetailPage = lazy(
|
||||
() => import("../pages/PublicationPointDetailPage"),
|
||||
);
|
||||
const ObjectsPage = lazy(() => import("../pages/ObjectsPage"));
|
||||
const ObjectDetailPage = lazy(() => import("../pages/ObjectDetailPage"));
|
||||
const ValidationPage = lazy(() => import("../pages/ValidationPage"));
|
||||
const SearchPage = lazy(() => import("../pages/SearchPage"));
|
||||
const ExportsPage = lazy(() => import("../pages/ExportsPage"));
|
||||
const RunsPage = lazy(() => import("../pages/RunsPage"));
|
||||
const ApiStatusPage = lazy(() => import("../pages/ApiStatusPage"));
|
||||
const NotFoundPage = lazy(() => import("../pages/NotFoundPage"));
|
||||
|
||||
export function App() {
|
||||
const [activeView, setActiveView] = useState<ViewId>("overview");
|
||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
|
||||
const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState);
|
||||
|
||||
const openObject = (objectInstanceId: string) => {
|
||||
setSelectedObjectInstanceId(objectInstanceId);
|
||||
setActiveView("objects");
|
||||
};
|
||||
|
||||
const renderActiveView = () => {
|
||||
if (activeView === "objects") {
|
||||
return <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} />;
|
||||
}
|
||||
if (activeView === "repositories") {
|
||||
return (
|
||||
<RepositoriesPage
|
||||
browserState={repositoryBrowserState}
|
||||
onBrowserStateChange={setRepositoryBrowserState}
|
||||
onOpenObject={openObject}
|
||||
<Shell>
|
||||
<Suspense fallback={<LoadingBlock label="Loading page…" />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<OverviewPage />} />
|
||||
<Route path="/repositories" element={<RepositoriesPage />} />
|
||||
<Route path="/repositories/:repoId" element={<RepositoryDetailPage />} />
|
||||
<Route path="/publication-points" element={<PublicationPointsPage />} />
|
||||
<Route
|
||||
path="/publication-points/:ppId"
|
||||
element={<PublicationPointDetailPage />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (activeView === "overview") {
|
||||
return <OverviewPage />;
|
||||
}
|
||||
return <ComingSoonPage {...comingSoonCopy[activeView]} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
|
||||
{renderActiveView()}
|
||||
<Route path="/objects" element={<ObjectsPage />} />
|
||||
<Route path="/objects/:objectInstanceId" element={<ObjectDetailPage />} />
|
||||
<Route path="/validation" element={<ValidationPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/exports" element={<ExportsPage />} />
|
||||
<Route path="/runs" element={<RunsPage />} />
|
||||
<Route path="/api" element={<ApiStatusPage />} />
|
||||
<Route path="/404" element={<NotFoundPage />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) {
|
||||
return (
|
||||
<section className="page-stack" aria-labelledby="coming-soon-heading">
|
||||
<div className="hero-dashboard compact-hero coming-soon-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Planned workspace</p>
|
||||
<h2 id="coming-soon-heading">{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="hero-status muted-status">
|
||||
<strong>Coming soon</strong>
|
||||
<span>{alternative}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 10_000,
|
||||
staleTime: 30_000,
|
||||
gcTime: 10 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount, error) => {
|
||||
// Never retry client errors (4xx); retry transient failures once.
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal file
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { truncateMiddle } from "../lib/format";
|
||||
|
||||
/**
|
||||
* Renders a long identifier (URI / hash / id) truncated with an always-available
|
||||
* copy button. The full value is exposed via the `title` attribute.
|
||||
*/
|
||||
export function CopyableValue({
|
||||
value,
|
||||
max = 36,
|
||||
label = "value",
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
/** Max rendered characters before middle-truncation. */
|
||||
max?: number;
|
||||
/** Accessible description of what is copied, e.g. "object URI". */
|
||||
label?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!value) return <span className="text-faint">—</span>;
|
||||
|
||||
const onCopy = async (event: MouseEvent) => {
|
||||
// Don't trigger row-level click handlers (navigation) when copying.
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API unavailable (permissions, non-secure context) — select
|
||||
// fallback is intentionally omitted; the title attribute exposes the
|
||||
// full value for manual copying.
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="copyable" title={value}>
|
||||
<span className="copyable-text">{truncateMiddle(value, max)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`copy-btn${copied ? " copied" : ""}`}
|
||||
onClick={onCopy}
|
||||
aria-label={`Copy ${label}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal file
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Previous/Next controls for cursor-paged lists.
|
||||
*/
|
||||
export function CursorPagerControls({
|
||||
page,
|
||||
canPrev,
|
||||
hasNext,
|
||||
loading,
|
||||
onPrev,
|
||||
onNext,
|
||||
itemCount,
|
||||
}: {
|
||||
page: number;
|
||||
canPrev: boolean;
|
||||
hasNext: boolean;
|
||||
loading?: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
itemCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="pager">
|
||||
<span className="pager-info">
|
||||
Page {page}
|
||||
{itemCount !== undefined ? ` · ${itemCount} rows` : ""}
|
||||
{loading ? " · loading…" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small"
|
||||
onClick={onPrev}
|
||||
disabled={!canPrev || loading}
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden="true" /> Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small"
|
||||
onClick={onNext}
|
||||
disabled={!hasNext || loading}
|
||||
>
|
||||
Next <ChevronRight size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal file
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { EmptyBlock, ErrorBlock } from "./StateBlock";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
/** Right-align (numeric) cells. */
|
||||
numeric?: boolean;
|
||||
/** Extra class on the td/th. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string;
|
||||
loading?: boolean;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
emptyTitle?: string;
|
||||
emptyHint?: string;
|
||||
caption?: string;
|
||||
/** Row click handler — renders rows as clickable. */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Number of skeleton rows while loading. */
|
||||
skeletonRows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data-dense table with unified loading / error / empty states.
|
||||
* Renders inside a horizontal-scroll wrapper so narrow viewports never
|
||||
* overflow the document.
|
||||
*/
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
loading = false,
|
||||
error,
|
||||
onRetry,
|
||||
emptyTitle = "No rows",
|
||||
emptyHint,
|
||||
caption,
|
||||
onRowClick,
|
||||
skeletonRows = 6,
|
||||
}: DataTableProps<T>) {
|
||||
if (error !== undefined && error !== null) {
|
||||
return <ErrorBlock error={error} onRetry={onRetry} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading table rows">
|
||||
{Array.from({ length: skeletonRows }, (_, i) => (
|
||||
<div className="skeleton-row" key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyBlock title={emptyTitle} hint={emptyHint} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={onRowClick ? "clickable" : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal file
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
sub?: ReactNode;
|
||||
tone?: "red" | "amber" | "blue";
|
||||
to?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<>
|
||||
<span className="kpi-label">{label}</span>
|
||||
<span className="kpi-value">{value}</span>
|
||||
{sub ? <span className="kpi-sub">{sub}</span> : null}
|
||||
</>
|
||||
);
|
||||
const className = `kpi-card${tone ? ` tone-${tone}` : ""}`;
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className={className}>{body}</div>;
|
||||
}
|
||||
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal file
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ListResult, ObjectInstanceRecord } from "../api/schemas";
|
||||
import type { ObjectFilters } from "../api/service";
|
||||
import { useCursorPager } from "../lib/cursor";
|
||||
import { objectTypeLabel, truncateMiddle } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { CopyableValue } from "./CopyableValue";
|
||||
import { CursorPagerControls } from "./CursorPagerControls";
|
||||
import { DataTable, type Column } from "./DataTable";
|
||||
import { StatusPill } from "./StatusPill";
|
||||
|
||||
export interface ObjectFilterValues {
|
||||
type: string;
|
||||
result: string;
|
||||
rejectedOnly: boolean;
|
||||
q: string;
|
||||
}
|
||||
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
||||
type: "",
|
||||
result: "",
|
||||
rejectedOnly: false,
|
||||
q: "",
|
||||
};
|
||||
|
||||
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
|
||||
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
||||
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
||||
|
||||
/**
|
||||
* Filter bar + paginated object table. The `fetcher` determines the scope
|
||||
* (global / repo / publication point / issues). Filter state is controlled
|
||||
* by the caller so standalone pages can sync it to the URL.
|
||||
*/
|
||||
export function ObjectsTable({
|
||||
runId,
|
||||
queryKeyScope,
|
||||
fetcher,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
showReason = true,
|
||||
}: {
|
||||
runId: string;
|
||||
queryKeyScope: string;
|
||||
fetcher: (filters: ObjectFilters) => Promise<ListResult<ObjectInstanceRecord>>;
|
||||
filters: ObjectFilterValues;
|
||||
onFiltersChange: (next: ObjectFilterValues) => void;
|
||||
showReason?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [qDraft, setQDraft] = useState(filters.q);
|
||||
|
||||
// Keep the draft input in sync when filters change externally (URL nav).
|
||||
useEffect(() => {
|
||||
setQDraft(filters.q);
|
||||
}, [filters.q]);
|
||||
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
|
||||
const pager = useCursorPager(filterKey);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["objects", queryKeyScope, runId, filters, pager.cursor],
|
||||
queryFn: () =>
|
||||
fetcher({
|
||||
limit: 50,
|
||||
cursor: pager.cursor,
|
||||
type: filters.type || undefined,
|
||||
result: filters.result || undefined,
|
||||
rejected: filters.rejectedOnly || undefined,
|
||||
q: filters.q || undefined,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const columns: Column<ObjectInstanceRecord>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "type",
|
||||
header: "Type",
|
||||
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
|
||||
},
|
||||
{
|
||||
key: "uri",
|
||||
header: "URI",
|
||||
render: (obj) => <CopyableValue value={obj.uri} max={64} label="object URI" />,
|
||||
},
|
||||
{
|
||||
key: "sha256",
|
||||
header: "SHA-256",
|
||||
render: (obj) => (
|
||||
<span className="mono text-muted" title={obj.sha256 ?? undefined}>
|
||||
{obj.sha256 ? truncateMiddle(obj.sha256, 18) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: "Source",
|
||||
render: (obj) => <StatusPill status={obj.sourceSection} />,
|
||||
},
|
||||
{
|
||||
key: "result",
|
||||
header: "Result",
|
||||
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
||||
},
|
||||
...(showReason
|
||||
? [
|
||||
{
|
||||
key: "reason",
|
||||
header: "Reject reason",
|
||||
render: (obj: ObjectInstanceRecord) =>
|
||||
obj.rejectReason ? (
|
||||
<span className="mono text-small" title={obj.rejectReason}>
|
||||
{truncateMiddle(obj.rejectReason, 60)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-faint">—</span>
|
||||
),
|
||||
} satisfies Column<ObjectInstanceRecord>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[showReason],
|
||||
);
|
||||
|
||||
const submitQ = () => {
|
||||
if (qDraft.trim() !== filters.q) {
|
||||
onFiltersChange({ ...filters, q: qDraft.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="panel-header filter-bar" role="search">
|
||||
<div className="filter-field">
|
||||
<label htmlFor={`${queryKeyScope}-type`}>Type</label>
|
||||
<select
|
||||
id={`${queryKeyScope}-type`}
|
||||
value={filters.type}
|
||||
onChange={(e) => onFiltersChange({ ...filters, type: e.target.value })}
|
||||
>
|
||||
<option value="">all types</option>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{objectTypeLabel(t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-field">
|
||||
<label htmlFor={`${queryKeyScope}-result`}>Result</label>
|
||||
<select
|
||||
id={`${queryKeyScope}-result`}
|
||||
value={filters.result}
|
||||
onChange={(e) => onFiltersChange({ ...filters, result: e.target.value })}
|
||||
>
|
||||
<option value="">all results</option>
|
||||
{RESULT_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-field">
|
||||
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
||||
<input
|
||||
id={`${queryKeyScope}-q`}
|
||||
type="search"
|
||||
className="mono"
|
||||
value={qDraft}
|
||||
onChange={(e) => setQDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitQ();
|
||||
}}
|
||||
onBlur={submitQ}
|
||||
placeholder="substring…"
|
||||
/>
|
||||
</div>
|
||||
<label className="filter-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.rejectedOnly}
|
||||
onChange={(e) => onFiltersChange({ ...filters, rejectedOnly: e.target.checked })}
|
||||
/>
|
||||
Rejected only
|
||||
</label>
|
||||
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
||||
server-side filters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={query.data?.items ?? []}
|
||||
rowKey={(obj) => obj.objectInstanceId}
|
||||
loading={query.isPending}
|
||||
error={query.isError ? query.error : undefined}
|
||||
onRetry={() => query.refetch()}
|
||||
emptyTitle="No objects match"
|
||||
emptyHint="Try relaxing the filters."
|
||||
caption="RPKI objects"
|
||||
onRowClick={(obj) =>
|
||||
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
|
||||
}
|
||||
/>
|
||||
<CursorPagerControls
|
||||
page={pager.page}
|
||||
canPrev={pager.canPrev}
|
||||
hasNext={query.data?.nextCursor != null}
|
||||
loading={query.isFetching}
|
||||
onPrev={pager.goPrev}
|
||||
onNext={() => pager.goNext(query.data?.nextCursor ?? null)}
|
||||
itemCount={query.data?.items.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal file
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div className="page-title-block">
|
||||
<h1>{title}</h1>
|
||||
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="page-actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
34
ui/rpki-explorer/src/components/Panel.tsx
Normal file
34
ui/rpki-explorer/src/components/Panel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Panel with header (title + optional tools) and body. */
|
||||
export function Panel({
|
||||
title,
|
||||
subtitle,
|
||||
tools,
|
||||
children,
|
||||
flush = false,
|
||||
className = "",
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Remove body padding (for tables that span the full width). */
|
||||
flush?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`panel ${className}`.trim()}>
|
||||
{title || tools ? (
|
||||
<header className="panel-header">
|
||||
<div>
|
||||
{title ? <h2>{title}</h2> : null}
|
||||
{subtitle ? <div className="panel-subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
{tools ? <div className="panel-tools">{tools}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className={`panel-body${flush ? " flush" : ""}`}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal file
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
||||
import type { ProjectionRecord } from "../api/schemas";
|
||||
import { CopyableValue } from "./CopyableValue";
|
||||
import { CursorPagerControls } from "./CursorPagerControls";
|
||||
import { DataTable, type Column } from "./DataTable";
|
||||
import { EmptyBlock, LoadingBlock, Notice } from "./StateBlock";
|
||||
import { useCursorPager } from "../lib/cursor";
|
||||
import { formatInt, formatUtc } from "../lib/format";
|
||||
import { asArray, asNumber, asRecord, asString } from "../lib/projection";
|
||||
|
||||
function MetaRow({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<dt>{label}</dt>
|
||||
<dd className={mono ? "mono" : undefined}>{value ?? <span className="text-faint">—</span>}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||
const query = useQuery({
|
||||
queryKey: ["manifest-files", runId, objectInstanceId, pager.cursor],
|
||||
queryFn: () => listManifestFiles(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const columns: Column<{ fileName: string; hashHex: string }>[] = [
|
||||
{ key: "file", header: "File", render: (row) => <span className="mono">{row.fileName}</span> },
|
||||
{
|
||||
key: "hash",
|
||||
header: "Hash",
|
||||
render: (row) => <CopyableValue value={row.hashHex} max={40} label="file hash" />,
|
||||
},
|
||||
];
|
||||
|
||||
if (query.isPending) return <LoadingBlock label="Loading manifest file list…" />;
|
||||
if (query.isError) return <Notice kind="error">Failed to load the manifest file list.</Notice>;
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={query.data.items}
|
||||
rowKey={(row) => row.fileName}
|
||||
emptyTitle="Manifest has no file entries"
|
||||
caption="Manifest file list"
|
||||
/>
|
||||
<CursorPagerControls
|
||||
page={pager.page}
|
||||
canPrev={pager.canPrev}
|
||||
hasNext={query.data.nextCursor != null}
|
||||
loading={query.isFetching}
|
||||
onPrev={pager.goPrev}
|
||||
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
|
||||
itemCount={query.data.items.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:crl`);
|
||||
const query = useQuery({
|
||||
queryKey: ["revoked-certs", runId, objectInstanceId, pager.cursor],
|
||||
queryFn: () =>
|
||||
listRevokedCertificates(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
|
||||
{
|
||||
key: "serial",
|
||||
header: "Serial number",
|
||||
render: (row) => (
|
||||
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
|
||||
),
|
||||
},
|
||||
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
||||
];
|
||||
|
||||
if (query.isPending) return <LoadingBlock label="Loading revoked certificates…" />;
|
||||
if (query.isError) return <Notice kind="error">Failed to load the revocation list.</Notice>;
|
||||
const rows = query.data.items.map((row, index) => ({
|
||||
...row,
|
||||
_key: `${row.serialNumberHex ?? row.serialNumber ?? "entry"}-${index}`,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => row._key}
|
||||
emptyTitle="No revoked certificates"
|
||||
caption="Revoked certificates"
|
||||
/>
|
||||
<CursorPagerControls
|
||||
page={pager.page}
|
||||
canPrev={pager.canPrev}
|
||||
hasNext={query.data.nextCursor != null}
|
||||
loading={query.isFetching}
|
||||
onPrev={pager.goPrev}
|
||||
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
|
||||
itemCount={query.data.items.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RoaView({ projection }: { projection: Record<string, unknown> }) {
|
||||
const roa = asRecord(projection.roa);
|
||||
const families = asArray(roa?.ipAddressFamilies);
|
||||
return (
|
||||
<>
|
||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||
<MetaRow label="AS ID" value={asNumber(roa?.asId) ?? asString(roa?.asId)} mono />
|
||||
</dl>
|
||||
{families.map((family, i) => {
|
||||
const fam = asRecord(family);
|
||||
const addresses = asArray(fam?.addresses);
|
||||
return (
|
||||
<div key={i}>
|
||||
<h3 className="text-small" style={{ margin: "12px 0 6px" }}>
|
||||
Address family {asString(fam?.afi) ?? `#${i + 1}`}
|
||||
</h3>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "prefix",
|
||||
header: "Prefix",
|
||||
render: (row: Record<string, unknown>) => (
|
||||
<span className="mono">{asString(row.prefix) ?? "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "maxLength",
|
||||
header: "maxLength",
|
||||
numeric: true,
|
||||
render: (row: Record<string, unknown>) => asNumber(row.maxLength) ?? "—",
|
||||
},
|
||||
]}
|
||||
rows={addresses.map((a) => asRecord(a) ?? {})}
|
||||
rowKey={(row) => `${asString(row.prefix)}-${asNumber(row.maxLength)}`}
|
||||
emptyTitle="No prefixes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
||||
const aspa = asRecord(projection.aspa);
|
||||
const providers = asArray(aspa?.providerAsIds).map((p) => asNumber(p) ?? asString(p));
|
||||
return (
|
||||
<dl className="meta-grid">
|
||||
<MetaRow label="Customer AS" value={asNumber(aspa?.customerAsId) ?? asString(aspa?.customerAsId)} mono />
|
||||
<MetaRow
|
||||
label="Provider ASes"
|
||||
value={
|
||||
providers.length ? (
|
||||
<span className="chip-list">
|
||||
{providers.map((p, i) => (
|
||||
<span className="chip" key={i}>AS{String(p)}</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
|
||||
const validity = asRecord(cert.validity);
|
||||
const resources = asRecord(cert.resources);
|
||||
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
|
||||
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
|
||||
return (
|
||||
<dl className="meta-grid">
|
||||
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
||||
<MetaRow label="Issuer" value={asString(cert.issuer)} mono />
|
||||
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
||||
<MetaRow
|
||||
label="Validity"
|
||||
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
|
||||
mono
|
||||
/>
|
||||
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
|
||||
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
|
||||
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
|
||||
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
|
||||
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
|
||||
<MetaRow
|
||||
label="IP resources"
|
||||
value={
|
||||
ipBlocks.length ? (
|
||||
<span className="chip-list">
|
||||
{ipBlocks.slice(0, 12).map((b, i) => (
|
||||
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
|
||||
))}
|
||||
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MetaRow
|
||||
label="AS resources"
|
||||
value={
|
||||
asBlocks.length ? (
|
||||
<span className="chip-list">
|
||||
{asBlocks.slice(0, 12).map((b, i) => (
|
||||
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
|
||||
))}
|
||||
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured rendering of a parsed object projection, per object type.
|
||||
*/
|
||||
export function ProjectionView({
|
||||
record,
|
||||
runId,
|
||||
objectInstanceId,
|
||||
}: {
|
||||
record: ProjectionRecord | null;
|
||||
runId: string;
|
||||
objectInstanceId: string;
|
||||
}) {
|
||||
if (!record || !record.projection) {
|
||||
return (
|
||||
<EmptyBlock
|
||||
title="Parsed projection unavailable"
|
||||
hint="The query service needs --repo-bytes-db to build parsed projections for this object."
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (record.parseStatus === "error") {
|
||||
return <Notice kind="error">Projection failed: {record.errorSummary ?? "unknown parse error"}</Notice>;
|
||||
}
|
||||
|
||||
const projection = record.projection;
|
||||
const type = (record.objectType ?? "").toLowerCase();
|
||||
|
||||
if (type === "roa") return <RoaView projection={projection} />;
|
||||
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
|
||||
|
||||
if (type === "mft") {
|
||||
const manifest = asRecord(projection.manifest);
|
||||
return (
|
||||
<>
|
||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||
<MetaRow label="Manifest number" value={asString(manifest?.manifestNumberHex ?? manifest?.manifestNumber)} mono />
|
||||
<MetaRow label="thisUpdate" value={formatUtc(asString(manifest?.thisUpdate))} />
|
||||
<MetaRow label="nextUpdate" value={formatUtc(asString(manifest?.nextUpdate))} />
|
||||
<MetaRow label="File hash algorithm" value={asString(manifest?.fileHashAlg)} mono />
|
||||
<MetaRow label="File count" value={formatInt(asNumber(manifest?.fileCount))} />
|
||||
</dl>
|
||||
<ManifestFiles runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "crl") {
|
||||
const crl = asRecord(projection.crl);
|
||||
return (
|
||||
<>
|
||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
|
||||
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
|
||||
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
|
||||
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
|
||||
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
|
||||
</dl>
|
||||
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "cer" || type === "ee" || type === "router_cert") {
|
||||
return <CertificateView projection={projection} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="json-block" data-testid="projection-json">
|
||||
{JSON.stringify(projection, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
176
ui/rpki-explorer/src/components/Shell.tsx
Normal file
176
ui/rpki-explorer/src/components/Shell.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Braces,
|
||||
FileBox,
|
||||
FolderTree,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
PackageOpen,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search as SearchIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { getHealth, listRuns } from "../api/service";
|
||||
import { LATEST_RUN, useRunParam, useSetRunParam } from "../lib/run";
|
||||
import { StatusPill } from "./StatusPill";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
||||
{ path: "/repositories", label: "Repositories", icon: Server, end: false },
|
||||
{ path: "/publication-points", label: "Publication Points", icon: FolderTree, end: false },
|
||||
{ path: "/objects", label: "Objects", icon: FileBox, end: false },
|
||||
{ path: "/validation", label: "Validation", icon: ShieldCheck, end: false },
|
||||
{ path: "/exports", label: "Exports", icon: PackageOpen, end: false },
|
||||
{ path: "/runs", label: "Runs", icon: History, end: false },
|
||||
{ path: "/api", label: "API", icon: Braces, end: false },
|
||||
];
|
||||
|
||||
const COLLAPSE_KEY = "rpki-explorer.sidebar-collapsed";
|
||||
|
||||
function ConnectionPill() {
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: getHealth,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
if (healthQuery.isPending) {
|
||||
return <StatusPill status="pending" label="connecting" />;
|
||||
}
|
||||
if (healthQuery.isError) {
|
||||
return <StatusPill status="error" label="API unreachable" />;
|
||||
}
|
||||
const health = healthQuery.data;
|
||||
return (
|
||||
<span title={health.latestReadyRun ? `Latest indexed run: ${health.latestReadyRun}` : undefined}>
|
||||
<StatusPill status="ok" label="connected" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSelector() {
|
||||
const runParam = useRunParam();
|
||||
const setRunParam = useSetRunParam();
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["runs", "selector"],
|
||||
queryFn: () => listRuns({ limit: 25 }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
||||
|
||||
return (
|
||||
<div className="topbar-run">
|
||||
<label htmlFor="run-selector">Run</label>
|
||||
<select
|
||||
id="run-selector"
|
||||
value={runParam ?? ""}
|
||||
onChange={(event) => setRunParam(event.target.value || null)}
|
||||
>
|
||||
<option value="">{latestId ? `latest (${latestId})` : LATEST_RUN}</option>
|
||||
{(runsQuery.data?.items ?? []).map((run) => (
|
||||
<option key={run.runId} value={run.runId}>
|
||||
{run.runId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopbarSearch() {
|
||||
const navigate = useNavigate();
|
||||
const runParam = useRunParam();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const q = value.trim();
|
||||
if (!q) return;
|
||||
const params = new URLSearchParams({ q });
|
||||
if (runParam) params.set("run", runParam);
|
||||
navigate(`/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="topbar-search" role="search" onSubmit={onSubmit}>
|
||||
<span className="search-icon" aria-hidden="true">
|
||||
<SearchIcon size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="Search URI, SHA-256, repository host…"
|
||||
aria-label="Global search"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function Shell({ children }: { children: ReactNode }) {
|
||||
const runParam = useRunParam();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem(COLLAPSE_KEY) === "1",
|
||||
);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
localStorage.setItem(COLLAPSE_KEY, prev ? "0" : "1");
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
const search = runParam ? `?run=${encodeURIComponent(runParam)}` : "";
|
||||
|
||||
return (
|
||||
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
||||
<div className="shell-brand">
|
||||
<span className="brand-badge" aria-hidden="true">
|
||||
<ShieldCheck size={15} />
|
||||
</span>
|
||||
<span className="brand-name">RPKI Explorer</span>
|
||||
</div>
|
||||
|
||||
<div className="shell-topbar">
|
||||
<TopbarSearch />
|
||||
<RunSelector />
|
||||
<ConnectionPill />
|
||||
</div>
|
||||
|
||||
<nav className="shell-nav" aria-label="Primary">
|
||||
<button
|
||||
type="button"
|
||||
className="nav-toggle"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<PanelLeftClose size={15} aria-hidden="true" />
|
||||
)}
|
||||
{!collapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={{ pathname: item.path, search }}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||
>
|
||||
<item.icon size={15} aria-hidden="true" />
|
||||
<span className="nav-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="nav-footer">ours RP query layer</div>
|
||||
</nav>
|
||||
|
||||
<main className="shell-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal file
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AlertTriangle, Inbox, RefreshCw } from "lucide-react";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
export function LoadingBlock({ label = "Loading…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="state-block" role="status" aria-live="polite">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBlock({
|
||||
error,
|
||||
onRetry,
|
||||
title = "Failed to load data",
|
||||
}: {
|
||||
error: unknown;
|
||||
onRetry?: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error";
|
||||
return (
|
||||
<div className="banner error" role="alert">
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
<div className="banner-body">
|
||||
<strong>{title}.</strong> {message}
|
||||
</div>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn small" onClick={onRetry}>
|
||||
<RefreshCw size={13} aria-hidden="true" /> Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyBlock({
|
||||
title = "No data",
|
||||
hint,
|
||||
}: {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="state-block">
|
||||
<Inbox size={22} aria-hidden="true" />
|
||||
<span className="state-title">{title}</span>
|
||||
{hint ? <span className="text-muted text-small">{hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline warning/info banner. */
|
||||
export function Notice({
|
||||
kind,
|
||||
children,
|
||||
}: {
|
||||
kind: "info" | "warning" | "error";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`banner ${kind}`} role={kind === "error" ? "alert" : "status"}>
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
<div className="banner-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal file
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Status pill with a deterministic status → tone mapping. The label is
|
||||
* always rendered as text (status never relies on color alone).
|
||||
*/
|
||||
|
||||
type Tone = "green" | "amber" | "red" | "blue" | "slate";
|
||||
|
||||
const TONE_BY_STATUS: Record<string, Tone> = {
|
||||
ok: "green",
|
||||
valid: "green",
|
||||
ready: "green",
|
||||
complete: "green",
|
||||
fresh: "green",
|
||||
success: "green",
|
||||
linked: "green",
|
||||
connected: "green",
|
||||
running: "blue",
|
||||
building: "amber",
|
||||
warnings: "amber",
|
||||
warning: "amber",
|
||||
cached: "amber",
|
||||
partial: "amber",
|
||||
stale: "amber",
|
||||
error: "red",
|
||||
invalid: "red",
|
||||
failed: "red",
|
||||
rejected: "red",
|
||||
failed_no_cache: "red",
|
||||
missing: "red",
|
||||
skipped: "slate",
|
||||
unknown: "slate",
|
||||
pending: "slate",
|
||||
};
|
||||
|
||||
export function toneForStatus(status: string | null | undefined): Tone {
|
||||
if (!status) return "slate";
|
||||
return TONE_BY_STATUS[status.toLowerCase()] ?? "slate";
|
||||
}
|
||||
|
||||
export function StatusPill({
|
||||
status,
|
||||
label,
|
||||
}: {
|
||||
status: string | null | undefined;
|
||||
label?: string;
|
||||
}) {
|
||||
const text = label ?? status ?? "unknown";
|
||||
return (
|
||||
<span className={`pill tone-${toneForStatus(status)}`}>
|
||||
<span className="pill-dot" aria-hidden="true" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal file
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useRef } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible tab strip with roving tabindex and arrow-key navigation.
|
||||
* Controlled: the parent owns the active id (usually synced to the URL).
|
||||
*/
|
||||
export function Tabs({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
tabs: TabItem[];
|
||||
active: string;
|
||||
onChange: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const refs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
||||
const index = tabs.findIndex((t) => t.id === active);
|
||||
if (index < 0) return;
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
const next = tabs[(index + delta + tabs.length) % tabs.length];
|
||||
event.preventDefault();
|
||||
onChange(next.id);
|
||||
refs.current.get(next.id)?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tabs" role="tablist" aria-label={ariaLabel} onKeyDown={onKeyDown}>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) refs.current.set(tab.id, el);
|
||||
else refs.current.delete(tab.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${tab.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabPanel({
|
||||
id,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
active: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (id !== active) return null;
|
||||
return (
|
||||
<div role="tabpanel" id={`tabpanel-${id}`} aria-labelledby={`tab-${id}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal file
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import type { ExportJobRecord } from "../api/schemas";
|
||||
import { exportDownloadUrl } from "../api/service";
|
||||
import { formatInt } from "../lib/format";
|
||||
|
||||
/**
|
||||
* Presentational status line for an async export job. Polling is the
|
||||
* caller's responsibility (react-query `refetchInterval`).
|
||||
*/
|
||||
export function WorkflowStatus({
|
||||
job,
|
||||
runId,
|
||||
}: {
|
||||
job: ExportJobRecord;
|
||||
runId: string;
|
||||
}) {
|
||||
if (job.status === "complete") {
|
||||
return (
|
||||
<span className="workflow-status" role="status">
|
||||
<CheckCircle2 size={15} aria-hidden="true" color="var(--green-600)" />
|
||||
<span>
|
||||
Export ready · {formatInt(job.objectCount)} objects
|
||||
</span>
|
||||
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
|
||||
Download tar
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return (
|
||||
<span className="workflow-status" role="alert">
|
||||
<XCircle size={15} aria-hidden="true" color="var(--red-600)" />
|
||||
<span>Export failed{job.error ? `: ${job.error}` : ""}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="workflow-status" role="status" aria-live="polite">
|
||||
<span className="spinner small" aria-hidden="true" />
|
||||
<span>Export running…</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CopyableValueProps {
|
||||
className?: string;
|
||||
displayValue?: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TooltipPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState === "idle") {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copyState]);
|
||||
|
||||
const showTooltip = (target: HTMLElement) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setTooltipPosition({
|
||||
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
|
||||
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
|
||||
});
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await copyText(value);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
|
||||
<span
|
||||
className="copyable-value-text"
|
||||
onBlur={() => setTooltipPosition(null)}
|
||||
onFocus={(event) => showTooltip(event.currentTarget)}
|
||||
onMouseEnter={(event) => showTooltip(event.currentTarget)}
|
||||
onMouseLeave={() => setTooltipPosition(null)}
|
||||
tabIndex={0}
|
||||
title={value}
|
||||
>
|
||||
{displayValue ?? value}
|
||||
</span>
|
||||
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
|
||||
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
|
||||
</button>
|
||||
{tooltipPosition ? (
|
||||
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
|
||||
{value}
|
||||
</span>
|
||||
) : null}
|
||||
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = value;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
interface CursorPagerProps {
|
||||
label?: string;
|
||||
isFetching: boolean;
|
||||
nextCursor: string | null;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
pageNumber: number;
|
||||
previousCount: number;
|
||||
rangeLabel?: string;
|
||||
}
|
||||
|
||||
export function CursorPager({
|
||||
label,
|
||||
isFetching,
|
||||
nextCursor,
|
||||
onNext,
|
||||
onPrevious,
|
||||
pageNumber,
|
||||
previousCount,
|
||||
rangeLabel
|
||||
}: CursorPagerProps) {
|
||||
return (
|
||||
<div className="cursor-pager" aria-label="Cursor pagination">
|
||||
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
|
||||
<span>
|
||||
{label ?? `Page ${pageNumber}`}
|
||||
{rangeLabel ? <small>{rangeLabel}</small> : null}
|
||||
</span>
|
||||
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface PageState {
|
||||
cursor: string | null;
|
||||
previous: Array<string | null>;
|
||||
}
|
||||
|
||||
export function advancePage(page: PageState, nextCursor: string | null): PageState {
|
||||
if (!nextCursor) {
|
||||
return page;
|
||||
}
|
||||
return {
|
||||
cursor: nextCursor,
|
||||
previous: [...page.previous, page.cursor]
|
||||
};
|
||||
}
|
||||
|
||||
export function previousPage(page: PageState): PageState {
|
||||
if (page.previous.length === 0) {
|
||||
return page;
|
||||
}
|
||||
return {
|
||||
cursor: page.previous[page.previous.length - 1] ?? null,
|
||||
previous: page.previous.slice(0, -1)
|
||||
};
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NavigationItem<Id extends string = string> {
|
||||
id: Id;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface ShellProps<Id extends string> extends PropsWithChildren {
|
||||
activeView: Id;
|
||||
navigationItems: Array<NavigationItem<Id>>;
|
||||
onNavigate: (id: Id) => void;
|
||||
}
|
||||
|
||||
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
|
||||
<aside className="sidebar" aria-label="Primary navigation">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">R</div>
|
||||
<div className="brand-copy">
|
||||
<h1 className="brand-title">RPKI Explorer</h1>
|
||||
<div className="brand-subtitle">Validation Intelligence</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav-list">
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
aria-current={activeView === item.id ? "page" : undefined}
|
||||
aria-label={isSidebarCollapsed ? item.label : undefined}
|
||||
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
title={isSidebarCollapsed ? item.label : undefined}
|
||||
type="button"
|
||||
>
|
||||
<item.icon aria-hidden="true" size={18} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
aria-expanded={!isSidebarCollapsed}
|
||||
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
|
||||
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||
type="button"
|
||||
>
|
||||
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
|
||||
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
|
||||
</button>
|
||||
</aside>
|
||||
<main className="main-panel">
|
||||
<header className="topbar">
|
||||
<div className="run-selector readonly-run" aria-label="Current run">
|
||||
<span>Run</span>
|
||||
<strong>latest indexed run</strong>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="search-box">
|
||||
<Search aria-hidden="true" size={18} />
|
||||
<span className="sr-only">Exact object URI search is not active yet</span>
|
||||
<input disabled placeholder="Exact URI lookup planned in M2" />
|
||||
</label>
|
||||
<div className="ready-pill"><span /> UI Ready</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { normalizeApiError } from "../../api/client";
|
||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||
import {
|
||||
createObjectSetExport,
|
||||
createPublicationPointExport,
|
||||
downloadRawObject,
|
||||
explainObjectValidation,
|
||||
getLatestRun,
|
||||
getObject,
|
||||
getObjectByUri,
|
||||
getObjectChain,
|
||||
getObjectParsed,
|
||||
getObjectValidation,
|
||||
listManifestFiles,
|
||||
listRevokedCertificates,
|
||||
type ChainEdgeRecord,
|
||||
type ObjectInstanceRecord,
|
||||
type ObjectValidationRecord,
|
||||
type ValidationExplainRecord
|
||||
} from "../../api/queryService";
|
||||
|
||||
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
|
||||
type ObjectTab = (typeof tabs)[number];
|
||||
|
||||
interface ObjectDetailPageProps {
|
||||
initialObjectInstanceId?: string | null;
|
||||
}
|
||||
|
||||
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
|
||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
|
||||
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const [searchUri, setSearchUri] = useState("");
|
||||
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
|
||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
||||
useEffect(() => {
|
||||
if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) {
|
||||
setSelectedObjectInstanceId(initialObjectInstanceId);
|
||||
setActiveTab("Validation");
|
||||
}
|
||||
}, [initialObjectInstanceId, selectedObjectInstanceId]);
|
||||
|
||||
const objectQuery = useQuery({
|
||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
queryKey: ["object-detail-object", runId, selectedObjectInstanceId],
|
||||
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const selectedObject = objectQuery.data?.data ?? null;
|
||||
const validationQuery = useQuery({
|
||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
|
||||
queryFn: () => getObjectValidation(runId, selectedObjectInstanceId ?? ""),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const parsedQuery = useQuery({
|
||||
enabled: activeTab === "Parsed" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
queryKey: ["object-detail-parsed", runId, selectedObjectInstanceId],
|
||||
queryFn: () => getObjectParsed(runId, selectedObjectInstanceId ?? ""),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const chainQuery = useQuery({
|
||||
enabled: activeTab === "Chain" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
queryKey: ["object-detail-chain", runId, selectedObjectInstanceId],
|
||||
queryFn: () => getObjectChain(runId, selectedObjectInstanceId ?? ""),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const manifestFilesQuery = useQuery({
|
||||
enabled: activeTab === "Manifest files" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
retry: false,
|
||||
queryKey: ["object-detail-manifest-files", runId, selectedObjectInstanceId],
|
||||
queryFn: () => listManifestFiles(runId, selectedObjectInstanceId ?? "", 50),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const revokedCertsQuery = useQuery({
|
||||
enabled: activeTab === "Revoked certs" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||
retry: false,
|
||||
queryKey: ["object-detail-revoked-certs", runId, selectedObjectInstanceId],
|
||||
queryFn: () => listRevokedCertificates(runId, selectedObjectInstanceId ?? "", 50),
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
const explainMutation = useMutation({
|
||||
mutationFn: () => explainObjectValidation(runId, selectedObjectInstanceId ?? "")
|
||||
});
|
||||
const uriSearchMutation = useMutation({
|
||||
mutationFn: (uri: string) => getObjectByUri(runId, uri),
|
||||
onSuccess: (result) => {
|
||||
setSelectedObjectInstanceId(result.data.objectInstanceId);
|
||||
setActiveTab("Validation");
|
||||
}
|
||||
});
|
||||
const rawDownloadMutation = useMutation({
|
||||
mutationFn: () => downloadRawObject(runId, selectedObjectInstanceId ?? "")
|
||||
});
|
||||
const objectExportMutation = useMutation({
|
||||
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
|
||||
});
|
||||
const ppExportMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedObject?.ppId) {
|
||||
throw new Error("No publication point selected");
|
||||
}
|
||||
return createPublicationPointExport(runId, selectedObject.ppId);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState === "idle") {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => setCopyState("idle"), 1800);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copyState]);
|
||||
|
||||
const copySelectedUri = async () => {
|
||||
if (!selectedObject?.uri) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedObject.uri);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
const validation = validationQuery.data?.data ?? null;
|
||||
const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error;
|
||||
|
||||
return (
|
||||
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
|
||||
{apiError ? <ErrorBanner error={apiError} /> : null}
|
||||
<div className="object-detail-only-layout">
|
||||
<aside className="object-detail-card object-detail-card-expanded" aria-label="Live object detail">
|
||||
<div className="object-detail-toolbar">
|
||||
<form
|
||||
className="search-box object-uri-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (searchUri.trim()) {
|
||||
uriSearchMutation.mutate(searchUri.trim());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Search aria-hidden="true" size={17} />
|
||||
<span className="sr-only">Search exact object URI</span>
|
||||
<input
|
||||
onChange={(event) => setSearchUri(event.target.value)}
|
||||
placeholder="Exact URI lookup..."
|
||||
value={searchUri}
|
||||
/>
|
||||
</form>
|
||||
<button className="filter-pill" disabled={!selectedObject?.uri} type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
|
||||
<strong>{selectedObject ? `${labelObjectType(selectedObject.objectType)} selected` : "No object selected"}</strong>
|
||||
</div>
|
||||
{!selectedObjectInstanceId ? <div className="empty-state">Search an exact URI or open an object from Repository Browser.</div> : null}
|
||||
{selectedObjectInstanceId && objectQuery.isFetching ? <LoadingLine label="Loading selected object..." /> : null}
|
||||
<div className="object-hero">
|
||||
<div>
|
||||
<p className="eyebrow">Object detail · live query service</p>
|
||||
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
|
||||
{selectedObject ? (
|
||||
<p><CopyableValue className="inline-copyable" label="selected object URI" value={selectedObject.uri} /></p>
|
||||
) : (
|
||||
<p>Select an object from the publication point list.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="object-actions">
|
||||
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
|
||||
<button className="ghost-button" disabled={!selectedObject?.uri} onClick={copySelectedUri} type="button">
|
||||
<Copy size={16} /> {copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy URI"}
|
||||
</button>
|
||||
<button className="ghost-button" disabled={!selectedObjectInstanceId} onClick={() => objectExportMutation.mutate()} type="button">
|
||||
<FileText size={16} /> Export object
|
||||
</button>
|
||||
<button className="primary-button" disabled={!selectedObjectInstanceId} onClick={() => rawDownloadMutation.mutate()} type="button">
|
||||
<Download size={16} /> Download raw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedObject ? <ObjectMeta object={selectedObject} validation={validation} /> : <div className="empty-state">No object selected.</div>}
|
||||
<WorkflowStatus
|
||||
objectExport={objectExportMutation.data?.data ?? null}
|
||||
objectExportError={objectExportMutation.error}
|
||||
ppExport={ppExportMutation.data?.data ?? null}
|
||||
ppExportError={ppExportMutation.error}
|
||||
rawError={rawDownloadMutation.error}
|
||||
rawPending={rawDownloadMutation.isPending}
|
||||
searchError={uriSearchMutation.error}
|
||||
searchPending={uriSearchMutation.isPending}
|
||||
/>
|
||||
<div className="object-actions inline-actions">
|
||||
<button className="ghost-button" disabled={!selectedObject?.ppId} onClick={() => ppExportMutation.mutate()} type="button">
|
||||
<FileText size={16} /> Export selected PP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Object detail tabs" onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
const nextIndex = event.key === "ArrowRight"
|
||||
? (currentIndex + 1) % tabs.length
|
||||
: (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
setActiveTab(tabs[nextIndex]);
|
||||
}}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
aria-controls={`object-tabpanel-${tabId(tab)}`}
|
||||
aria-selected={activeTab === tab}
|
||||
className={activeTab === tab ? "active" : ""}
|
||||
id={`object-tab-${tabId(tab)}`}
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
tabIndex={activeTab === tab ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-labelledby={`object-tab-${tabId(activeTab)}`}
|
||||
className="object-content-grid"
|
||||
id={`object-tabpanel-${tabId(activeTab)}`}
|
||||
role="tabpanel"
|
||||
>
|
||||
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
|
||||
{activeTab === "Validation" ? (
|
||||
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
|
||||
) : null}
|
||||
{activeTab === "Chain" ? <ChainPanel edges={chainQuery.data?.data ?? []} isFetching={chainQuery.isFetching} error={chainQuery.error} /> : null}
|
||||
{activeTab === "Manifest files" ? <ArrayPanel title="Manifest files" rows={manifestFilesQuery.data?.data ?? []} isFetching={manifestFilesQuery.isFetching} error={manifestFilesQuery.error} /> : null}
|
||||
{activeTab === "Revoked certs" ? <ArrayPanel title="Revoked certificates" rows={revokedCertsQuery.data?.data ?? []} isFetching={revokedCertsQuery.isFetching} error={revokedCertsQuery.error} /> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
|
||||
return (
|
||||
<div className="object-meta-grid">
|
||||
<Meta label="SHA256" value={object.sha256} copyable />
|
||||
<Meta label="Source" value={object.sourceSection} />
|
||||
<Meta label="Repository" value={object.repoId} copyable />
|
||||
<Meta label="Publication Point" value={object.ppId} copyable />
|
||||
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
|
||||
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowStatus({
|
||||
objectExport,
|
||||
objectExportError,
|
||||
ppExport,
|
||||
ppExportError,
|
||||
rawError,
|
||||
rawPending,
|
||||
searchError,
|
||||
searchPending
|
||||
}: {
|
||||
objectExport: { jobId?: string; status?: string } | null;
|
||||
objectExportError: unknown;
|
||||
ppExport: { jobId?: string; status?: string } | null;
|
||||
ppExportError: unknown;
|
||||
rawError: unknown;
|
||||
rawPending: boolean;
|
||||
searchError: unknown;
|
||||
searchPending: boolean;
|
||||
}) {
|
||||
const lines = [
|
||||
searchPending ? "Searching object URI..." : null,
|
||||
searchError ? `URI search failed: ${normalizeApiError(searchError).message}` : null,
|
||||
rawPending ? "Downloading raw object..." : null,
|
||||
rawError ? `Raw download failed: ${normalizeApiError(rawError).message}` : null,
|
||||
objectExport ? `Object export job ${objectExport.jobId ?? ""} ${objectExport.status ?? ""}`.trim() : null,
|
||||
objectExportError ? `Object export failed: ${normalizeApiError(objectExportError).message}` : null,
|
||||
ppExport ? `PP export job ${ppExport.jobId ?? ""} ${ppExport.status ?? ""}`.trim() : null,
|
||||
ppExportError ? `PP export failed: ${normalizeApiError(ppExportError).message}` : null
|
||||
].filter((line): line is string => Boolean(line));
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className="panel workflow-status">
|
||||
<PanelHeading eyebrow="Workflow" title="Search / raw / export status" />
|
||||
<ul className="workflow-status-list">
|
||||
{lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ParsedPanel({ parsed, isFetching, error }: { parsed: unknown; isFetching: boolean; error: unknown }) {
|
||||
if (isFetching) {
|
||||
return <LoadingPanel title="Parsed projection" label="Loading parsed projection..." />;
|
||||
}
|
||||
if (error) {
|
||||
return <NoticePanel title="Parsed projection" message={normalizeApiError(error).message} />;
|
||||
}
|
||||
if (!parsed) {
|
||||
return <NoticePanel title="Parsed projection" message="Projection unavailable. The local query service was started without repo-bytes.db, so this object cannot be decoded on demand." />;
|
||||
}
|
||||
return (
|
||||
<section className="panel parsed-panel">
|
||||
<PanelHeading eyebrow="Parsed projection" title="Projection JSON" icon={<FileText className="panel-icon" size={22} />} />
|
||||
<JsonPreview value={parsed} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationPanel({ validation, isFetching, explain, explainPending, onExplain, explainError }: {
|
||||
validation: ObjectValidationRecord | null;
|
||||
isFetching: boolean;
|
||||
explain: ValidationExplainRecord | null;
|
||||
explainPending: boolean;
|
||||
onExplain: () => void;
|
||||
explainError: unknown;
|
||||
}) {
|
||||
if (isFetching) {
|
||||
return <LoadingPanel title="Validation" label="Loading validation summary..." />;
|
||||
}
|
||||
return (
|
||||
<section className="panel validation-panel">
|
||||
<PanelHeading eyebrow="Validation" title="File and chain checks" icon={<ShieldCheck className="panel-icon success" size={22} />} />
|
||||
<div className="check-list">
|
||||
<ValidationCheck label="Final status" status={validation?.finalStatus ?? "unknown"} note={validation?.detailSummary ?? "Derived from run audit state."} />
|
||||
<ValidationCheck label="File validation" status={validation?.fileValidation.status ?? "unknown"} note={issuesText(validation?.fileValidation.issues) ?? validation?.fileValidation.detailSummary ?? "No file issues recorded."} />
|
||||
<ValidationCheck label="Chain validation" status={validation?.chainValidation.status ?? "unknown"} note={issuesText(validation?.chainValidation.issues) ?? validation?.chainValidation.note ?? "No chain issues recorded."} />
|
||||
</div>
|
||||
<div className="explain-box">
|
||||
<button className="ghost-button" disabled={explainPending} onClick={onExplain} type="button">
|
||||
{explainPending ? <Loader2 size={16} /> : <ShieldCheck size={16} />}
|
||||
Explain validation
|
||||
</button>
|
||||
<span>Explain is an audit projection, not full authoritative revalidation.</span>
|
||||
</div>
|
||||
{explainError ? <div className="empty-state">Explain failed: {normalizeApiError(explainError).message}</div> : null}
|
||||
{explain ? (
|
||||
<div className="detail-list explain-result">
|
||||
<Meta label="Mode" value={explain.explainMode} />
|
||||
<Meta label="Authoritative" value={String(explain.authoritative)} />
|
||||
<Meta label="Generated" value={explain.generatedAt} />
|
||||
<Meta label="Edges" value={String(explain.chainEdges.length)} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; isFetching: boolean; error: unknown }) {
|
||||
if (isFetching) {
|
||||
return <LoadingPanel title="Certificate chain" label="Loading chain edges..." />;
|
||||
}
|
||||
if (error) {
|
||||
return <NoticePanel title="Certificate chain" message={normalizeApiError(error).message} />;
|
||||
}
|
||||
return (
|
||||
<section className="panel chain-panel">
|
||||
<PanelHeading eyebrow="Certificate chain" title="Audit edge graph" icon={<GitBranch className="panel-icon" size={22} />} />
|
||||
{edges.length === 0 ? <div className="empty-state">No chain edges recorded for this object.</div> : null}
|
||||
<div className="chain-flow live-chain-flow">
|
||||
{edges.map((edge) => (
|
||||
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
|
||||
<strong>{edge.relation}</strong>
|
||||
<span>{edge.status}</span>
|
||||
<code><CopyableValue label="chain edge URI" value={edge.toUri} /></code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayPanel({ title, rows, isFetching, error }: { title: string; rows: unknown[]; isFetching: boolean; error: unknown }) {
|
||||
if (isFetching) {
|
||||
return <LoadingPanel title={title} label={`Loading ${title.toLowerCase()}...`} />;
|
||||
}
|
||||
if (error) {
|
||||
return <NoticePanel title={title} message={normalizeApiError(error).message} />;
|
||||
}
|
||||
return (
|
||||
<section className="panel table-panel">
|
||||
<PanelHeading eyebrow={title} title="Paged projection list" />
|
||||
{rows.length === 0 ? <div className="empty-state">No rows returned for this object.</div> : null}
|
||||
{rows.length > 0 ? <JsonPreview value={rows} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingPanel({ title, label }: { title: string; label: string }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<PanelHeading eyebrow={title} title={title} />
|
||||
<LoadingLine label={label} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticePanel({ title, message }: { title: string; message: string }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<PanelHeading eyebrow={title} title={title} />
|
||||
<div className="empty-state">{message}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeading({ eyebrow, title, icon }: { eyebrow: string; title: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationCheck({ label, status, note }: { label: string; status: string; note: string }) {
|
||||
const ok = status === "valid" || status === "ok";
|
||||
return (
|
||||
<article className={`check-item ${ok ? "" : "warning-check"}`}>
|
||||
<Check size={16} />
|
||||
<div>
|
||||
<strong>{label}: {status}</strong>
|
||||
<p>{note}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonPreview({ value }: { value: unknown }) {
|
||||
const text = useMemo(() => JSON.stringify(value, null, 2), [value]);
|
||||
return <pre className="json-preview">{text}</pre>;
|
||||
}
|
||||
|
||||
function ErrorBanner({ error }: { error: unknown }) {
|
||||
return (
|
||||
<div className="alert-banner" role="status">
|
||||
<strong>Object API unavailable</strong>
|
||||
<span>{normalizeApiError(error).message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ state }: { state: string }) {
|
||||
const normalized = state.toLowerCase();
|
||||
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error") || normalized.includes("invalid")
|
||||
? "warning"
|
||||
: normalized.includes("cache") || normalized.includes("fallback")
|
||||
? "fallback"
|
||||
: "healthy";
|
||||
return <span className={`status-badge ${className}`}>{state}</span>;
|
||||
}
|
||||
|
||||
function LoadingLine({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="loading-line">
|
||||
<Loader2 size={16} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ copyable, label, value }: { copyable?: boolean; label: string; value: string }) {
|
||||
return (
|
||||
<div className="meta-item">
|
||||
<span>{label}</span>
|
||||
<strong>{copyable ? <CopyableValue label={label} value={value} /> : value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function labelObjectType(type: string) {
|
||||
const labels: Record<string, string> = {
|
||||
certificate: "CER",
|
||||
crl: "CRL",
|
||||
manifest: "MFT",
|
||||
roa: "ROA",
|
||||
aspa: "ASPA"
|
||||
};
|
||||
return labels[type] ?? type.toUpperCase();
|
||||
}
|
||||
|
||||
function tabId(tab: ObjectTab) {
|
||||
return tab.toLowerCase().replaceAll(" ", "-");
|
||||
}
|
||||
|
||||
function issuesText(issues: { summary?: string }[] | undefined) {
|
||||
if (!issues || issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return issues.map((issue) => issue.summary).filter(Boolean).join("; ");
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowUpRight, CheckCircle2, Clock3, ShieldCheck } from "lucide-react";
|
||||
import { objectTypeRows, overviewKpis, topRepositories, validationIssues, validationSlices } from "../../test/fixtures/dashboard";
|
||||
import {
|
||||
getLatestRun,
|
||||
getStatsObjectTypes,
|
||||
getStatsReasons,
|
||||
getStatsValidation,
|
||||
listRepos,
|
||||
type CountsByKey,
|
||||
type RepositoryRecord,
|
||||
type RunRecord
|
||||
} from "../../api/queryService";
|
||||
import { normalizeApiError } from "../../api/client";
|
||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||
|
||||
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
|
||||
const objectTypeLabels: Record<string, string> = {
|
||||
aspa: "ASPA",
|
||||
certificate: "CER / RC",
|
||||
crl: "CRL",
|
||||
gbr: "GBR",
|
||||
manifest: "MFT",
|
||||
roa: "ROA",
|
||||
router_certificate: "Router Cert"
|
||||
};
|
||||
|
||||
export function OverviewPage() {
|
||||
const latestRunQuery = useQuery({
|
||||
queryKey: ["latest-run"],
|
||||
queryFn: getLatestRun
|
||||
});
|
||||
const liveRun = latestRunQuery.data?.data;
|
||||
const runId = liveRun?.runId ?? "latest";
|
||||
const enabled = Boolean(liveRun);
|
||||
const objectTypesQuery = useQuery({
|
||||
queryKey: ["stats-object-types", runId],
|
||||
queryFn: () => getStatsObjectTypes(runId),
|
||||
enabled
|
||||
});
|
||||
const validationQuery = useQuery({
|
||||
queryKey: ["stats-validation", runId],
|
||||
queryFn: () => getStatsValidation(runId),
|
||||
enabled
|
||||
});
|
||||
const reasonsQuery = useQuery({
|
||||
queryKey: ["stats-reasons", runId],
|
||||
queryFn: () => getStatsReasons(runId),
|
||||
enabled
|
||||
});
|
||||
const reposQuery = useQuery({
|
||||
queryKey: ["top-repos", runId],
|
||||
queryFn: () => listRepos(runId, 8),
|
||||
enabled
|
||||
});
|
||||
|
||||
const error = latestRunQuery.error ? normalizeApiError(latestRunQuery.error) : null;
|
||||
const kpis = liveRun ? kpisFromRun(liveRun) : overviewKpis;
|
||||
const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows;
|
||||
const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices;
|
||||
const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100);
|
||||
const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures();
|
||||
const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues;
|
||||
const statusText = liveRun ? statusForRun(liveRun) : "Static fixture";
|
||||
const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected";
|
||||
|
||||
return (
|
||||
<section className="page-stack" aria-labelledby="overview-heading">
|
||||
{error ? (
|
||||
<div className="alert-banner" role="status">
|
||||
<strong>Query service unavailable</strong>
|
||||
<span>{error.message}. Showing static fallback data for layout inspection.</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hero-dashboard">
|
||||
<div>
|
||||
<p className="eyebrow">{liveRun ? `Latest ready run · ${liveRun.syncMode ?? "unknown"} mode` : "Latest ready run · fallback"}</p>
|
||||
<h2 id="overview-heading">Global RPKI validation health</h2>
|
||||
<p>
|
||||
{liveRun
|
||||
? `Live query-service view for ${liveRun.runId}, validated at ${formatTimestamp(liveRun.validationTime)}.`
|
||||
: "Static fallback showing how operators inspect validation output, repository health, and input quality."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-status">
|
||||
<ShieldCheck size={20} />
|
||||
<div>
|
||||
<strong>{statusText}</strong>
|
||||
<span>{statusSubtext}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid" aria-label="Overview KPI cards">
|
||||
{kpis.map((metric) => (
|
||||
<article className={`metric-card tone-${metric.tone}`} key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.delta}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel validation-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Validation</p>
|
||||
<h3>Result distribution</h3>
|
||||
</div>
|
||||
<CheckCircle2 className="panel-icon success" size={22} />
|
||||
</div>
|
||||
<div className="donut-wrap">
|
||||
<div className="donut" aria-label="Validation distribution donut" style={{ background: donutGradient(validationRows) }} />
|
||||
<div className="donut-center">
|
||||
<strong>{validPercent}%</strong>
|
||||
<span>usable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="legend-list">
|
||||
{validationRows.map((slice) => (
|
||||
<div className="legend-row" key={slice.label}>
|
||||
<span style={{ backgroundColor: slice.color }} />
|
||||
<label>{slice.label}</label>
|
||||
<strong>{slice.value}%</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Objects</p>
|
||||
<h3>Object type mix</h3>
|
||||
</div>
|
||||
<Clock3 className="panel-icon" size={22} />
|
||||
</div>
|
||||
<div className="bar-list">
|
||||
{objectRows.map((row) => (
|
||||
<div className="bar-row" key={row.type}>
|
||||
<div className="bar-meta">
|
||||
<strong>{row.type}</strong>
|
||||
<span>{row.count}</span>
|
||||
</div>
|
||||
<div className="bar-track">
|
||||
<span style={{ width: `${row.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel table-panel wide-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Repositories</p>
|
||||
<h3>Top repositories by workload</h3>
|
||||
</div>
|
||||
<button className="ghost-button" type="button">
|
||||
View all <ArrowUpRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Transport</th>
|
||||
<th>Duration</th>
|
||||
<th>Objects</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repoRows.map((repo) => (
|
||||
<tr key={repo.id}>
|
||||
<td>{repo.host}</td>
|
||||
<td>{repo.transport}</td>
|
||||
<td>{repo.duration}</td>
|
||||
<td>{repo.objects}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${repo.status}`}>{repo.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="panel issue-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Triage</p>
|
||||
<h3>Recent validation issues</h3>
|
||||
</div>
|
||||
<AlertTriangle className="panel-icon warning" size={22} />
|
||||
</div>
|
||||
<div className="issue-list">
|
||||
{issueRows.map((issue) => (
|
||||
<article className="issue-card" key={issue.uri}>
|
||||
<div>
|
||||
<span className={`severity ${issue.severity}`}>{issue.severity}</span>
|
||||
<strong>{issue.type}</strong>
|
||||
</div>
|
||||
<p>{issue.reason}</p>
|
||||
<code><CopyableValue label="issue URI" value={issue.uri} /></code>
|
||||
<small>{issue.repo}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function kpisFromRun(run: RunRecord) {
|
||||
return [
|
||||
{ label: "VRPs", value: formatNumber(run.counts.vrps), delta: "latest run", tone: "blue" },
|
||||
{ label: "ASPAs", value: formatNumber(run.counts.aspas), delta: "latest run", tone: "cyan" },
|
||||
{ label: "Objects", value: formatNumber(run.counts.objects), delta: "indexed inputs", tone: "blue" },
|
||||
{ label: "Publication Points", value: formatNumber(run.counts.publicationPoints), delta: "indexed", tone: "purple" },
|
||||
{ label: "Rejected Objects", value: formatNumber(run.counts.rejectedObjects), delta: "validation rejects", tone: "red" },
|
||||
{ label: "Warnings", value: formatNumber(run.counts.warnings), delta: `${formatDuration(run.wallMs)} wall`, tone: "amber" }
|
||||
];
|
||||
}
|
||||
|
||||
function objectRowsFromStats(stats: CountsByKey) {
|
||||
const total = sumValues(stats) || 1;
|
||||
return Object.entries(stats)
|
||||
.sort(([, left], [, right]) => right - left)
|
||||
.slice(0, 7)
|
||||
.map(([type, count]) => ({
|
||||
type: objectTypeLabels[type] ?? type,
|
||||
count: formatNumber(count),
|
||||
percent: Math.max(1, Math.round((count / total) * 100))
|
||||
}));
|
||||
}
|
||||
|
||||
function validationRowsFromStats(stats: CountsByKey) {
|
||||
const total = sumValues(stats) || 1;
|
||||
return Object.entries(stats)
|
||||
.sort(([, left], [, right]) => right - left)
|
||||
.map(([label, count], index) => ({
|
||||
label: titleCase(label),
|
||||
value: Math.round((count / total) * 100),
|
||||
color: validationColors[index] ?? "#94a3b8"
|
||||
}));
|
||||
}
|
||||
|
||||
function reposFromApi(repos: RepositoryRecord[]) {
|
||||
return [...repos]
|
||||
.sort((left, right) => right.objects - left.objects)
|
||||
.slice(0, 6)
|
||||
.map((repo) => ({
|
||||
host: repo.host,
|
||||
id: repo.repoId,
|
||||
transport: repo.transport.toUpperCase(),
|
||||
duration: formatDuration(repo.syncDurationMsTotal),
|
||||
objects: formatNumber(repo.objects),
|
||||
status: repoStatus(repo)
|
||||
}));
|
||||
}
|
||||
|
||||
function reposFromFixtures() {
|
||||
return topRepositories.map((repo, index) => ({
|
||||
...repo,
|
||||
id: fallbackRepoId(repo, index)
|
||||
}));
|
||||
}
|
||||
|
||||
function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) {
|
||||
return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`;
|
||||
}
|
||||
|
||||
function issuesFromReasons(reasons: CountsByKey) {
|
||||
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
|
||||
if (entries.length === 0) {
|
||||
return [
|
||||
{
|
||||
severity: "low",
|
||||
type: "OK",
|
||||
reason: "No validation reject reasons reported in the latest run.",
|
||||
uri: "query-service://validation/reasons",
|
||||
repo: "latest run"
|
||||
}
|
||||
];
|
||||
}
|
||||
return entries.slice(0, 4).map(([reason, count], index) => ({
|
||||
severity: index === 0 ? "high" : index === 1 ? "medium" : "low",
|
||||
type: "Reject",
|
||||
reason: `${formatNumber(count)} object(s): ${reason}`,
|
||||
uri: `query-service://validation/reasons/${index + 1}`,
|
||||
repo: "latest run"
|
||||
}));
|
||||
}
|
||||
|
||||
function repoStatus(repo: RepositoryRecord) {
|
||||
if (repo.terminalStates.failed_no_cache || repo.phases.rrdp_failed_rsync_failed) {
|
||||
return "warning";
|
||||
}
|
||||
if (repo.terminalStates.fallback_current_instance || repo.phases.rsync_ok) {
|
||||
return "fallback";
|
||||
}
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
function statusForRun(run: RunRecord) {
|
||||
if (run.counts.rejectedObjects > 0 || run.counts.warnings > 0) {
|
||||
return "Healthy with warnings";
|
||||
}
|
||||
return "Healthy";
|
||||
}
|
||||
|
||||
function donutGradient(rows: Array<{ value: number; color: string }>) {
|
||||
let start = 0;
|
||||
const parts = rows.map((row) => {
|
||||
const end = Math.min(100, start + row.value);
|
||||
const segment = `${row.color} ${start}% ${end}%`;
|
||||
start = end;
|
||||
return segment;
|
||||
});
|
||||
return `conic-gradient(${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function sumValues(values?: CountsByKey) {
|
||||
return Object.values(values ?? {}).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
function percent(value: number, total: number) {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((value / total) * 100);
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (ms === null || ms === undefined) {
|
||||
return "—";
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return "unknown time";
|
||||
}
|
||||
return value.replace("T", " ").replace("Z", " UTC");
|
||||
}
|
||||
|
||||
function titleCase(value: string) {
|
||||
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||
import { CursorPager } from "../../components/common/CursorPager";
|
||||
import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging";
|
||||
import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState";
|
||||
import {
|
||||
getLatestRun,
|
||||
listObjectsForPublicationPoint,
|
||||
listPublicationPointsForRepo,
|
||||
listRepos,
|
||||
type ObjectInstanceRecord,
|
||||
type PublicationPointRecord,
|
||||
type RepositoryRecord
|
||||
} from "../../api/queryService";
|
||||
import { normalizeApiError } from "../../api/client";
|
||||
|
||||
const REPO_PAGE_SIZE = 50;
|
||||
const PP_PAGE_SIZE = 50;
|
||||
const OBJECT_PAGE_SIZE = 50;
|
||||
|
||||
interface RepositoriesPageProps {
|
||||
browserState: RepositoryBrowserState;
|
||||
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
|
||||
onOpenObject?: (objectInstanceId: string) => void;
|
||||
}
|
||||
|
||||
export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
|
||||
const {
|
||||
selectedRepoId,
|
||||
selectedPpId,
|
||||
repoFilter,
|
||||
ppFilter,
|
||||
objectFilter,
|
||||
repoPage,
|
||||
ppPage,
|
||||
objectPage,
|
||||
isRepoPanelCollapsed,
|
||||
isPpPanelCollapsed
|
||||
} = browserState;
|
||||
const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value }));
|
||||
const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value }));
|
||||
const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value }));
|
||||
const setRepoPage = (updater: SetStateAction<PageState>) => {
|
||||
onBrowserStateChange((state) => ({
|
||||
...state,
|
||||
repoPage: typeof updater === "function" ? updater(state.repoPage) : updater
|
||||
}));
|
||||
};
|
||||
const setPpPage = (updater: SetStateAction<PageState>) => {
|
||||
onBrowserStateChange((state) => ({
|
||||
...state,
|
||||
ppPage: typeof updater === "function" ? updater(state.ppPage) : updater
|
||||
}));
|
||||
};
|
||||
const setObjectPage = (updater: SetStateAction<PageState>) => {
|
||||
onBrowserStateChange((state) => ({
|
||||
...state,
|
||||
objectPage: typeof updater === "function" ? updater(state.objectPage) : updater
|
||||
}));
|
||||
};
|
||||
const latestRunQuery = useQuery({
|
||||
queryKey: ["repositories-latest-run"],
|
||||
queryFn: getLatestRun
|
||||
});
|
||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
||||
const reposQuery = useQuery({
|
||||
queryKey: ["repositories", runId, repoPage.cursor],
|
||||
queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
|
||||
enabled: Boolean(latestRunQuery.data?.data.runId)
|
||||
});
|
||||
|
||||
const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]);
|
||||
const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]);
|
||||
const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
|
||||
const ppsQuery = useQuery({
|
||||
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
|
||||
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
|
||||
enabled: Boolean(selectedRepo?.repoId)
|
||||
});
|
||||
|
||||
const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
|
||||
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
|
||||
const selectedPp = selectedPpId
|
||||
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
|
||||
: null;
|
||||
const objectsQuery = useQuery({
|
||||
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
|
||||
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
|
||||
enabled: Boolean(selectedPp?.ppId)
|
||||
});
|
||||
const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
|
||||
const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]);
|
||||
const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length;
|
||||
const ppTotal = selectedRepo?.publicationPoints;
|
||||
const objectTotal = selectedPp?.objects;
|
||||
|
||||
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
|
||||
|
||||
return (
|
||||
<section className="page-stack repositories-page" aria-labelledby="repositories-heading">
|
||||
<div className="hero-dashboard compact-hero">
|
||||
<div>
|
||||
<p className="eyebrow">Repository browser · live query service</p>
|
||||
<h2 id="repositories-heading">Repository / publication point / object browser</h2>
|
||||
<p>Browse repo trees first, then expand publication points and object rows only on demand.</p>
|
||||
</div>
|
||||
<div className="hero-status">
|
||||
<Database size={20} />
|
||||
<div>
|
||||
<strong>{latestRunQuery.data?.data.runId ?? "Loading run"}</strong>
|
||||
<span>{repos.length ? `${repos.length} repositories loaded` : "waiting for query service"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert-banner" role="status">
|
||||
<strong>Repository API unavailable</strong>
|
||||
<span>{normalizeApiError(error).message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
|
||||
<section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
|
||||
<PanelTitle
|
||||
icon={<Database size={18} />}
|
||||
isCollapsed={isRepoPanelCollapsed}
|
||||
label="Repositories"
|
||||
meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
||||
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))}
|
||||
toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"}
|
||||
/>
|
||||
{isRepoPanelCollapsed ? (
|
||||
<CollapsedPanelSummary icon={<Database size={20} />} label="Repositories" value={repoTotal ?? repos.length} />
|
||||
) : (
|
||||
<>
|
||||
<CurrentPageFilter
|
||||
label="Filter repositories on current page"
|
||||
onChange={setRepoFilter}
|
||||
placeholder="Filter host or URI..."
|
||||
value={repoFilter}
|
||||
/>
|
||||
<div className="stack-list">
|
||||
{filteredRepos.map((repo) => (
|
||||
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
|
||||
<button
|
||||
className="stack-row-select"
|
||||
onClick={() => {
|
||||
onBrowserStateChange((state) => ({
|
||||
...state,
|
||||
selectedRepoId: repo.repoId,
|
||||
selectedPpId: null,
|
||||
ppFilter: "",
|
||||
objectFilter: "",
|
||||
ppPage: createEmptyPageState(),
|
||||
objectPage: createEmptyPageState()
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
<strong>{repo.host}</strong>
|
||||
<small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
|
||||
</span>
|
||||
<em>{repo.objects.toLocaleString()}</em>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<CursorPager
|
||||
isFetching={reposQuery.isFetching}
|
||||
pageNumber={repoPage.previous.length + 1}
|
||||
nextCursor={reposQuery.data?.page?.nextCursor ?? null}
|
||||
onNext={() => setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))}
|
||||
onPrevious={() => setRepoPage(previousPage)}
|
||||
previousCount={repoPage.previous.length}
|
||||
rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
|
||||
<PanelTitle
|
||||
icon={<GitBranch size={18} />}
|
||||
isCollapsed={isPpPanelCollapsed}
|
||||
label="Publication Points"
|
||||
meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"}
|
||||
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))}
|
||||
toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"}
|
||||
/>
|
||||
{isPpPanelCollapsed ? (
|
||||
<CollapsedPanelSummary icon={<GitBranch size={20} />} label="Publication Points" value={ppTotal ?? publicationPoints.length} />
|
||||
) : (
|
||||
<>
|
||||
{!selectedRepo ? (
|
||||
<div className="empty-state">Select a repository to load its publication points.</div>
|
||||
) : null}
|
||||
<CurrentPageFilter
|
||||
disabled={!selectedRepo}
|
||||
label="Filter publication points on current page"
|
||||
onChange={setPpFilter}
|
||||
placeholder="Filter manifest, rsync, source..."
|
||||
value={ppFilter}
|
||||
/>
|
||||
{ppsQuery.isFetching ? <LoadingLine /> : null}
|
||||
<div className="stack-list">
|
||||
{filteredPublicationPoints.map((pp) => (
|
||||
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
|
||||
<button
|
||||
className="stack-row-select"
|
||||
onClick={() => {
|
||||
onBrowserStateChange((state) => ({
|
||||
...state,
|
||||
selectedPpId: pp.ppId,
|
||||
objectFilter: "",
|
||||
objectPage: createEmptyPageState()
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
|
||||
<small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
|
||||
</span>
|
||||
<em>{pp.objects.toLocaleString()}</em>
|
||||
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
|
||||
</button>
|
||||
<CopyableValue className="stack-row-copy" label="publication point URI" value={pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? pp.ppId} />
|
||||
{pp.manifestRsyncUri ? <CopyableValue className="stack-row-copy secondary" label="manifest URI" value={pp.manifestRsyncUri} /> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<CursorPager
|
||||
isFetching={ppsQuery.isFetching}
|
||||
nextCursor={ppsQuery.data?.page?.nextCursor ?? null}
|
||||
onNext={() => setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))}
|
||||
onPrevious={() => setPpPage(previousPage)}
|
||||
pageNumber={ppPage.previous.length + 1}
|
||||
previousCount={ppPage.previous.length}
|
||||
rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
|
||||
<PanelTitle
|
||||
icon={<FileCode2 size={18} />}
|
||||
label="Objects"
|
||||
meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"}
|
||||
/>
|
||||
{!selectedPp ? (
|
||||
<div className="empty-state">Select a publication point to load its objects.</div>
|
||||
) : null}
|
||||
<CurrentPageFilter
|
||||
disabled={!selectedPp}
|
||||
label="Filter objects on current page"
|
||||
onChange={setObjectFilter}
|
||||
placeholder="Filter type, URI, hash, status..."
|
||||
value={objectFilter}
|
||||
/>
|
||||
{objectsQuery.isFetching ? <LoadingLine /> : null}
|
||||
{selectedPp && !objectsQuery.isFetching ? (
|
||||
<ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
|
||||
) : null}
|
||||
<CursorPager
|
||||
isFetching={objectsQuery.isFetching}
|
||||
nextCursor={objectsQuery.data?.page?.nextCursor ?? null}
|
||||
onNext={() => setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))}
|
||||
onPrevious={() => setObjectPage(previousPage)}
|
||||
pageNumber={objectPage.previous.length + 1}
|
||||
previousCount={objectPage.previous.length}
|
||||
rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({
|
||||
icon,
|
||||
isCollapsed,
|
||||
label,
|
||||
meta,
|
||||
onToggleCollapse,
|
||||
toggleLabel
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
label: string;
|
||||
meta: string;
|
||||
onToggleCollapse?: () => void;
|
||||
toggleLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel-heading compact-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{label}</p>
|
||||
<h3>{label}</h3>
|
||||
</div>
|
||||
<div className="panel-heading-actions">
|
||||
<span className="panel-meta">
|
||||
{icon}
|
||||
{meta}
|
||||
</span>
|
||||
{onToggleCollapse ? (
|
||||
<button aria-expanded={!isCollapsed} aria-label={toggleLabel} className="panel-collapse-button" onClick={onToggleCollapse} title={toggleLabel} type="button">
|
||||
{isCollapsed ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelLeftClose aria-hidden="true" size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<div className="collapsed-panel-summary">
|
||||
{icon}
|
||||
<strong>{label}</strong>
|
||||
<span>{value.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentPageFilter({
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
placeholder,
|
||||
value
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="current-page-filter">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) {
|
||||
if (objects.length === 0) {
|
||||
return <div className="empty-state">No objects returned for this publication point.</div>;
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>URI</th>
|
||||
<th>Hash</th>
|
||||
<th>Source</th>
|
||||
<th>Result</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objects.map((object) => (
|
||||
<tr key={object.objectInstanceId}>
|
||||
<td><span className="object-type">{object.objectType}</span></td>
|
||||
<td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
|
||||
<td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
|
||||
<td>{object.sourceSection}</td>
|
||||
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
||||
<td>
|
||||
<button className="table-link-button" onClick={() => onOpenObject?.(object.objectInstanceId)} type="button">
|
||||
Open
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ state }: { state: string }) {
|
||||
const normalized = state.toLowerCase();
|
||||
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error")
|
||||
? "warning"
|
||||
: normalized.includes("cache") || normalized.includes("fallback")
|
||||
? "fallback"
|
||||
: "healthy";
|
||||
return <span className={`status-badge ${className}`}>{state}</span>;
|
||||
}
|
||||
|
||||
function LoadingLine() {
|
||||
return (
|
||||
<div className="loading-line">
|
||||
<Loader2 size={16} />
|
||||
Loading selected branch...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortName(uri: string) {
|
||||
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
||||
}
|
||||
|
||||
function shortHash(value: string, prefixLength: number) {
|
||||
return `${value.slice(0, prefixLength)}...`;
|
||||
}
|
||||
|
||||
function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) {
|
||||
const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1;
|
||||
const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1;
|
||||
const totalText = total === undefined ? "unknown" : total.toLocaleString();
|
||||
const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : "";
|
||||
return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`;
|
||||
}
|
||||
|
||||
function matchesNeedle(values: Array<string | number | null | undefined>, needle: string) {
|
||||
const normalized = needle.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
function filterRepositories(repos: RepositoryRecord[], filter: string) {
|
||||
return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter));
|
||||
}
|
||||
|
||||
function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) {
|
||||
return publicationPoints.filter((pp) => matchesNeedle([
|
||||
pp.ppId,
|
||||
pp.manifestRsyncUri,
|
||||
pp.publicationPointRsyncUri,
|
||||
pp.rsyncBaseUri,
|
||||
pp.rrdpNotificationUri,
|
||||
pp.source,
|
||||
pp.repoSyncSource,
|
||||
pp.repoTerminalState,
|
||||
pp.objects,
|
||||
pp.rejectedObjects
|
||||
], filter));
|
||||
}
|
||||
|
||||
function filterObjects(objects: ObjectInstanceRecord[], filter: string) {
|
||||
return objects.filter((object) => matchesNeedle([
|
||||
object.objectType,
|
||||
object.uri,
|
||||
object.sha256,
|
||||
object.sourceSection,
|
||||
object.result,
|
||||
object.rejectReason,
|
||||
object.rejected ? "rejected" : "accepted"
|
||||
], filter));
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { PageState } from "../../components/common/cursorPaging";
|
||||
|
||||
export interface RepositoryBrowserState {
|
||||
selectedRepoId: string | null;
|
||||
selectedPpId: string | null;
|
||||
repoFilter: string;
|
||||
ppFilter: string;
|
||||
objectFilter: string;
|
||||
repoPage: PageState;
|
||||
ppPage: PageState;
|
||||
objectPage: PageState;
|
||||
isRepoPanelCollapsed: boolean;
|
||||
isPpPanelCollapsed: boolean;
|
||||
}
|
||||
|
||||
export function createRepositoryBrowserState(): RepositoryBrowserState {
|
||||
return {
|
||||
selectedRepoId: null,
|
||||
selectedPpId: null,
|
||||
repoFilter: "",
|
||||
ppFilter: "",
|
||||
objectFilter: "",
|
||||
repoPage: createEmptyPageState(),
|
||||
ppPage: createEmptyPageState(),
|
||||
objectPage: createEmptyPageState(),
|
||||
isRepoPanelCollapsed: false,
|
||||
isPpPanelCollapsed: false
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyPageState(): PageState {
|
||||
return { cursor: null, previous: [] };
|
||||
}
|
||||
42
ui/rpki-explorer/src/lib/cursor.test.ts
Normal file
42
ui/rpki-explorer/src/lib/cursor.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INITIAL_PAGER_STATE,
|
||||
advancePager,
|
||||
pageNumber,
|
||||
retreatPager,
|
||||
type PagerState,
|
||||
} from "./cursor";
|
||||
|
||||
describe("cursor pager state machine", () => {
|
||||
it("starts on page 1 with a null cursor", () => {
|
||||
expect(INITIAL_PAGER_STATE.cursor).toBeNull();
|
||||
expect(pageNumber(INITIAL_PAGER_STATE)).toBe(1);
|
||||
});
|
||||
|
||||
it("advances and keeps a cursor stack for going back", () => {
|
||||
let state: PagerState = INITIAL_PAGER_STATE;
|
||||
state = advancePager(state, "c2");
|
||||
expect(state.cursor).toBe("c2");
|
||||
expect(pageNumber(state)).toBe(2);
|
||||
state = advancePager(state, "c3");
|
||||
expect(state.cursor).toBe("c3");
|
||||
expect(pageNumber(state)).toBe(3);
|
||||
|
||||
state = retreatPager(state);
|
||||
expect(state.cursor).toBe("c2");
|
||||
expect(pageNumber(state)).toBe(2);
|
||||
state = retreatPager(state);
|
||||
expect(state.cursor).toBeNull();
|
||||
expect(pageNumber(state)).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores advance with a null cursor (no next page)", () => {
|
||||
const state = advancePager(INITIAL_PAGER_STATE, null);
|
||||
expect(state).toEqual(INITIAL_PAGER_STATE);
|
||||
});
|
||||
|
||||
it("ignores retreat on the first page", () => {
|
||||
const state = retreatPager(INITIAL_PAGER_STATE);
|
||||
expect(state).toEqual(INITIAL_PAGER_STATE);
|
||||
});
|
||||
});
|
||||
72
ui/rpki-explorer/src/lib/cursor.ts
Normal file
72
ui/rpki-explorer/src/lib/cursor.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export interface CursorPager {
|
||||
/** Cursor to pass to the backend for the current page (null = first page). */
|
||||
cursor: string | null;
|
||||
/** 1-based page number. */
|
||||
page: number;
|
||||
canPrev: boolean;
|
||||
goNext: (nextCursor: string | null) => void;
|
||||
goPrev: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface PagerState {
|
||||
cursor: string | null;
|
||||
/** Stack of cursors that produced the previous pages (page 1 = null). */
|
||||
stack: (string | null)[];
|
||||
}
|
||||
|
||||
export const INITIAL_PAGER_STATE: PagerState = { cursor: null, stack: [] };
|
||||
|
||||
/** Advance to the page identified by `nextCursor`; unchanged when null. */
|
||||
export function advancePager(state: PagerState, nextCursor: string | null): PagerState {
|
||||
if (!nextCursor) return state;
|
||||
return { cursor: nextCursor, stack: [...state.stack, state.cursor] };
|
||||
}
|
||||
|
||||
/** Go back one page; unchanged on the first page. */
|
||||
export function retreatPager(state: PagerState): PagerState {
|
||||
if (state.stack.length === 0) return state;
|
||||
const stack = [...state.stack];
|
||||
const cursor = stack.pop() ?? null;
|
||||
return { cursor, stack };
|
||||
}
|
||||
|
||||
export function pageNumber(state: PagerState): number {
|
||||
return state.stack.length + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor pager that resets whenever `resetKey` changes (run id, filters…).
|
||||
*/
|
||||
export function useCursorPager(resetKey: string): CursorPager {
|
||||
const [state, setState] = useState<PagerState>(INITIAL_PAGER_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
setState(INITIAL_PAGER_STATE);
|
||||
}, [resetKey]);
|
||||
|
||||
const goNext = useCallback((nextCursor: string | null) => {
|
||||
setState((s) => advancePager(s, nextCursor));
|
||||
}, []);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((s) => retreatPager(s));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cursor: state.cursor,
|
||||
page: pageNumber(state),
|
||||
canPrev: state.stack.length > 0,
|
||||
goNext,
|
||||
goPrev,
|
||||
reset,
|
||||
}),
|
||||
[state, goNext, goPrev, reset],
|
||||
);
|
||||
}
|
||||
99
ui/rpki-explorer/src/lib/format.test.ts
Normal file
99
ui/rpki-explorer/src/lib/format.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDurationMs,
|
||||
formatInt,
|
||||
formatPercent,
|
||||
formatUtc,
|
||||
objectTypeLabel,
|
||||
parseTime,
|
||||
truncateMiddle,
|
||||
} from "./format";
|
||||
|
||||
describe("formatInt", () => {
|
||||
it("formats with thousands separators", () => {
|
||||
expect(formatInt(1234567)).toBe("1,234,567");
|
||||
});
|
||||
it("renders dash for nullish", () => {
|
||||
expect(formatInt(null)).toBe("—");
|
||||
expect(formatInt(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("keeps small values in bytes", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
});
|
||||
it("scales to KiB/MiB/GiB", () => {
|
||||
expect(formatBytes(2048)).toBe("2.0 KiB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
|
||||
expect(formatBytes(3 * 1024 ** 3)).toBe("3.0 GiB");
|
||||
});
|
||||
it("renders dash for nullish", () => {
|
||||
expect(formatBytes(null)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationMs", () => {
|
||||
it("sub-second values", () => {
|
||||
expect(formatDurationMs(640)).toBe("640 ms");
|
||||
});
|
||||
it("seconds", () => {
|
||||
expect(formatDurationMs(4200)).toBe("4.2s");
|
||||
expect(formatDurationMs(42_000)).toBe("42s");
|
||||
});
|
||||
it("minutes and hours", () => {
|
||||
expect(formatDurationMs(3 * 60_000 + 4_000)).toBe("3m 04s");
|
||||
expect(formatDurationMs(2 * 3_600_000 + 5 * 60_000)).toBe("2h 05m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTime / formatUtc", () => {
|
||||
it("parses RFC3339 and renders UTC", () => {
|
||||
expect(formatUtc("2026-07-17T01:46:55Z")).toBe("2026-07-17 01:46:55 UTC");
|
||||
});
|
||||
it("passes through unparseable input", () => {
|
||||
expect(formatUtc("not-a-date")).toBe("not-a-date");
|
||||
expect(parseTime("not-a-date")).toBeNull();
|
||||
});
|
||||
it("renders dash for missing values", () => {
|
||||
expect(formatUtc(null)).toBe("—");
|
||||
expect(formatUtc(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPercent", () => {
|
||||
it("computes one decimal", () => {
|
||||
expect(formatPercent(1, 3)).toBe("33.3%");
|
||||
});
|
||||
it("guards zero total", () => {
|
||||
expect(formatPercent(1, 0)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
it("keeps short values intact", () => {
|
||||
expect(truncateMiddle("abc", 10)).toBe("abc");
|
||||
});
|
||||
it("truncates with an ellipsis keeping head and tail", () => {
|
||||
const out = truncateMiddle("a".repeat(100), 21);
|
||||
expect(out.length).toBe(21);
|
||||
expect(out).toContain("…");
|
||||
expect(out.startsWith("a".repeat(10))).toBe(true);
|
||||
expect(out.endsWith("a".repeat(10))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objectTypeLabel", () => {
|
||||
it("maps known types", () => {
|
||||
expect(objectTypeLabel("roa")).toBe("ROA");
|
||||
expect(objectTypeLabel("mft")).toBe("Manifest");
|
||||
expect(objectTypeLabel("asa")).toBe("ASPA");
|
||||
});
|
||||
it("uppercases unknown types", () => {
|
||||
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
||||
});
|
||||
it("renders dash for missing", () => {
|
||||
expect(objectTypeLabel(null)).toBe("—");
|
||||
});
|
||||
});
|
||||
109
ui/rpki-explorer/src/lib/format.ts
Normal file
109
ui/rpki-explorer/src/lib/format.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Formatting helpers for numbers, bytes, durations and timestamps. */
|
||||
|
||||
export function formatInt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
return value.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatBytes(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = value;
|
||||
let unit = "B";
|
||||
for (const next of units) {
|
||||
if (size < 1024) break;
|
||||
size /= 1024;
|
||||
unit = next;
|
||||
}
|
||||
return `${size >= 100 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
if (value < 1000) return `${Math.round(value)} ms`;
|
||||
const totalSeconds = Math.floor(value / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||||
const fraction = value / 1000;
|
||||
return `${fraction >= 10 ? fraction.toFixed(0) : fraction.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** Parse an ISO/RFC3339 timestamp; returns null when unparseable. */
|
||||
export function parseTime(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** "2026-07-17 01:46:55 UTC" — stable, timezone-explicit rendering. */
|
||||
export function formatUtc(value: string | null | undefined): string {
|
||||
const date = parseTime(value);
|
||||
if (!date) return value ? String(value) : "—";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
|
||||
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRelative(value: string | null | undefined): string {
|
||||
const date = parseTime(value);
|
||||
if (!date) return "—";
|
||||
const deltaMs = Date.now() - date.getTime();
|
||||
const future = deltaMs < 0;
|
||||
const abs = Math.abs(deltaMs);
|
||||
const minutes = Math.floor(abs / 60_000);
|
||||
const hours = Math.floor(abs / 3_600_000);
|
||||
const days = Math.floor(abs / 86_400_000);
|
||||
let text: string;
|
||||
if (abs < 60_000) text = "just now";
|
||||
else if (minutes < 60) text = `${minutes}m`;
|
||||
else if (hours < 24) text = `${hours}h`;
|
||||
else text = `${days}d`;
|
||||
if (text === "just now") return text;
|
||||
return future ? `in ${text}` : `${text} ago`;
|
||||
}
|
||||
|
||||
export function formatPercent(part: number, total: number): string {
|
||||
if (!total) return "—";
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Truncate a long identifier for display, keeping head and tail. */
|
||||
export function truncateMiddle(value: string, max = 32): string {
|
||||
if (value.length <= max) return value;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
|
||||
}
|
||||
|
||||
/** Human label for an object type code (accepts both report-stream and projection vocabularies). */
|
||||
export function objectTypeLabel(type: string | null | undefined): string {
|
||||
switch ((type ?? "").toLowerCase()) {
|
||||
case "roa":
|
||||
return "ROA";
|
||||
case "mft":
|
||||
case "manifest":
|
||||
return "Manifest";
|
||||
case "crl":
|
||||
return "CRL";
|
||||
case "cer":
|
||||
case "certificate":
|
||||
return "Certificate";
|
||||
case "asa":
|
||||
case "aspa":
|
||||
return "ASPA";
|
||||
case "gbr":
|
||||
return "Ghostbusters";
|
||||
case "ee":
|
||||
return "EE Certificate";
|
||||
case "other":
|
||||
return "Other";
|
||||
default:
|
||||
return type ? type.toUpperCase() : "—";
|
||||
}
|
||||
}
|
||||
25
ui/rpki-explorer/src/lib/projection.test.ts
Normal file
25
ui/rpki-explorer/src/lib/projection.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { asArray, asNumber, asRecord, asString } from "./projection";
|
||||
|
||||
describe("projection guards", () => {
|
||||
it("asRecord accepts plain objects only", () => {
|
||||
expect(asRecord({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(asRecord([1])).toBeNull();
|
||||
expect(asRecord("x")).toBeNull();
|
||||
expect(asRecord(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("asString / asNumber narrow primitives", () => {
|
||||
expect(asString("s")).toBe("s");
|
||||
expect(asString(1)).toBeNull();
|
||||
expect(asNumber(4)).toBe(4);
|
||||
expect(asNumber("4")).toBeNull();
|
||||
expect(asNumber(NaN)).toBeNull();
|
||||
});
|
||||
|
||||
it("asArray defaults to empty", () => {
|
||||
expect(asArray([1, 2])).toEqual([1, 2]);
|
||||
expect(asArray(undefined)).toEqual([]);
|
||||
expect(asArray({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
18
ui/rpki-explorer/src/lib/projection.ts
Normal file
18
ui/rpki-explorer/src/lib/projection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Defensive accessors for loosely-typed projection payloads. */
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
48
ui/rpki-explorer/src/lib/run.ts
Normal file
48
ui/rpki-explorer/src/lib/run.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Run-context helpers. The active run is carried in the `?run=` search param;
|
||||
* "latest" (the default, no param) resolves server-side to the newest
|
||||
* indexed run.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export const LATEST_RUN = "latest";
|
||||
|
||||
/** The run id to use in API paths ("latest" when no explicit selection). */
|
||||
export function useRunId(): string {
|
||||
const [searchParams] = useSearchParams();
|
||||
return searchParams.get("run") ?? LATEST_RUN;
|
||||
}
|
||||
|
||||
/** Raw `?run=` value (null when following latest). */
|
||||
export function useRunParam(): string | null {
|
||||
const [searchParams] = useSearchParams();
|
||||
return searchParams.get("run");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function that sets (or clears, for "latest") the `?run=` param
|
||||
* while preserving the other current search params.
|
||||
*/
|
||||
export function useSetRunParam(): (runId: string | null) => void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
return useCallback(
|
||||
(runId: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (runId === null || runId === LATEST_RUN) {
|
||||
next.delete("run");
|
||||
} else {
|
||||
next.set("run", runId);
|
||||
}
|
||||
setSearchParams(next, { preventScrollReset: true });
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
);
|
||||
}
|
||||
|
||||
/** Append the current run param to a path that already carries a query string. */
|
||||
export function withRunParam(path: string, runId: string): string {
|
||||
if (runId === LATEST_RUN) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}run=${encodeURIComponent(runId)}`;
|
||||
}
|
||||
15
ui/rpki-explorer/src/lib/useRun.ts
Normal file
15
ui/rpki-explorer/src/lib/useRun.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Resolve the active run record for the current `?run=` context. */
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { getRun } from "../api/service";
|
||||
import type { RunRecord } from "../api/schemas";
|
||||
import { useRunId } from "./run";
|
||||
|
||||
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
||||
const runId = useRunId();
|
||||
const runQuery = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => getRun(runId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return { runId, runQuery };
|
||||
}
|
||||
97
ui/rpki-explorer/src/lib/vrpQuery.test.ts
Normal file
97
ui/rpki-explorer/src/lib/vrpQuery.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyNetworkQuery,
|
||||
parseAsn,
|
||||
parseIpAddress,
|
||||
parsePrefix,
|
||||
} from "./vrpQuery";
|
||||
|
||||
describe("parseAsn", () => {
|
||||
it("accepts bare and AS-prefixed numbers", () => {
|
||||
expect(parseAsn("45090")).toBe(45090);
|
||||
expect(parseAsn("AS45090")).toBe(45090);
|
||||
expect(parseAsn("as0")).toBe(0);
|
||||
expect(parseAsn(" AS64496 ")).toBe(64496);
|
||||
});
|
||||
|
||||
it("rejects out-of-range and malformed ASNs", () => {
|
||||
expect(parseAsn("4294967295")).toBe(4294967295);
|
||||
expect(parseAsn("4294967296")).toBeNull();
|
||||
expect(parseAsn("AS123abc")).toBeNull();
|
||||
expect(parseAsn("AS")).toBeNull();
|
||||
expect(parseAsn("-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIpAddress", () => {
|
||||
it("accepts IPv4 addresses", () => {
|
||||
expect(parseIpAddress("81.68.1.1")).toBe("81.68.1.1");
|
||||
expect(parseIpAddress("0.0.0.0")).toBe("0.0.0.0");
|
||||
expect(parseIpAddress("255.255.255.255")).toBe("255.255.255.255");
|
||||
});
|
||||
|
||||
it("rejects IPv4 with bad octets or CIDR suffix", () => {
|
||||
expect(parseIpAddress("256.0.0.1")).toBeNull();
|
||||
expect(parseIpAddress("192.0.2.1/24")).toBeNull();
|
||||
expect(parseIpAddress("192.0.2")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts IPv6 addresses", () => {
|
||||
expect(parseIpAddress("2001:7fa:0:1::1")).toBe("2001:7fa:0:1::1");
|
||||
expect(parseIpAddress("::")).toBe("::");
|
||||
expect(parseIpAddress("::1")).toBe("::1");
|
||||
expect(parseIpAddress("2001:db8:0:0:0:0:0:1")).toBe("2001:db8:0:0:0:0:0:1");
|
||||
expect(parseIpAddress("1:2:3:4:5:6:7::")).toBe("1:2:3:4:5:6:7::"); // "::" = one :0 group
|
||||
expect(parseIpAddress("::ffff:192.0.2.1")).toBe("::ffff:192.0.2.1");
|
||||
});
|
||||
|
||||
it("rejects malformed IPv6", () => {
|
||||
expect(parseIpAddress("2001:::1")).toBeNull();
|
||||
expect(parseIpAddress("2001:db8::g1")).toBeNull();
|
||||
expect(parseIpAddress("1:2:3:4:5:6:7:8:9")).toBeNull();
|
||||
expect(parseIpAddress("1:2:3:4:5:6:7:8::")).toBeNull(); // no room for "::"
|
||||
expect(parseIpAddress("12345::")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePrefix", () => {
|
||||
it("accepts IPv4 and IPv6 CIDR", () => {
|
||||
expect(parsePrefix("192.0.2.0/24")).toBe("192.0.2.0/24");
|
||||
expect(parsePrefix("192.0.2.1/24")).toBe("192.0.2.1/24"); // host bits: backend normalizes
|
||||
expect(parsePrefix("0.0.0.0/0")).toBe("0.0.0.0/0");
|
||||
expect(parsePrefix("2001:db8::/32")).toBe("2001:db8::/32");
|
||||
expect(parsePrefix("2001:db8::1/128")).toBe("2001:db8::1/128");
|
||||
});
|
||||
|
||||
it("rejects bad lengths and addresses", () => {
|
||||
expect(parsePrefix("192.0.2.0/33")).toBeNull();
|
||||
expect(parsePrefix("2001:db8::/129")).toBeNull();
|
||||
expect(parsePrefix("192.0.2.0/")).toBeNull();
|
||||
expect(parsePrefix("192.0.2.0/24/1")).toBeNull();
|
||||
expect(parsePrefix("999.0.0.0/8")).toBeNull();
|
||||
expect(parsePrefix("/24")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyNetworkQuery", () => {
|
||||
it("prefix wins over ip, ip over asn", () => {
|
||||
expect(classifyNetworkQuery("81.68.0.0/14")).toEqual({
|
||||
kind: "prefix",
|
||||
prefix: "81.68.0.0/14",
|
||||
});
|
||||
expect(classifyNetworkQuery("81.68.1.1")).toEqual({ kind: "ip", ip: "81.68.1.1" });
|
||||
expect(classifyNetworkQuery("2001:7fa:0:1::1")).toEqual({
|
||||
kind: "ip",
|
||||
ip: "2001:7fa:0:1::1",
|
||||
});
|
||||
expect(classifyNetworkQuery("AS45090")).toEqual({ kind: "asn", asn: 45090 });
|
||||
expect(classifyNetworkQuery("45090")).toEqual({ kind: "asn", asn: 45090 });
|
||||
});
|
||||
|
||||
it("falls back to none for text queries", () => {
|
||||
expect(classifyNetworkQuery("repo-a.example")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("rsync://repo.example/ca/roa.roa")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
104
ui/rpki-explorer/src/lib/vrpQuery.ts
Normal file
104
ui/rpki-explorer/src/lib/vrpQuery.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Classify a free-text search query into a VRP lookup target.
|
||||
*
|
||||
* The backend VRP lookup (`GET /runs/{id}/vrps/lookup`) accepts exactly one of
|
||||
* `ip` / `prefix`, plus an optional `asn` filter. An ASN-only query cannot be
|
||||
* served by the API — the UI surfaces that explicitly instead of guessing.
|
||||
*/
|
||||
|
||||
export type NetworkQuery =
|
||||
| { kind: "ip"; ip: string }
|
||||
| { kind: "prefix"; prefix: string }
|
||||
| { kind: "asn"; asn: number }
|
||||
| { kind: "none" };
|
||||
|
||||
const ASN_MAX = 4_294_967_295;
|
||||
|
||||
/** "AS45090" / "as45090" / "45090" → ASN, else null. */
|
||||
export function parseAsn(input: string): number | null {
|
||||
const match = /^(?:as)?(\d{1,10})$/i.exec(input.trim());
|
||||
if (!match) return null;
|
||||
const asn = Number(match[1]);
|
||||
return Number.isSafeInteger(asn) && asn <= ASN_MAX ? asn : null;
|
||||
}
|
||||
|
||||
function isIpv4Octets(addr: string): boolean {
|
||||
const parts = addr.split(".");
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function isIpv6(addr: string): boolean {
|
||||
if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false;
|
||||
if ((addr.match(/::/g) ?? []).length > 1) return false;
|
||||
const isGroup = (g: string, isLast: boolean) =>
|
||||
isLast && g.includes(".")
|
||||
? isIpv4Octets(g) // embedded IPv4 tail, e.g. "::ffff:192.0.2.1"
|
||||
: /^[0-9a-fA-F]{1,4}$/.test(g);
|
||||
const ipv4TailWeight = (groups: string[]) =>
|
||||
groups.length > 0 && groups[groups.length - 1].includes(".") ? 1 : 0;
|
||||
if (addr.includes("::")) {
|
||||
const [head, tail] = addr.split("::");
|
||||
const headGroups = head ? head.split(":") : [];
|
||||
const tailGroups = tail ? tail.split(":") : [];
|
||||
// "::" compresses at least one group; an IPv4 tail counts as two groups.
|
||||
const effective =
|
||||
headGroups.length + tailGroups.length + ipv4TailWeight(tailGroups);
|
||||
if (effective > 7) return false;
|
||||
return [...headGroups, ...tailGroups].every((g, i, all) =>
|
||||
isGroup(g, i === all.length - 1),
|
||||
);
|
||||
}
|
||||
const groups = addr.split(":");
|
||||
const expected = groups[groups.length - 1]?.includes(".") ? 7 : 8;
|
||||
if (groups.length !== expected) return false;
|
||||
return groups.every((g, i) => isGroup(g, i === groups.length - 1));
|
||||
}
|
||||
|
||||
/** Bare IPv4/IPv6 address (no "/") → normalized string, else null. */
|
||||
export function parseIpAddress(input: string): string | null {
|
||||
const s = input.trim();
|
||||
if (s.includes("/")) return null;
|
||||
if (isIpv4Octets(s)) return s;
|
||||
if (s.includes(":") && isIpv6(s)) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePrefixParts(s: string): [string, number] | null {
|
||||
const slash = s.indexOf("/");
|
||||
if (slash < 0 || slash !== s.lastIndexOf("/")) return null;
|
||||
const addr = s.slice(0, slash);
|
||||
const lenRaw = s.slice(slash + 1);
|
||||
if (!/^\d{1,3}$/.test(lenRaw)) return null;
|
||||
return [addr, Number(lenRaw)];
|
||||
}
|
||||
|
||||
/** "addr/len" CIDR with family-correct length → normalized string, else null. */
|
||||
export function parsePrefix(input: string): string | null {
|
||||
const s = input.trim();
|
||||
const parts = parsePrefixParts(s);
|
||||
if (!parts) return null;
|
||||
const [addr, len] = parts;
|
||||
if (isIpv4Octets(addr)) return len <= 32 ? `${addr}/${len}` : null;
|
||||
if (addr.includes(":") && isIpv6(addr)) return len <= 128 ? `${addr}/${len}` : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
||||
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
|
||||
* no overlap. A bare number is always treated as an ASN.)
|
||||
*/
|
||||
export function classifyNetworkQuery(input: string): NetworkQuery {
|
||||
const s = input.trim();
|
||||
if (!s) return { kind: "none" };
|
||||
const prefix = parsePrefix(s);
|
||||
if (prefix) return { kind: "prefix", prefix };
|
||||
const ip = parseIpAddress(s);
|
||||
if (ip) return { kind: "ip", ip };
|
||||
const asn = parseAsn(s);
|
||||
if (asn !== null) return { kind: "asn", asn };
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AppProviders } from "./app/providers";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/globals.css";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/shell.css";
|
||||
import "./styles/components.css";
|
||||
import "./styles/pages.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
</React.StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
112
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal file
112
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHealth, getServiceInfo } from "../api/service";
|
||||
import { CopyableValue } from "../components/CopyableValue";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { ErrorBlock } from "../components/StateBlock";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [
|
||||
{ method: "GET", path: "/api/v1/healthz", note: "Service health + latest indexed run" },
|
||||
{ method: "GET", path: "/api/v1/runs", note: "Run history (cursor paged)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}", note: "Run record ('latest' accepted)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/repos", note: "Repositories (cursor paged)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/repos/{id}/publication-points", note: "PPs of a repository" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/publication-points/{id}/objects", note: "Objects of a PP (filters: type,result,rejected,reason,q)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects", note: "All objects (streamed; same filters)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/by-uri?uri=", note: "Exact URI lookup" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/parsed", note: "Parsed projection (needs repo-bytes-db)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/validation", note: "Validation summary" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/chain", note: "Chain edges" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/vrps/lookup?ip=|prefix=[&asn=]", note: "VRP lookup (covering semantics, cursor paged)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" },
|
||||
{ method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/exports/{job}/download", note: "Download export tarball" },
|
||||
{ method: "POST", path: "/api/v1/runs/{run}/objects/{id}/validation/explain", note: "On-demand validation explain (audit projection)" },
|
||||
];
|
||||
|
||||
export default function ApiStatusPage() {
|
||||
const { runId, runQuery } = useRun();
|
||||
const infoQuery = useQuery({ queryKey: ["service-info"], queryFn: getServiceInfo });
|
||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="API" subtitle="Query service connection status and endpoint reference" />
|
||||
|
||||
<div className="detail-grid">
|
||||
<Panel title="Service">
|
||||
{infoQuery.isError ? (
|
||||
<ErrorBlock error={infoQuery.error} onRetry={() => infoQuery.refetch()} title="Query service unreachable" />
|
||||
) : (
|
||||
<dl className="meta-grid">
|
||||
<dt>Service</dt>
|
||||
<dd className="mono">{infoQuery.data?.service ?? "…"}</dd>
|
||||
<dt>API version</dt>
|
||||
<dd className="mono">{infoQuery.data?.version ?? "…"}</dd>
|
||||
<dt>Health</dt>
|
||||
<dd>
|
||||
{healthQuery.isError ? (
|
||||
<StatusPill status="error" label="unhealthy" />
|
||||
) : (
|
||||
<StatusPill status={healthQuery.data?.status ?? "unknown"} />
|
||||
)}
|
||||
</dd>
|
||||
<dt>Latest indexed run</dt>
|
||||
<dd className="mono">{healthQuery.data?.latestReadyRun ?? "—"}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Explorer context">
|
||||
<dl className="meta-grid">
|
||||
<dt>Active run</dt>
|
||||
<dd className="mono">{runQuery.data?.runId ?? runId}</dd>
|
||||
<dt>Index status</dt>
|
||||
<dd><StatusPill status={runQuery.data?.indexStatus} /></dd>
|
||||
<dt>API base</dt>
|
||||
<dd><CopyableValue value={`${window.location.origin}/api/v1`} max={64} label="API base URL" /></dd>
|
||||
</dl>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title="Deployment notes">
|
||||
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
||||
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
||||
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
||||
<li>VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Endpoint reference" flush>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table endpoint-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Endpoint</th>
|
||||
<th scope="col">Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ENDPOINTS.map((endpoint) => (
|
||||
<tr key={`${endpoint.method}:${endpoint.path}`}>
|
||||
<td>
|
||||
<span className={`method${endpoint.method === "POST" ? " post" : ""}`}>{endpoint.method}</span>
|
||||
{endpoint.path}
|
||||
</td>
|
||||
<td>{endpoint.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal file
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { exportDownloadUrl, listExports } from "../api/service";
|
||||
import type { ExportJobRecord } from "../api/schemas";
|
||||
import { DataTable, type Column } from "../components/DataTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { Notice } from "../components/StateBlock";
|
||||
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
/**
|
||||
* Export job history for the active run. Jobs are held in the query service
|
||||
* process memory, so the list resets when the service restarts.
|
||||
*/
|
||||
export default function ExportsPage() {
|
||||
const { runId } = useRun();
|
||||
|
||||
const exportsQuery = useQuery({
|
||||
queryKey: ["exports", runId],
|
||||
queryFn: () => listExports(runId, { limit: 50 }),
|
||||
refetchInterval: (query) => {
|
||||
const jobs = query.state.data?.items ?? [];
|
||||
return jobs.some((job) => job.status === "running") ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<ExportJobRecord>[] = [
|
||||
{
|
||||
key: "job",
|
||||
header: "Job",
|
||||
render: (job) => <span className="mono text-small">{job.jobId.slice(0, 12)}…</span>,
|
||||
},
|
||||
{ key: "scope", header: "Scope", render: (job) => <span className="chip">{job.scope}</span> },
|
||||
{
|
||||
key: "target",
|
||||
header: "Target",
|
||||
render: (job) => (
|
||||
<span className="mono text-small">{job.repoId ?? job.ppId ?? "object set"}</span>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "Status", render: (job) => <StatusPill status={job.status} /> },
|
||||
{
|
||||
key: "objects",
|
||||
header: "Objects",
|
||||
numeric: true,
|
||||
render: (job) => formatInt(job.objectCount),
|
||||
},
|
||||
{ key: "bytes", header: "Size", numeric: true, render: (job) => formatBytes(job.bytesWritten) },
|
||||
{
|
||||
key: "created",
|
||||
header: "Created",
|
||||
render: (job) => <span title={formatUtc(job.createdAt)}>{formatRelative(job.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: "error",
|
||||
header: "Error",
|
||||
render: (job) =>
|
||||
job.error ? <span className="text-small" title={job.error}>{job.error}</span> : <span className="text-faint">—</span>,
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
header: "",
|
||||
render: (job) =>
|
||||
job.status === "complete" ? (
|
||||
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
|
||||
Download
|
||||
</a>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Exports"
|
||||
subtitle="Evidence bundles (raw object tarballs) produced by the query service"
|
||||
/>
|
||||
<Notice kind="info">
|
||||
Export jobs live in the query service process memory and are cleared on
|
||||
restart. Creating exports requires the service to run with --repo-bytes-db.
|
||||
New exports can be started from an object, publication point or repository
|
||||
page.
|
||||
</Notice>
|
||||
<Panel title="Jobs" flush>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={exportsQuery.data?.items ?? []}
|
||||
rowKey={(job) => job.jobId}
|
||||
loading={exportsQuery.isPending}
|
||||
error={exportsQuery.isError ? exportsQuery.error : undefined}
|
||||
onRetry={() => exportsQuery.refetch()}
|
||||
emptyTitle="No export jobs yet"
|
||||
emptyHint="Start an export from an object detail page (object set) to see it here."
|
||||
caption="Export jobs"
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal file
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="not-found">
|
||||
<div className="not-found-code">404</div>
|
||||
<p className="text-muted">This page does not exist.</p>
|
||||
<p style={{ marginTop: 12 }}>
|
||||
<Link className="btn" to="/">
|
||||
Back to Overview
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal file
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
||||
import { apiFetchBlob, saveBlob } from "../api/client";
|
||||
import {
|
||||
createExport,
|
||||
explainObjectValidation,
|
||||
getExportJob,
|
||||
getObject,
|
||||
getObjectChain,
|
||||
getObjectProjection,
|
||||
getObjectValidation,
|
||||
rawObjectUrl,
|
||||
} from "../api/service";
|
||||
import type { ValidationIssue } from "../api/schemas";
|
||||
import { CopyableValue } from "../components/CopyableValue";
|
||||
import { DataTable, type Column } from "../components/DataTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { ProjectionView } from "../components/ProjectionView";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||
import { TabPanel, Tabs } from "../components/Tabs";
|
||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||
import { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
||||
const last = uri?.split("/").filter(Boolean).pop();
|
||||
if (last && last.length <= 128) return last;
|
||||
const ext = type === "mft" ? ".mft" : type === "crl" ? ".crl" : type === "roa" ? ".roa" : type === "asa" ? ".asa" : ".cer";
|
||||
return `${(sha256 ?? "object").slice(0, 16)}${ext}`;
|
||||
}
|
||||
|
||||
function IssueList({ issues }: { issues: ValidationIssue[] }) {
|
||||
if (issues.length === 0) return <p className="text-muted">No issues recorded.</p>;
|
||||
return (
|
||||
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{issues.map((issue, i) => (
|
||||
<li key={i}>
|
||||
<StatusPill status={issue.severity} />
|
||||
{issue.reasonCode ? <code style={{ marginLeft: 8 }}>{issue.reasonCode}</code> : null}
|
||||
<span style={{ marginLeft: 8 }}>{issue.reasonText ?? "—"}</span>
|
||||
{issue.rfcRefs?.length ? (
|
||||
<span className="text-faint text-small" style={{ marginLeft: 8 }}>
|
||||
[{issue.rfcRefs.join(", ")}]
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
const validationQuery = useQuery({
|
||||
queryKey: ["object-validation", runId, objectInstanceId],
|
||||
queryFn: () => getObjectValidation(runId, objectInstanceId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const explainMutation = useMutation({
|
||||
mutationFn: () => explainObjectValidation(runId, objectInstanceId, { forceRefresh }),
|
||||
});
|
||||
|
||||
if (validationQuery.isPending) return <LoadingBlock label="Loading validation summary…" />;
|
||||
if (validationQuery.isError) {
|
||||
return (
|
||||
<ErrorBlock
|
||||
error={validationQuery.error}
|
||||
onRetry={() => validationQuery.refetch()}
|
||||
title="Failed to load validation summary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validationQuery.data;
|
||||
const explain = explainMutation.data;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
|
||||
<dl className="meta-grid">
|
||||
<dt>Final status</dt>
|
||||
<dd><StatusPill status={validation.finalStatus} /></dd>
|
||||
<dt>Audit result</dt>
|
||||
<dd><StatusPill status={validation.auditResult} /></dd>
|
||||
<dt>Detail</dt>
|
||||
<dd>{validation.detailSummary ?? "—"}</dd>
|
||||
</dl>
|
||||
|
||||
<section>
|
||||
<h3 className="text-small" style={{ marginBottom: 8 }}>File validation (parsevalidate)</h3>
|
||||
<p style={{ marginBottom: 8 }}>
|
||||
<StatusPill status={validation.parsevalidate?.status} />
|
||||
</p>
|
||||
<IssueList issues={validation.parsevalidate?.issues ?? []} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-small" style={{ marginBottom: 8 }}>Chain validation (chainvalidate)</h3>
|
||||
<p style={{ marginBottom: 8 }}>
|
||||
<StatusPill status={validation.chainvalidate?.status} />
|
||||
</p>
|
||||
<IssueList issues={validation.chainvalidate?.issues ?? []} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-small" style={{ marginBottom: 8 }}>Explain validation</h3>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => explainMutation.mutate()}
|
||||
disabled={explainMutation.isPending}
|
||||
>
|
||||
{explainMutation.isPending ? (
|
||||
<span className="spinner small" aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={13} aria-hidden="true" />
|
||||
)}
|
||||
{explain ? "Run again" : "Run explain"}
|
||||
</button>
|
||||
<label className="filter-check" style={{ paddingBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceRefresh}
|
||||
onChange={(e) => setForceRefresh(e.target.checked)}
|
||||
/>
|
||||
Force refresh (bypass cached explain)
|
||||
</label>
|
||||
</div>
|
||||
{explainMutation.isError ? (
|
||||
<Notice kind="error">Explain failed: {explainMutation.error.message}</Notice>
|
||||
) : null}
|
||||
{explain ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Notice kind="info">
|
||||
Explain mode: <strong>{explain.explainMode ?? "audit projection"}</strong> —{" "}
|
||||
{explain.authoritative
|
||||
? "authoritative revalidation."
|
||||
: "not a full revalidation; derived from the run audit trail."}
|
||||
</Notice>
|
||||
<dl className="meta-grid">
|
||||
<dt>Final status</dt>
|
||||
<dd><StatusPill status={explain.finalStatus} /></dd>
|
||||
<dt>Parse stage</dt>
|
||||
<dd><StatusPill status={explain.parsevalidate?.status} /></dd>
|
||||
<dt>Chain stage</dt>
|
||||
<dd>
|
||||
<StatusPill status={explain.chainvalidate?.status} />{" "}
|
||||
<span className="text-muted text-small">
|
||||
{formatInt(explain.chainvalidate?.edgesCount)} chain edges
|
||||
</span>
|
||||
</dd>
|
||||
{explain.chainvalidate?.note ? (
|
||||
<>
|
||||
<dt>Note</dt>
|
||||
<dd>{explain.chainvalidate.note}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||
const chainQuery = useQuery({
|
||||
queryKey: ["object-chain", runId, objectInstanceId],
|
||||
queryFn: () => getObjectChain(runId, objectInstanceId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (chainQuery.isPending) return <LoadingBlock label="Loading chain edges…" />;
|
||||
if (chainQuery.isError) {
|
||||
return <ErrorBlock error={chainQuery.error} onRetry={() => chainQuery.refetch()} title="Failed to load chain" />;
|
||||
}
|
||||
const edges = chainQuery.data;
|
||||
if (edges.length === 0) {
|
||||
return <EmptyBlock title="No chain edges" hint="No issuer/manifest relationships were recorded for this object." />;
|
||||
}
|
||||
|
||||
const columns: Column<(typeof edges)[number]>[] = [
|
||||
{ key: "relation", header: "Relation", render: (edge) => <span className="chip">{edge.relation}</span> },
|
||||
{
|
||||
key: "from",
|
||||
header: "From URI",
|
||||
render: (edge) => <CopyableValue value={edge.fromUri} max={48} label="from URI" />,
|
||||
},
|
||||
{
|
||||
key: "to",
|
||||
header: "To URI",
|
||||
render: (edge) => <CopyableValue value={edge.toUri} max={48} label="to URI" />,
|
||||
},
|
||||
{ key: "status", header: "Status", render: (edge) => <StatusPill status={edge.status} /> },
|
||||
];
|
||||
|
||||
return <DataTable columns={columns} rows={edges} rowKey={(e) => `${e.relation}:${e.fromUri}:${e.toUri}`} caption="Certificate chain edges" />;
|
||||
}
|
||||
|
||||
export default function ObjectDetailPage() {
|
||||
const { objectInstanceId = "" } = useParams();
|
||||
const { runId } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "parsed";
|
||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||
|
||||
const objectQuery = useQuery({
|
||||
queryKey: ["object", runId, objectInstanceId],
|
||||
queryFn: () => getObject(runId, objectInstanceId),
|
||||
});
|
||||
|
||||
const projectionQuery = useQuery({
|
||||
queryKey: ["object-parsed", runId, objectInstanceId],
|
||||
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
||||
enabled: tab === "parsed",
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const rawMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const blob = await apiFetchBlob(rawObjectUrl(runId, objectInstanceId));
|
||||
const object = objectQuery.data;
|
||||
saveBlob(blob, filenameFor(object?.uri, object?.sha256, object?.objectType ?? ""));
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
|
||||
onSuccess: (job) => setExportJobId(job.jobId),
|
||||
});
|
||||
|
||||
const exportJobQuery = useQuery({
|
||||
queryKey: ["export-job", runId, exportJobId],
|
||||
queryFn: () => getExportJob(runId, exportJobId!),
|
||||
enabled: exportJobId !== null,
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
if (objectQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Object" />
|
||||
<ErrorBlock error={objectQuery.error} onRetry={() => objectQuery.refetch()} title="Failed to load object" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (objectQuery.isPending) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Object" />
|
||||
<LoadingBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const object = objectQuery.data;
|
||||
|
||||
const setTab = (next: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("tab", next);
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<Link to={withRunParam("/objects", runId)}>Objects</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{objectTypeLabel(object.objectType)}</span>
|
||||
</nav>
|
||||
|
||||
<PageHeader
|
||||
title={
|
||||
<span style={{ display: "inline-flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{objectTypeLabel(object.objectType)} object
|
||||
<StatusPill status={object.rejected ? "rejected" : object.result} />
|
||||
<StatusPill status={object.sourceSection} />
|
||||
</span>
|
||||
}
|
||||
subtitle={<CopyableValue value={object.uri} max={110} label="object URI" />}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => rawMutation.mutate()}
|
||||
disabled={rawMutation.isPending}
|
||||
>
|
||||
{rawMutation.isPending ? <span className="spinner small" aria-hidden="true" /> : <Download size={13} aria-hidden="true" />}
|
||||
Download raw
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"}
|
||||
>
|
||||
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
||||
Export object set
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{rawMutation.isError ? (
|
||||
<Notice kind="error">
|
||||
Raw download failed: {rawMutation.error.message}. The query service needs
|
||||
--repo-bytes-db to serve raw bytes.
|
||||
</Notice>
|
||||
) : null}
|
||||
{exportMutation.isError ? (
|
||||
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
||||
) : null}
|
||||
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
||||
|
||||
<Panel className="object-header-card">
|
||||
<dl className="meta-grid">
|
||||
<dt>Object type</dt>
|
||||
<dd><span className="chip">{objectTypeLabel(object.objectType)}</span></dd>
|
||||
<dt>URI</dt>
|
||||
<dd><CopyableValue value={object.uri} max={110} label="object URI" /></dd>
|
||||
<dt>SHA-256</dt>
|
||||
<dd><CopyableValue value={object.sha256} max={72} label="object sha256" /></dd>
|
||||
<dt>Instance ID</dt>
|
||||
<dd><CopyableValue value={object.objectInstanceId} max={56} label="object instance id" /></dd>
|
||||
<dt>Result</dt>
|
||||
<dd><StatusPill status={object.rejected ? "rejected" : object.result} /></dd>
|
||||
<dt>Reject reason</dt>
|
||||
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
||||
<dt>Detail</dt>
|
||||
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
||||
<dt>Size</dt>
|
||||
<dd>{formatBytes(object.sizeBytes)}</dd>
|
||||
<dt>Repository</dt>
|
||||
<dd>
|
||||
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
||||
{object.repoId}
|
||||
</Link>
|
||||
</dd>
|
||||
<dt>Publication point</dt>
|
||||
<dd>
|
||||
<Link className="mono" to={withRunParam(`/publication-points/${encodeURIComponent(object.ppId)}`, runId)}>
|
||||
{object.ppId}
|
||||
</Link>
|
||||
</dd>
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<Panel flush>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "parsed", label: "Parsed" },
|
||||
{ id: "validation", label: "Validation" },
|
||||
{ id: "chain", label: "Chain" },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel="Object detail sections"
|
||||
/>
|
||||
<TabPanel id="parsed" active={tab}>
|
||||
<div style={{ padding: 16 }}>
|
||||
{projectionQuery.isPending ? (
|
||||
<LoadingBlock label="Loading parsed projection…" />
|
||||
) : projectionQuery.isError ? (
|
||||
<ErrorBlock
|
||||
error={projectionQuery.error}
|
||||
onRetry={() => projectionQuery.refetch()}
|
||||
title="Failed to load parsed projection"
|
||||
/>
|
||||
) : (
|
||||
<ProjectionView
|
||||
record={projectionQuery.data ?? null}
|
||||
runId={runId}
|
||||
objectInstanceId={objectInstanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="validation" active={tab}>
|
||||
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</TabPanel>
|
||||
<TabPanel id="chain" active={tab}>
|
||||
<ChainTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</TabPanel>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal file
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { listObjects } from "../api/service";
|
||||
import { Notice } from "../components/StateBlock";
|
||||
import {
|
||||
EMPTY_OBJECT_FILTERS,
|
||||
ObjectsTable,
|
||||
type ObjectFilterValues,
|
||||
} from "../components/ObjectsTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
function filtersFromParams(params: URLSearchParams): ObjectFilterValues {
|
||||
return {
|
||||
type: params.get("type") ?? "",
|
||||
result: params.get("result") ?? "",
|
||||
rejectedOnly: params.get("rejected") === "true",
|
||||
q: params.get("q") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ObjectsPage() {
|
||||
const { runId } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
|
||||
|
||||
const setFilters = (next: ObjectFilterValues) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const write = (key: string, value: string) => {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
};
|
||||
write("type", next.type);
|
||||
write("result", next.result);
|
||||
write("q", next.q);
|
||||
if (next.rejectedOnly) params.set("rejected", "true");
|
||||
else params.delete("rejected");
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const hasFilters =
|
||||
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
||||
filters.result !== "" ||
|
||||
filters.rejectedOnly ||
|
||||
filters.q !== "";
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Objects"
|
||||
subtitle="Browse every object instance indexed for the active run. Lists stream from the run report — deep paging on large runs can be slow."
|
||||
/>
|
||||
{!hasFilters ? (
|
||||
<Notice kind="info">
|
||||
This run may contain hundreds of thousands of objects. Use the filters to
|
||||
narrow the scan; all filters are applied server-side.
|
||||
</Notice>
|
||||
) : null}
|
||||
<Panel title="All objects" flush>
|
||||
<ObjectsTable
|
||||
runId={runId}
|
||||
queryKeyScope="global"
|
||||
fetcher={(f) => listObjects(runId, f)}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user