14 Commits

130 changed files with 15293 additions and 5345 deletions

View File

@@ -1,6 +1,7 @@
target/ target/
.git/ .git/
.gitignore .gitignore
docker/
perf.* perf.*
**/* copy.excalidraw **/* copy.excalidraw
ui/rpki-explorer/node_modules/ ui/rpki-explorer/node_modules/

View File

@@ -2,6 +2,8 @@
# `build_docker_installer_package.sh` rewrites package-specific placeholders # `build_docker_installer_package.sh` rewrites package-specific placeholders
# when producing amd64/arm64 release packages. # when producing amd64/arm64 release packages.
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md # 中文说明见 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 metadata and architecture guardrails.
PACKAGE_ARCH=__PACKAGE_ARCH__ PACKAGE_ARCH=__PACKAGE_ARCH__
@@ -86,6 +88,12 @@ RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
METRICS_INSTANCE=__PACKAGE_ARCH__-installer METRICS_INSTANCE=__PACKAGE_ARCH__-installer
METRICS_PORT=9556 METRICS_PORT=9556
METRICS_POLL_SECS=10 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. # 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 # Default points to an installer-managed empty fallback directory. To enable real RTR
# metrics, set this to the host-side report directory, for example: # metrics, set this to the host-side report directory, for example:

View File

@@ -39,6 +39,10 @@ services:
- ${METRICS_POLL_SECS:-10} - ${METRICS_POLL_SECS:-10}
- --instance - --instance
- ${METRICS_INSTANCE:-package-installer} - ${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: ports:
- "${METRICS_PORT:-9556}:9556" - "${METRICS_PORT:-9556}:9556"
volumes: volumes:

View File

@@ -850,6 +850,264 @@
], ],
"title": "Fresh PP / Object Counts by Run", "title": "Fresh PP / Object Counts by Run",
"type": "timeseries" "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", "refresh": "5s",

View File

@@ -80,7 +80,7 @@ Supported values:
- `rfc6487`: switch back to the original RFC 6487 resource containment behavior. - `rfc6487`: switch back to the original RFC 6487 resource containment behavior.
The installer / soak runner passes `--resource-validation-mode` to every `rpki` child start. 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 ## 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. 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 ## Data Directory
Default host directory: Default host directory:

View File

@@ -80,7 +80,7 @@ RESOURCE_VALIDATION_MODE=validation-update-03
- `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。 - `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。
installer / soak runner 每次启动 `rpki` 子进程时都会显式传入 `--resource-validation-mode`。 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 容器也能稳定启动。 该命令只接受本机绝对路径,会先校验候选 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 秒)。
## 数据目录 ## 数据目录
默认宿主机目录: 默认宿主机目录:

View File

@@ -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 ./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: Upgrade does not delete:

View File

@@ -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 ./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 操作前失败。
升级不会删除以下数据: 升级不会删除以下数据:

View File

@@ -6,6 +6,11 @@ ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example" ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml" COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
MANIFEST_FILE="${MANIFEST_FILE:-$INSTALLER_ROOT/PACKAGE-MANIFEST.env}" 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() { log() {
printf '[ours-rp-installer] %s\n' "$*" printf '[ours-rp-installer] %s\n' "$*"
@@ -48,20 +53,246 @@ env_file_has_key() {
[[ -f "$env_path" ]] && grep -Eq "^${key}=" "$env_path" [[ -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 env_path="$1"
local key="$2" local key="$2"
local value="$3" local value="$3"
local tmp_env 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 return 0
fi fi
tmp_env="$(mktemp)" tmp_env="$(mktemp)"
cat "$env_path" > "$tmp_env" ENV_FILE_REPLACEMENT="$key=$encoded_value" awk -v key="$key" '
printf '%s=%s\n' "$key" "$value" >> "$tmp_env" 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" 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() { env_flag_enabled() {
case "${1:-0}" in case "${1:-0}" in
1|true|TRUE|yes|YES|on|ON) 1|true|TRUE|yes|YES|on|ON)
@@ -156,15 +387,15 @@ assert_arch_compatibility() {
load_env() { load_env() {
load_manifest load_manifest
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE" [[ -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" cp "$ENV_EXAMPLE" "$ENV_FILE"
log "created .env from .env.example" log "created .env from .env.example"
fi fi
set -a validate_env_file_contract "$ENV_FILE"
# shellcheck disable=SC1090 load_env_file "$ENV_FILE"
source "$ENV_FILE"
set +a
if [[ -n "${MANIFEST_PACKAGE_ARCH_RAW:-}" ]]; then 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" 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")}" PACKAGE_PLATFORM="${PACKAGE_PLATFORM:-$(platform_for_arch "$PACKAGE_ARCH")}"
fi 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() { compose_cmd() {
@@ -546,6 +756,18 @@ verify_metrics_image() {
log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM" log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM"
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics" 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_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() { verify_image_platform() {

View File

@@ -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 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. .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 Image tags are intentionally kept from the new package so the upgraded service
actually runs the new packaged runtime and monitor images. Use actually runs the new packaged runtime and monitor images. Use
--keep-reused-image only when you intentionally want to keep previous image tags. --keep-reused-image only when you intentionally want to keep previous image tags.
@@ -39,56 +41,80 @@ while [[ $# -gt 0 ]]; do
esac esac
done 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() { overlay_reused_env() {
local source_env="$1" local source_env="$1"
local target_env="$2" local target_env="$2"
local key local key
local value local value
while IFS='=' read -r key _; do local -a reused_keys=()
[[ -n "$key" ]] || continue local -a ignored_removed_keys=()
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue 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 case "$key" in
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM) PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
preserved_package_keys+=("$key")
continue continue
;; ;;
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE) 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 esac
value="$(env_get_key "$source_env" "$key")" [[ -n "${ENV_PARSED_VALUES[$key]+x}" ]] || die "unable to read $key from reused environment: $source_env"
env_set_key "$target_env" "$key" "$value" value="${ENV_PARSED_VALUES[$key]}"
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$source_env" || true) 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 if [[ -n "$REUSE_ENV_FROM" ]]; then
[[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM" [[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM"
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE" cp "$ENV_EXAMPLE" "$ENV_FILE"
overlay_reused_env "$REUSE_ENV_FROM" "$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" 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
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 load_env
assert_arch_compatibility assert_arch_compatibility
create_data_dirs create_data_dirs

View 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'

View File

@@ -85,27 +85,66 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
mkdir -p /build-out/bin; \ mkdir -p /build-out/bin; \
cp "target/$target_triple/release/rpki_artifact_metrics" /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 FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
libexpat1 \
libssl3 \
libtls26 \
tzdata \ 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 WORKDIR /opt/ours-rp
COPY --from=builder /build-out/bin/ /opt/ours-rp/bin/ 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_COMMIT=unknown
ARG SOURCE_DIRTY=unknown ARG SOURCE_DIRTY=unknown
ARG BUILD_TIMESTAMP_UTC=unknown ARG BUILD_TIMESTAMP_UTC=unknown
ARG RPKI_CLIENT_VERSION=9.8
LABEL org.opencontainers.image.title="ours-rp-metrics" \ LABEL org.opencontainers.image.title="ours-rp-metrics" \
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \ org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \ org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \ org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
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/* \ RUN chmod +x /opt/ours-rp/bin/* \
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs && mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs

View 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())

View 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 ))

View File

@@ -1,18 +1,26 @@
# rpctl # rpctl
`rpctl` 是四款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次 delta 子 run。 `rpctl` 是五款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次复用 state 的子 run。
## 部署 ## 部署
```bash ```bash
mkdir -p /root/rpki_4rp_ctl mkdir -p /data/rpki_4rp_ctl
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.77.237.41:/root/rpki_4rp_ctl/ rsync -a rpki_2/rpki/scripts/rpctl/ root@47.251.127.231:/data/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/ rsync -a artifacts/rpki-prover/rpki-prover-6857d4bf-linux-amd64.docker.tar.gz root@47.251.127.231:/data/
ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle' 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 运行所需动态库。 `--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 ```bash
@@ -20,6 +28,8 @@ ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki
./rpctl status ./rpctl status
./rpctl start --rp ours-pp-object-cache --interval 10m --runs 6 --rirs all5 ./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 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 continue --exp-id <successful-exp-id> --runs 3 --interval 10m
./rpctl tail ./rpctl tail
./rpctl show ./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` | | `routinator` | `routinator-latest-release` |
| `rpki-client` | `rpki-client-latest-release` | | `rpki-client` | `rpki-client-latest-release` |
| `fort` | `fort-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/ 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。
## 历史查看与删除 ## 历史查看与删除

View File

@@ -4,8 +4,10 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import gzip import gzip
import hashlib
import json import json
import os import os
import platform
import re import re
import shutil import shutil
import signal import signal
@@ -37,6 +39,8 @@ PROFILES = {
"routinator-latest-release": "routinator", "routinator-latest-release": "routinator",
"rpki-client-latest-release": "rpki-client", "rpki-client-latest-release": "rpki-client",
"fort-latest-release": "fort", "fort-latest-release": "fort",
"rpki-prover-incremental": "rpki-prover",
"rpki-prover-full": "rpki-prover",
} }
ALIASES = { ALIASES = {
@@ -52,8 +56,15 @@ ALIASES = {
"rpki-client-latest-release": "rpki-client-latest-release", "rpki-client-latest-release": "rpki-client-latest-release",
"fort": "fort-latest-release", "fort": "fort-latest-release",
"fort-latest-release": "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 = { DEFAULT_CONFIG = {
"root": "/root/rpki_4rp_ctl", "root": "/root/rpki_4rp_ctl",
"bundle": "", "bundle": "",
@@ -207,6 +218,14 @@ def command_string(argv: list[str]) -> str:
return " ".join(shlex.quote(x) for x in argv) 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: 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"] 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()] 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) 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: def cmd_init(args: argparse.Namespace) -> None:
ensure_dirs() ensure_dirs()
bundle = Path(args.bundle).resolve() bundle = Path(args.bundle).resolve()
@@ -239,9 +354,16 @@ def cmd_init(args: argparse.Namespace) -> None:
cfg = load_config() cfg = load_config()
cfg["bundle"] = str(bundle) cfg["bundle"] = str(bundle)
cfg["initializedAtUtc"] = utc_now() cfg["initializedAtUtc"] = utc_now()
if args.rpki_prover_image:
cfg["rpkiProverImage"] = install_rpki_prover_image(Path(args.rpki_prover_image).resolve())
save_config(cfg) save_config(cfg)
print(f"initialized root={ctl_root()}") print(f"initialized root={ctl_root()}")
print(f"bundle={bundle}") 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: 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"), "--vrps-csv-out", str(run_dir / "vrps.csv"),
"--vaps-csv-out", str(run_dir / "vaps.csv"), "--vaps-csv-out", str(run_dir / "vaps.csv"),
"--compare-view-trust-anchor", "all5" if len(rirs) > 1 else rirs[0], "--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-size", "256",
"--parallel-phase2-ready-batch-wall-time-budget-ms", "100", "--parallel-phase2-ready-batch-wall-time-budget-ms", "100",
"--parallel-phase2-result-drain-batch-size", "2048", "--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 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: def normalize_one(run_root: Path, profile: str, run_seq: int) -> None:
script = ctl_root() / "scripts" / "normalize_and_collect.py" script = ctl_root() / "scripts" / "normalize_and_collect.py"
if not script.exists(): if not script.exists():
@@ -460,6 +817,18 @@ def parse_entry_rirs(entry: dict[str, Any]) -> list[str]:
return parse_rirs(str(value)) 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: def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs: list[str], append: bool = False) -> int:
ensure_dirs() ensure_dirs()
rp = PROFILES[profile] 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 profile_root = run_root / "profiles" / profile
(run_root / "logs").mkdir(parents=True, exist_ok=True) (run_root / "logs").mkdir(parents=True, exist_ok=True)
(run_root / "reports").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() now = utc_now()
if append: if append:
run_info = normalize_experiment_metadata(read_json(run_root / "run-info.json", {})) 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), "experimentRoot": str(run_root),
"stateRoot": str(profile_root / "state"), "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) write_json(run_root / "run-info.json", run_info)
status = { status = {
"state": "running", "state": "running",
@@ -535,7 +907,7 @@ def run_profile(profile: str, exp_id: str, runs: int, interval_secs: int, rirs:
time.sleep(sleep_seconds) time.sleep(sleep_seconds)
last_start = time.time() last_start = time.time()
snapshot = run_seq == 1 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}" run_dir = profile_root / "runs" / f"run_{run_seq:04d}"
if run_dir.exists() and any(run_dir.iterdir()): if run_dir.exists() and any(run_dir.iterdir()):
raise RuntimeError(f"refusing to overwrite existing run directory: {run_dir}") 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) argv, env = build_rpki_client_args(run_dir, state, rirs)
elif rp == "fort": elif rp == "fort":
argv, env = build_fort_args(run_dir, state, rirs) 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: else:
raise RuntimeError(rp) raise RuntimeError(rp)
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "command.txt").write_text(command_string(argv) + "\n", encoding="utf-8") (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) code = timed_run(run_dir, argv, env=env)
normalize_one(run_root, profile, run_seq) normalize_one(run_root, profile, run_seq)
meta = parse_latest_meta(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: if code != 0:
exit_code = code exit_code = code
break break
if rp == "rpki-prover":
write_rpki_prover_group_report(run_root, profile)
else:
write_group_report(run_root, interval_secs, completed_seq) write_group_report(run_root, interval_secs, completed_seq)
final_state = "success" if exit_code == 0 else "failed" final_state = "success" if exit_code == 0 else "failed"
update_current(state=final_state, finishedAtUtc=utc_now(), exitCode=exit_code) 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"runs={runs_dir()}")
print(f"logs={logs_dir()}") print(f"logs={logs_dir()}")
print(f"bundle={cfg.get('bundle','')}") 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() cur = current_live_status()
if cur.get("experimentRoot"): if cur.get("experimentRoot"):
print(f"current_experiment_root={cur['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") raise SystemExit("--runs must be >= 1")
if not (ctl_root() / "versions.json").exists(): if not (ctl_root() / "versions.json").exists():
raise SystemExit("rpctl is not initialized; run ./rpctl init --bundle <bundle-root> first") 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" exp_id = args.exp_id or f"{utc_id()}_{profile}_{args.runs}runs_{interval_secs}s"
runner_args = [ runner_args = [
sys.executable, sys.executable,
@@ -717,6 +1119,8 @@ def cmd_continue(args: argparse.Namespace) -> None:
if entry["state"] != "success": if entry["state"] != "success":
raise SystemExit(f"can only continue a successful experiment: exp_id={args.exp_id} state={entry['state']}") raise SystemExit(f"can only continue a successful experiment: exp_id={args.exp_id} state={entry['state']}")
profile = entry["profile"] profile = entry["profile"]
if PROFILES[profile] == "rpki-prover":
require_rpki_prover_image()
rirs = parse_entry_rirs(entry) rirs = parse_entry_rirs(entry)
interval_secs = parse_interval(args.interval) interval_secs = parse_interval(args.interval)
runner_args = [ runner_args = [
@@ -867,6 +1271,13 @@ def parse_command_summary(command: str) -> str:
keys.append(f"--rsync-scope={tokens[idx + 1]}") keys.append(f"--rsync-scope={tokens[idx + 1]}")
elif token.startswith("--rsync-scope="): elif token.startswith("--rsync-scope="):
keys.append(token) 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) return " ".join(keys)
@@ -945,6 +1356,10 @@ def cache_profile_name(profile: str) -> str:
return "pp+roa+crt" return "pp+roa+crt"
if profile == "ours-no-cache": if profile == "ours-no-cache":
return "off" return "off"
if profile == "rpki-prover-incremental":
return "incremental"
if profile == "rpki-prover-full":
return "full-every-iteration"
return "n/a" return "n/a"
@@ -971,6 +1386,8 @@ def profile_label(profile: str) -> str:
"routinator-latest-release": "routinator", "routinator-latest-release": "routinator",
"rpki-client-latest-release": "rpki-client", "rpki-client-latest-release": "rpki-client",
"fort-latest-release": "fort", "fort-latest-release": "fort",
"rpki-prover-incremental": "prover-inc",
"rpki-prover-full": "prover-full",
} }
return labels.get(profile, profile) return labels.get(profile, profile)
@@ -1043,6 +1460,9 @@ def infer_output_counts(profile: str, run_dir: Path) -> tuple[str, str]:
return vrps, vaps return vrps, vaps
if rp == "fort": if rp == "fort":
return count_csv_data_rows(run_dir / "vrps.csv"), "0" if (run_dir / "vrps.csv").exists() else "" 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 "", "" return "", ""
@@ -1355,6 +1775,7 @@ def fallback_run_details(entry: dict[str, Any]) -> list[dict[str, Any]]:
"wall_ms": timing.get("wallMs", ""), "wall_ms": timing.get("wallMs", ""),
"max_rss_kb": timing.get("maxRssKb", ""), "max_rss_kb": timing.get("maxRssKb", ""),
"vrps": first_non_empty(meta.get("counts", {}).get("vrps") if meta else "", inferred_vrps), "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), "vaps": first_non_empty(meta.get("counts", {}).get("vaps") if meta else "", inferred_vaps),
"remote_run_dir": str(run_dir), "remote_run_dir": str(run_dir),
}) })
@@ -1391,7 +1812,7 @@ def cmd_view(args: argparse.Namespace) -> None:
print("command=" + entry["command"]) print("command=" + entry["command"])
if rows: if rows:
print("\nper-run:") 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 = [] table_rows = []
for row in rows: for row in rows:
table_rows.append([ 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("wall_ms"), row.get("wallMs")),
first_non_empty(row.get("max_rss_kb"), row.get("maxRssKb")), 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"), 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("vaps"), row.get("VAPs")),
first_non_empty(row.get("remote_run_dir"), row.get("remoteRunDir")), 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: def build_parser() -> argparse.ArgumentParser:
formatter = argparse.RawDescriptionHelpFormatter formatter = argparse.RawDescriptionHelpFormatter
parser = argparse.ArgumentParser( 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, formatter_class=formatter,
epilog=""" epilog="""
RP/profile naming: RP/profile naming:
@@ -1435,6 +1857,14 @@ RP/profile naming:
fort fort
Alias of fort-latest-release. Uses archived FORT validator release binary. 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: Cache difference for ours RP:
--rp ours adds: --rp ours adds:
--enable-publication-point-validation-cache --enable-publication-point-validation-cache
@@ -1443,11 +1873,17 @@ Cache difference for ours RP:
--rp ours-no-cache does not add those flags. --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: 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 list
./rpctl start --rp ours --interval 10m --runs 6 --rirs all5 ./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 ours-no-cache --interval 10m --runs 6 --rirs all5
./rpctl start --rp rpki-prover --interval 0 --runs 2 --rirs all5
./rpctl status ./rpctl status
./rpctl show ./rpctl show
./rpctl show --failed ./rpctl show --failed
@@ -1458,7 +1894,8 @@ Typical usage:
Data model: Data model:
- Experiment data stays under ./runs/<profile>/<exp-id>/. - 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. - By default only one RP/profile can run at a time.
""", """,
) )
@@ -1481,14 +1918,24 @@ The bundle must contain:
scripts/write_group_report.py scripts/write_group_report.py
versions.json 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: Recommended source on 47.77.237.41:
/root/rpki_4rp_ctl_runtime_bundle /root/rpki_4rp_ctl_runtime_bundle
Example: 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("--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.set_defaults(func=cmd_init)
p = sub.add_parser( p = sub.add_parser(
@@ -1502,6 +1949,10 @@ Use this command when unsure which value to pass to --rp.
Important ours RP profiles: Important ours RP profiles:
ours-pp-object-cache cache-enabled profile ours-pp-object-cache cache-enabled profile
ours-no-cache no-cache baseline 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) 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 routinator --interval 15m --runs 2 --rirs apnic
./rpctl start --rp rpki-client --interval 0 --runs 1 --rirs apnic,arin ./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 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: Cache mapping:
--rp ours enables PP cache + ROA cache + child certificate cache. --rp ours enables PP cache + ROA cache + child certificate cache.
--rp ours-no-cache disables those cache flags. --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: Safety:
rpctl refuses to start a second RP while another rpctl runner is alive. rpctl refuses to start a second RP while another rpctl runner is alive.
""", """,
@@ -1537,7 +1994,7 @@ Safety:
p.add_argument( p.add_argument(
"--rp", "--rp",
required=True, 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( p.add_argument(
"--interval", "--interval",
@@ -1578,6 +2035,7 @@ Run semantics:
Examples: Examples:
./rpctl continue --exp-id 20260709T081122Z_ours-pp-object-cache_2runs_600s --runs 3 --interval 10m ./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 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.") p.add_argument("--exp-id", required=True, help="Existing successful experiment id to append to.")
@@ -1692,7 +2150,7 @@ Examples:
./rpctl show --json ./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("--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("--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.") 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
View 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()

View File

@@ -14,7 +14,7 @@ Usage:
scripts/soak/build_portable_soak_package.sh [--out-dir <path>] [--profile <profile>] scripts/soak/build_portable_soak_package.sh [--out-dir <path>] [--profile <profile>]
Requires release binaries to already exist. Build them first, for example: 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 USAGE
} }
@@ -53,7 +53,7 @@ else
TARGET_BIN_DIR="$REPO_ROOT/target/$PROFILE" TARGET_BIN_DIR="$REPO_ROOT/target/$PROFILE"
fi 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=( OPTIONAL_BINS=(
ccr_dump ccr_dump
ccr_state_compare ccr_state_compare
@@ -82,7 +82,7 @@ STAGE_DIR="$BUILD_ROOT/$PACKAGE_DIR_NAME"
ARCHIVE_PATH="$OUT_DIR/$PACKAGE_NAME.tar.gz" ARCHIVE_PATH="$OUT_DIR/$PACKAGE_NAME.tar.gz"
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH" 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" "$STAGE_DIR/runs" "$STAGE_DIR/state" "$STAGE_DIR/logs" "$STAGE_DIR/tmp"
install -m 0755 "$SCRIPT_DIR/run_soak.sh" "$STAGE_DIR/run_soak.sh" install -m 0755 "$SCRIPT_DIR/run_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/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.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 "$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/.env"
install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/portable-soak.env.example" install -m 0644 "$SCRIPT_DIR/portable-soak.env.example" "$STAGE_DIR/portable-soak.env.example"

View File

@@ -48,6 +48,7 @@ TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}" RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}"
INVALID_ROOT="$STATE_ROOT/invalid" INVALID_ROOT="$STATE_ROOT/invalid"
RESET_STAGING_ROOT="$STATE_ROOT/reset-staging" 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_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_CONNECT_TIMEOUT_SECS="${LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS:-15}"
LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}" LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}"
@@ -1080,6 +1081,7 @@ build_child_args() {
CHILD_ARGS+=( CHILD_ARGS+=(
--report-json "{run_out}/report.json" --report-json "{run_out}/report.json"
--validation-contract-out "{run_out}/validation-contract.json"
) )
if is_true "$OUTPUT_COMPACT_REPORT"; then if is_true "$OUTPUT_COMPACT_REPORT"; then
CHILD_ARGS+=(--report-json-compact) CHILD_ARGS+=(--report-json-compact)
@@ -1109,6 +1111,59 @@ build_child_args() {
fi 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() { copy_inner_run_outputs() {
local daemon_state_root="$1" local daemon_state_root="$1"
local run_dir="$2" local run_dir="$2"
@@ -1304,6 +1359,12 @@ run_one_round() {
final_status="failed" final_status="failed"
fi fi
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" \ 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" \ "$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" \ "$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 python3
require_command date require_command date
require_command find require_command find
require_command flock
if [[ "$TAL_INPUT_MODE" == "file-live-ta" ]]; then if [[ "$TAL_INPUT_MODE" == "file-live-ta" ]]; then
require_command curl require_command curl
validate_positive_int "LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS" "$LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS" validate_positive_int "LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS" "$LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS"
@@ -1360,7 +1422,7 @@ main() {
fi fi
done 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 if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
mkdir -p "$RSYNC_MIRROR_ROOT" mkdir -p "$RSYNC_MIRROR_ROOT"
fi fi
@@ -1386,6 +1448,9 @@ main() {
local any_failed=0 local any_failed=0
while (( run_forever == 1 || next_index <= stop_index )); do 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_DB_PATH=""
INVALID_STATE_PATH="" INVALID_STATE_PATH=""
INVALID_TMP_PATH="" INVALID_TMP_PATH=""
@@ -1472,6 +1537,8 @@ main() {
echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2 echo "completed run $(printf 'run_%04d' "$next_index") status=failed" >&2
any_failed=1 any_failed=1
fi fi
flock -u "$state_lock_fd"
exec {state_lock_fd}>&-
if (( STOP_AFTER_SECS > 0 )); then if (( STOP_AFTER_SECS > 0 )); then
elapsed_secs=$(( $(date +%s) - started_epoch )) elapsed_secs=$(( $(date +%s) - started_epoch ))
if (( elapsed_secs >= STOP_AFTER_SECS )); then if (( elapsed_secs >= STOP_AFTER_SECS )); then

View 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,按不匹配状态集合继续排查。

View 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"

View File

@@ -7,9 +7,11 @@ use std::process::Command;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rpki::blob_store::ExternalRepoBytesDb; use rpki::blob_store::ExternalRepoBytesDb;
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
use rpki::query::vrp::VrpLookup;
use rpki::query_db::{ use rpki::query_db::{
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError, ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
ValidationExplainRecord, QueryDbError, ValidationExplainRecord,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -544,6 +546,18 @@ fn route_request(
return Ok(json!({"data":{"service":"rpki_query_service","version":1}})); return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
} }
match segments.as_slice() { 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), ["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
["latest_run"] => { ["latest_run"] => {
let run_id = db let run_id = db
@@ -585,6 +599,22 @@ fn route_request(
Some(run_id), 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"] => { ["runs", raw_run_id, "repos"] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
page_response( page_response(
@@ -630,8 +660,15 @@ fn route_request(
} }
["runs", raw_run_id, "repos", repo_id, "objects"] => { ["runs", raw_run_id, "repos", repo_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response( 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), Some(run_id),
) )
} }
@@ -669,18 +706,63 @@ fn route_request(
} }
["runs", raw_run_id, "publication-points", pp_id, "objects"] => { ["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response( 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), Some(run_id),
) )
} }
["runs", raw_run_id, "objects"] => { ["runs", raw_run_id, "objects"] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
let filter = object_filter_from_query(&query)?;
page_response( 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), 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"] => { ["runs", raw_run_id, "objects", "by-uri"] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
let uri = query let uri = query
@@ -792,6 +874,18 @@ fn route_request(
offset_cursor(&query), 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] => { ["runs", raw_run_id, "exports", job_id] => {
let run_id = resolve_run(db, raw_run_id)?; let run_id = resolve_run(db, raw_run_id)?;
let job = get_export_job(db, export_jobs, &run_id, job_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)?) 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( fn run_export_job(
db: &QueryDb, db: &QueryDb,
repo_bytes: &ExternalRepoBytesDb, repo_bytes: &ExternalRepoBytesDb,
@@ -1575,6 +1687,103 @@ fn projection_for_explain(
Ok(Some(projection)) 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( fn global_projection_for_hash(
db: &QueryDb, db: &QueryDb,
repo_bytes: Option<&ExternalRepoBytesDb>, repo_bytes: Option<&ExternalRepoBytesDb>,
@@ -1878,6 +2087,93 @@ fn limit(query: &BTreeMap<String, String>) -> usize {
.clamp(1, 1000) .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> { fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
query.get("cursor").map(String::as_str) query.get("cursor").map(String::as_str)
} }
@@ -2223,6 +2519,28 @@ mod tests {
.as_u64(), .as_u64(),
Some(1) 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 [ for target in [
"/api/v1/runs/latest/repos", "/api/v1/runs/latest/repos",

View 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")
);
}
}

View File

@@ -77,6 +77,7 @@ pub struct ExternalRawStoreDb {
pub struct ExternalRepoBytesDb { pub struct ExternalRepoBytesDb {
path: PathBuf, path: PathBuf,
db: Arc<DB>, db: Arc<DB>,
read_only: bool,
} }
impl ExternalRawStoreDb { impl ExternalRawStoreDb {
@@ -189,6 +190,7 @@ impl ExternalRepoBytesDb {
Ok(Self { Ok(Self {
path, path,
db: Arc::new(db), db: Arc::new(db),
read_only: false,
}) })
} }
@@ -201,6 +203,7 @@ impl ExternalRepoBytesDb {
Ok(Self { Ok(Self {
path, path,
db: Arc::new(db), db: Arc::new(db),
read_only: true,
}) })
} }
@@ -208,6 +211,12 @@ impl ExternalRepoBytesDb {
if blobs.is_empty() { if blobs.is_empty() {
return Ok(()); 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(); let mut batch = WriteBatch::default();
for (sha256_hex, bytes) in blobs { for (sha256_hex, bytes) in blobs {
validate_blob_sha256_hex(sha256_hex)?; validate_blob_sha256_hex(sha256_hex)?;
@@ -221,6 +230,42 @@ impl ExternalRepoBytesDb {
Ok(()) 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>>> { pub fn get_blob_bytes(&self, sha256_hex: &str) -> StorageResult<Option<Vec<u8>>> {
validate_blob_sha256_hex(sha256_hex)?; validate_blob_sha256_hex(sha256_hex)?;
let key = repo_bytes_key(sha256_hex); let key = repo_bytes_key(sha256_hex);

View File

@@ -6,6 +6,7 @@ use crate::ccr::build::{
}; };
use crate::ccr::encode::encode_manifest_state_payload_der; use crate::ccr::encode::encode_manifest_state_payload_der;
use crate::ccr::hash::compute_state_hash; use crate::ccr::hash::compute_state_hash;
use crate::ccr::manifest_location::select_manifest_signed_object_location_from_der;
use crate::ccr::model::{ use crate::ccr::model::{
CcrDigestAlgorithm, ManifestInstance, ManifestState, RpkiCanonicalCacheRepresentation, CcrDigestAlgorithm, ManifestInstance, ManifestState, RpkiCanonicalCacheRepresentation,
}; };
@@ -54,7 +55,10 @@ impl CcrManifestContribution {
aki: projection.manifest_ee_aki.clone(), aki: projection.manifest_ee_aki.clone(),
manifest_number_be: projection.manifest_number_be.clone(), manifest_number_be: projection.manifest_number_be.clone(),
this_update, 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(), subordinate_skis: projection.subordinate_skis.clone(),
}) })
} }
@@ -246,7 +250,8 @@ mod tests {
use crate::ccr::export::build_ccr_from_run; use crate::ccr::export::build_ccr_from_run;
use crate::ccr::verify::verify_content_info; use crate::ccr::verify::verify_content_info;
use crate::data_model::manifest::ManifestObject; 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::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor; use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal; use crate::data_model::tal::Tal;
@@ -283,16 +288,35 @@ mod tests {
) )
.expect("read manifest"); .expect("read manifest");
let manifest = ManifestObject::decode_der(&manifest_der).expect("decode 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 manifest_hash = hex::encode(sha2::Sha256::digest(&manifest_der));
let mut raw = RawByHashEntry::from_bytes(manifest_hash.clone(), manifest_der.clone()); let mut raw = RawByHashEntry::from_bytes(manifest_hash.clone(), manifest_der.clone());
raw.origin_uris raw.origin_uris.push(manifest_uri.clone());
.push("rsync://example.test/repo/current.mft".to_string());
raw.object_type = Some("mft".to_string()); raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string()); raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw"); store.put_raw_by_hash_entry(&raw).expect("put raw");
let projection = VcirCcrManifestProjection { 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_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64, manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -312,23 +336,20 @@ mod tests {
.as_ref() .as_ref()
.expect("manifest sia") .expect("manifest sia")
{ {
SubjectInfoAccess::Ee(ee_sia) => ee_sia SubjectInfoAccess::Ee(ee_sia) => vec![
.access_descriptions crate::ccr::manifest_location::select_manifest_signed_object_location(
.iter() &manifest_uri,
.map(|ad| { &ee_sia.access_descriptions,
let oid = encode_oid_for_test(&ad.access_method_oid) )
.expect("encode access method oid"); .expect("select manifest signedObject"),
let uri = encode_tlv_for_test(0x86, ad.access_location.as_bytes().to_vec()); ],
encode_sequence_for_test(&[oid, uri])
})
.collect(),
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
}, },
subordinate_skis: vec![vec![0x33; 20]], subordinate_skis: vec![vec![0x33; 20]],
}; };
let vcir = ValidatedCaInstanceResult { 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, parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(), tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(), ca_subject_name: "CN=test".to_string(),
@@ -337,8 +358,8 @@ mod tests {
last_successful_validation_time: PackTime::from_utc_offset_datetime( last_successful_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update, 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(), current_crl_rsync_uri: format!("{manifest_uri}.crl"),
validated_manifest_meta: ValidatedManifestMeta { validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
validated_manifest_this_update: PackTime::from_utc_offset_datetime( validated_manifest_this_update: PackTime::from_utc_offset_datetime(
@@ -381,7 +402,7 @@ mod tests {
related_artifacts: vec![VcirRelatedArtifact { related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest, artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft, artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/repo/current.mft".to_string()), uri: Some(manifest_uri.clone()),
sha256: manifest_hash, sha256: manifest_hash,
object_type: Some("mft".to_string()), object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted, validation_status: VcirArtifactValidationStatus::Accepted,
@@ -429,55 +450,6 @@ mod tests {
(vcir, vrps, aspas, router_keys) (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] #[test]
fn accumulator_finish_matches_builder_on_fresh_vcir_inputs() { fn accumulator_finish_matches_builder_on_fresh_vcir_inputs() {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
@@ -512,4 +484,32 @@ mod tests {
let ci = crate::ccr::model::CcrContentInfo::new(accumulated_ccr); let ci = crate::ccr::model::CcrContentInfo::new(accumulated_ccr);
verify_content_info(&ci).expect("verify 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]);
}
} }

View File

@@ -9,12 +9,13 @@ use crate::ccr::encode::{
encode_trust_anchor_state_payload_der, encode_trust_anchor_state_payload_der,
}; };
use crate::ccr::hash::compute_state_hash; use crate::ccr::hash::compute_state_hash;
use crate::ccr::manifest_location::select_manifest_signed_object_location;
use crate::ccr::model::{ use crate::ccr::model::{
AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet, AspaPayloadSet, AspaPayloadState, ManifestInstance, ManifestState, RoaPayloadSet,
RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState, RoaPayloadState, RouterKey, RouterKeySet, RouterKeyState, TrustAnchorState,
}; };
use crate::data_model::manifest::ManifestObject; 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::roa::RoaAfi;
use crate::data_model::router_cert::BgpsecRouterCertificate; use crate::data_model::router_cert::BgpsecRouterCertificate;
use crate::data_model::ta::TrustAnchor; use crate::data_model::ta::TrustAnchor;
@@ -68,12 +69,17 @@ pub enum CcrBuildError {
#[error("manifest EE certificate SIA is not the EE form: {0}")] #[error("manifest EE certificate SIA is not the EE form: {0}")]
ManifestEeSiaWrongVariant(String), 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}")] #[error("manifest child SKI is not valid lowercase hex or not 20 bytes: {0}")]
InvalidChildSki(String), InvalidChildSki(String),
#[error("manifest AccessDescription contains unsupported accessMethod OID: {0}")]
UnsupportedAccessMethodOid(String),
#[error("manifest state encoding failed: {0}")] #[error("manifest state encoding failed: {0}")]
ManifestEncode(String), ManifestEncode(String),
@@ -262,11 +268,16 @@ pub fn build_manifest_state_from_vcirs_with_breakdown(
CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone()) CcrBuildError::ManifestEeMissingSia(vcir.current_manifest_rsync_uri.clone())
})?; })?;
let locations = match sia { let locations = match sia {
SubjectInfoAccess::Ee(ee_sia) => ee_sia SubjectInfoAccess::Ee(ee_sia) => vec![
.access_descriptions select_manifest_signed_object_location(
.iter() &vcir.current_manifest_rsync_uri,
.map(encode_access_description_der) &ee_sia.access_descriptions,
.collect::<Result<Vec<_>, _>>()?, )
.map_err(|detail| CcrBuildError::ManifestSiaLocationSelection {
manifest_rsync_uri: vcir.current_manifest_rsync_uri.clone(),
detail,
})?,
],
SubjectInfoAccess::Ca(_) => { SubjectInfoAccess::Ca(_) => {
return Err(CcrBuildError::ManifestEeSiaWrongVariant( return Err(CcrBuildError::ManifestEeSiaWrongVariant(
vcir.current_manifest_rsync_uri.clone(), vcir.current_manifest_rsync_uri.clone(),
@@ -353,45 +364,6 @@ fn collect_subordinate_ski_bytes(
Ok(skis.into_iter().collect()) 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( pub fn build_router_key_state(
router_certs: &[BgpsecRouterCertificate], router_certs: &[BgpsecRouterCertificate],
) -> Result<RouterKeyState, CcrBuildError> { ) -> Result<RouterKeyState, CcrBuildError> {
@@ -650,17 +622,33 @@ mod tests {
} }
} }
fn sample_manifest_vcir( fn sample_manifest_vcir(manifest_der: &[u8], child_ski_hex: &str) -> ValidatedCaInstanceResult {
manifest_uri: &str,
manifest_der: &[u8],
child_ski_hex: &str,
) -> ValidatedCaInstanceResult {
let manifest = ManifestObject::decode_der(manifest_der).expect("decode manifest fixture"); 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 hash = hex::encode(sha2::Sha256::digest(manifest_der));
let now = manifest.manifest.this_update; let now = manifest.manifest.this_update;
let next = manifest.manifest.next_update; let next = manifest.manifest.next_update;
let projection = crate::storage::VcirCcrManifestProjection { 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_sha256: sha2::Sha256::digest(manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64, manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -680,18 +668,19 @@ mod tests {
.as_ref() .as_ref()
.expect("manifest sia") .expect("manifest sia")
{ {
SubjectInfoAccess::Ee(ee_sia) => ee_sia SubjectInfoAccess::Ee(ee_sia) => vec![
.access_descriptions select_manifest_signed_object_location(
.iter() &manifest_uri,
.map(encode_access_description_der) &ee_sia.access_descriptions,
.collect::<Result<Vec<_>, _>>() )
.expect("encode access descriptions"), .expect("select manifest signedObject"),
],
SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"), SubjectInfoAccess::Ca(_) => panic!("manifest ee sia should not be CA variant"),
}, },
subordinate_skis: vec![hex::decode(child_ski_hex).expect("decode child ski")], subordinate_skis: vec![hex::decode(child_ski_hex).expect("decode child ski")],
}; };
crate::storage::ValidatedCaInstanceResult { crate::storage::ValidatedCaInstanceResult {
manifest_rsync_uri: manifest_uri.to_string(), manifest_rsync_uri: manifest_uri.clone(),
parent_manifest_rsync_uri: None, parent_manifest_rsync_uri: None,
tal_id: "test-tal".to_string(), tal_id: "test-tal".to_string(),
ca_subject_name: "CN=test".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( last_successful_validation_time: crate::storage::PackTime::from_utc_offset_datetime(
now, now,
), ),
current_manifest_rsync_uri: manifest_uri.to_string(), current_manifest_rsync_uri: manifest_uri.clone(),
current_crl_rsync_uri: format!("{manifest_uri}.crl"), current_crl_rsync_uri: format!("{manifest_uri}.crl"),
validated_manifest_meta: crate::storage::ValidatedManifestMeta { validated_manifest_meta: crate::storage::ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
@@ -736,7 +725,7 @@ mod tests {
related_artifacts: vec![crate::storage::VcirRelatedArtifact { related_artifacts: vec![crate::storage::VcirRelatedArtifact {
artifact_role: crate::storage::VcirArtifactRole::Manifest, artifact_role: crate::storage::VcirArtifactRole::Manifest,
artifact_kind: crate::storage::VcirArtifactKind::Mft, artifact_kind: crate::storage::VcirArtifactKind::Mft,
uri: Some(manifest_uri.to_string()), uri: Some(manifest_uri.clone()),
sha256: hash, sha256: hash,
object_type: Some("mft".to_string()), object_type: Some("mft".to_string()),
validation_status: crate::storage::VcirArtifactValidationStatus::Accepted, validation_status: crate::storage::VcirArtifactValidationStatus::Accepted,
@@ -766,10 +755,8 @@ mod tests {
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft", "tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft",
)) ))
.expect("read manifest b"); .expect("read manifest b");
let vcir_a = let vcir_a = sample_manifest_vcir(&manifest_a, &"33".repeat(20));
sample_manifest_vcir("rsync://example.test/a.mft", &manifest_a, &"33".repeat(20)); let vcir_b = sample_manifest_vcir(&manifest_b, &"44".repeat(20));
let vcir_b =
sample_manifest_vcir("rsync://example.test/b.mft", &manifest_b, &"44".repeat(20));
let store_dir = tempfile::tempdir().expect("tempdir"); let store_dir = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] { for (vcir, bytes) in [(&vcir_a, &manifest_a), (&vcir_b, &manifest_b)] {

View File

@@ -471,3 +471,71 @@ fn decode_big_unsigned(bytes: &[u8]) -> Result<BigUnsigned, CcrDecodeError> {
}; };
Ok(BigUnsigned { bytes_be }) 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);
}
}

View File

@@ -124,6 +124,8 @@ mod tests {
use super::*; use super::*;
use crate::ccr::decode::decode_content_info; use crate::ccr::decode::decode_content_info;
use crate::data_model::manifest::ManifestObject; 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::roa::{IpPrefix, RoaAfi};
use crate::data_model::ta::TrustAnchor; use crate::data_model::ta::TrustAnchor;
use crate::data_model::tal::Tal; use crate::data_model::tal::Tal;
@@ -149,15 +151,34 @@ mod tests {
let base = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); 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_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 = 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 hash = hex::encode(sha2::Sha256::digest(&manifest_der));
let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone()); let mut raw = RawByHashEntry::from_bytes(hash.clone(), manifest_der.clone());
raw.origin_uris raw.origin_uris.push(manifest_uri.clone());
.push("rsync://example.test/repo/current.mft".to_string());
raw.object_type = Some("mft".to_string()); raw.object_type = Some("mft".to_string());
raw.encoding = Some("der".to_string()); raw.encoding = Some("der".to_string());
store.put_raw_by_hash_entry(&raw).expect("put raw"); store.put_raw_by_hash_entry(&raw).expect("put raw");
let projection = VcirCcrManifestProjection { 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_sha256: sha2::Sha256::digest(&manifest_der).to_vec(),
manifest_size: manifest_der.len() as u64, manifest_size: manifest_der.len() as u64,
manifest_ee_aki: manifest.signed_object.signed_data.certificates[0] manifest_ee_aki: manifest.signed_object.signed_data.certificates[0]
@@ -169,14 +190,27 @@ mod tests {
.expect("manifest aki"), .expect("manifest aki"),
manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(), manifest_number_be: manifest.manifest.manifest_number.bytes_be.clone(),
manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update), manifest_this_update: PackTime::from_utc_offset_datetime(manifest.manifest.this_update),
manifest_sia_locations_der: vec![vec![ manifest_sia_locations_der: match manifest.signed_object.signed_data.certificates[0]
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, .resource_cert
b'r', b's', b'y', b'n', b'c', .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]], subordinate_skis: vec![vec![0x33; 20]],
}; };
ValidatedCaInstanceResult { ValidatedCaInstanceResult {
manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(), manifest_rsync_uri: manifest_uri.clone(),
parent_manifest_rsync_uri: None, parent_manifest_rsync_uri: None,
tal_id: "apnic".to_string(), tal_id: "apnic".to_string(),
ca_subject_name: "CN=test".to_string(), ca_subject_name: "CN=test".to_string(),
@@ -185,7 +219,7 @@ mod tests {
last_successful_validation_time: PackTime::from_utc_offset_datetime( last_successful_validation_time: PackTime::from_utc_offset_datetime(
manifest.manifest.this_update, 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(), current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
validated_manifest_meta: ValidatedManifestMeta { validated_manifest_meta: ValidatedManifestMeta {
validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(), validated_manifest_number: manifest.manifest.manifest_number.bytes_be.clone(),
@@ -229,7 +263,7 @@ mod tests {
related_artifacts: vec![VcirRelatedArtifact { related_artifacts: vec![VcirRelatedArtifact {
artifact_role: VcirArtifactRole::Manifest, artifact_role: VcirArtifactRole::Manifest,
artifact_kind: VcirArtifactKind::Mft, artifact_kind: VcirArtifactKind::Mft,
uri: Some("rsync://example.test/repo/current.mft".to_string()), uri: Some(manifest_uri),
sha256: hash, sha256: hash,
object_type: Some("mft".to_string()), object_type: Some("mft".to_string()),
validation_status: VcirArtifactValidationStatus::Accepted, validation_status: VcirArtifactValidationStatus::Accepted,

View 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());
}
}

View File

@@ -10,6 +10,8 @@ pub mod encode;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod export; pub mod export;
pub mod hash; pub mod hash;
#[cfg(feature = "full")]
pub(crate) mod manifest_location;
pub mod model; pub mod model;
pub mod state_digest; pub mod state_digest;
#[cfg(feature = "full")] #[cfg(feature = "full")]

View File

@@ -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,
run_tree_from_tal_url_parallel_phase2_audit_with_timing, 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)] #[cfg(test)]
use output::write_json; use output::write_json;
use output::{ use output::{
@@ -115,6 +115,12 @@ fn vcir_storage_summary_enabled() -> bool {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliArgs { 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_urls: Vec<String>,
pub tal_paths: Vec<PathBuf>, pub tal_paths: Vec<PathBuf>,
pub ta_paths: Vec<PathBuf>, pub ta_paths: Vec<PathBuf>,
@@ -168,7 +174,7 @@ pub struct CliArgs {
pub rsync_mirror_root: Option<PathBuf>, pub rsync_mirror_root: Option<PathBuf>,
pub rsync_scope_policy: RsyncScopePolicy, pub rsync_scope_policy: RsyncScopePolicy,
pub max_depth: Option<usize>, pub max_ca_depth: usize,
pub max_instances: Option<usize>, pub max_instances: Option<usize>,
pub validation_time: Option<time::OffsetDateTime>, pub validation_time: Option<time::OffsetDateTime>,
@@ -184,8 +190,19 @@ fn usage() -> String {
Usage: Usage:
{bin} --db <path> --tal-url <url> [--tal-url <url> ...] [options] {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} --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: 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) --db <path> RocksDB directory path (required)
--raw-store-db <path> External raw-by-hash store DB path (optional) --raw-store-db <path> External raw-by-hash store DB path (optional)
--repo-bytes-db <path> External repo object bytes 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-timeout-secs <n> rsync I/O timeout seconds (default: 60)
--rsync-mirror-root <path> Persist rsync mirrors under this directory (default: disabled) --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) --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 --max-instances <n> Max number of CA instances to process
--validation-time <rfc3339> Validation time in RFC3339 (default: now UTC) --validation-time <rfc3339> Validation time in RFC3339 (default: now UTC)
--analyze Write timing analysis JSON under target/live/analyze/<timestamp>/ --analyze Write timing analysis JSON under target/live/analyze/<timestamp>/
@@ -272,6 +290,12 @@ Options:
} }
pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> { 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_urls: Vec<String> = Vec::new();
let mut tal_paths: Vec<PathBuf> = Vec::new(); let mut tal_paths: Vec<PathBuf> = Vec::new();
let mut ta_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_timeout_secs: u64 = 30;
let mut rsync_mirror_root: Option<PathBuf> = None; let mut rsync_mirror_root: Option<PathBuf> = None;
let mut rsync_scope_policy = RsyncScopePolicy::default(); 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 max_instances: Option<usize> = None;
let mut validation_time: Option<time::OffsetDateTime> = None; let mut validation_time: Option<time::OffsetDateTime> = None;
let mut analyze: bool = false; let mut analyze: bool = false;
@@ -331,6 +356,39 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let arg = argv[i].as_str(); let arg = argv[i].as_str();
match arg { match arg {
"--help" | "-h" => return Err(usage()), "--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" => { "--tal-url" => {
i += 1; i += 1;
let v = argv.get(i).ok_or("--tal-url requires a value")?; 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")?; let v = argv.get(i).ok_or("--rsync-scope requires a value")?;
rsync_scope_policy = RsyncScopePolicy::parse_cli_value(v)?; rsync_scope_policy = RsyncScopePolicy::parse_cli_value(v)?;
} }
"--max-depth" => { "--max-ca-depth" | "--max-depth" => {
i += 1; i += 1;
let v = argv.get(i).ok_or("--max-depth requires a value")?; let option = arg;
max_depth = Some( 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>() 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" => { "--max-instances" => {
i += 1; i += 1;
@@ -702,10 +773,91 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
i += 1; 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 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; 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!( return Err(format!(
"must specify either one-or-more --tal-url or one-or-more --tal-path/--ta-path pairs\n\n{}", "must specify either one-or-more --tal-url or one-or-more --tal-path/--ta-path pairs\n\n{}",
usage() usage()
@@ -759,13 +911,13 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
usage() usage()
)); ));
} }
if !tal_urls.is_empty() && !ta_paths.is_empty() { if !verification_only && !tal_urls.is_empty() && !ta_paths.is_empty() {
return Err(format!( return Err(format!(
"--ta-path cannot be used with --tal-url mode\n\n{}", "--ta-path cannot be used with --tal-url mode\n\n{}",
usage() usage()
)); ));
} }
if !tal_paths.is_empty() { if !verification_only && !tal_paths.is_empty() {
if !ta_paths.is_empty() { if !ta_paths.is_empty() {
if ta_paths.len() != tal_paths.len() { if ta_paths.len() != tal_paths.len() {
return Err(format!( return Err(format!(
@@ -940,6 +1092,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
} }
Ok(CliArgs { 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_urls,
tal_paths, tal_paths,
ta_paths, ta_paths,
@@ -989,7 +1147,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
rsync_timeout_secs, rsync_timeout_secs,
rsync_mirror_root, rsync_mirror_root,
rsync_scope_policy, rsync_scope_policy,
max_depth, max_ca_depth: max_ca_depth.unwrap_or(DEFAULT_MAX_CA_DEPTH),
max_instances, max_instances,
validation_time, validation_time,
analyze, analyze,
@@ -1854,7 +2012,7 @@ where
H: crate::sync::rrdp::Fetcher + Clone + 'static, H: crate::sync::rrdp::Fetcher + Clone + 'static,
R: crate::fetch::rsync::RsyncFetcher + 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 { return if let Some(t) = timing {
run_tree_from_multiple_tals_parallel_phase2_audit_with_timing( run_tree_from_multiple_tals_parallel_phase2_audit_with_timing(
store, store,
@@ -1995,9 +2153,54 @@ where
} }
pub fn run(argv: &[String]) -> Result<(), String> { 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 { if let Some(strict_policy) = args.strict_policy {
policy.strict = strict_policy; policy.strict = strict_policy;
} }
@@ -2007,9 +2210,31 @@ pub fn run(argv: &[String]) -> Result<(), String> {
if args.disable_rrdp { if args.disable_rrdp {
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly; policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
} }
} else {
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
policy.ca_failed_fetch_policy = crate::policy::CaFailedFetchPolicy::StopAllOutput;
}
let validation_time = args let validation_time = args
.validation_time .validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc); .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 let http_root_certificates_pem = args
.http_root_cert_paths .http_root_cert_paths
.iter() .iter()
@@ -2019,7 +2244,12 @@ pub fn run(argv: &[String]) -> Result<(), String> {
}) })
.collect::<Result<Vec<_>, _>>()?; .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( Arc::new(
RocksStore::open_with_external_stores( RocksStore::open_with_external_stores(
&args.db_path, &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())?) Arc::new(RocksStore::open(&args.db_path).map_err(|e| e.to_string())?)
}; };
let config = TreeRunConfig { let config = TreeRunConfig {
max_depth: args.max_depth, max_depth: Some(args.max_ca_depth),
max_instances: args.max_instances, max_instances: args.max_instances,
compact_audit: args.skip_report_build compact_audit: args.skip_report_build
&& args.report_json_path.is_none() && 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 validation_started = std::time::Instant::now();
let collect_current_repo_objects = false; 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 let tal_path = args
.tal_path .tal_path
.as_ref() .as_ref()
@@ -2651,6 +2897,62 @@ pub fn run(argv: &[String]) -> Result<(), String> {
} }
print_summary_from_shared(validation_time, &shared); 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(()) Ok(())
} }

View File

@@ -1,4 +1,6 @@
use super::*; use super::*;
use crate::data_model::oid::OID_AD_SIGNED_OBJECT;
use crate::data_model::rc::AccessDescription;
use crate::memory_telemetry::{ use crate::memory_telemetry::{
MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot, MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot,
}; };
@@ -21,6 +23,8 @@ fn parse_help_returns_usage() {
assert!(err.contains("--memory-trim-after-validation"), "{err}"); assert!(err.contains("--memory-trim-after-validation"), "{err}");
assert!(err.contains("--enable-roa-validation-cache"), "{err}"); assert!(err.contains("--enable-roa-validation-cache"), "{err}");
assert!(err.contains("--resource-validation-mode"), "{err}"); assert!(err.contains("--resource-validation-mode"), "{err}");
assert!(err.contains("--max-ca-depth"), "{err}");
assert!(err.contains("default: 32"), "{err}");
assert!( assert!(
err.contains("--publication-point-cache-observe-only"), err.contains("--publication-point-cache-observe-only"),
"{err}" "{err}"
@@ -47,6 +51,63 @@ fn parse_rejects_unknown_argument() {
assert!(err.contains("unknown argument"), "{err}"); 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] #[test]
fn parse_rejects_both_tal_url_and_tal_path() { fn parse_rejects_both_tal_url_and_tal_path() {
let argv = vec![ let argv = vec![
@@ -80,6 +141,51 @@ fn parse_rejects_invalid_max_depth() {
assert!(err.contains("invalid --max-depth"), "{err}"); 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] #[test]
fn parse_accepts_ccr_out_path() { fn parse_accepts_ccr_out_path() {
let argv = vec![ 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.ta_paths, vec![PathBuf::from("ta.cer")]);
assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal"))); 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.ta_path.as_deref(), Some(Path::new("ta.cer")));
assert_eq!(args.max_depth, Some(0)); assert_eq!(args.max_ca_depth, 0);
} }
#[test] #[test]
@@ -1618,8 +1724,9 @@ fn sample_cli_ccr_accumulator() -> CcrAccumulator {
) )
.expect("discover root"); .expect("discover root");
let mut accumulator = CcrAccumulator::new(vec![discovery.trust_anchor.clone()]); 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 { 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_sha256: vec![0x44; 32],
manifest_size: 2048, manifest_size: 2048,
manifest_ee_aki: vec![0x55; 20], 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( manifest_this_update: crate::storage::PackTime::from_utc_offset_datetime(
time::OffsetDateTime::now_utc(), time::OffsetDateTime::now_utc(),
), ),
manifest_sia_locations_der: vec![vec![ manifest_sia_locations_der: vec![
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
b'r', b's', b'y', b'n', b'c', access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
]], access_location: manifest_uri,
})
.expect("encode signedObject"),
],
subordinate_skis: vec![vec![0x33; 20]], subordinate_skis: vec![vec![0x33; 20]],
}; };
accumulator accumulator

View 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"));
}
}

View File

@@ -1,3 +1,4 @@
pub mod current_repository;
pub mod http; pub mod http;
pub mod rsync; pub mod rsync;
pub mod rsync_system; pub mod rsync_system;

View File

@@ -42,3 +42,5 @@ pub mod sync;
pub mod tools; pub mod tools;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod validation; pub mod validation;
#[cfg(feature = "full")]
pub mod verification_only;

View File

@@ -1,3 +1,4 @@
pub mod artifact_manifest; pub mod artifact_manifest;
pub mod object_resolver; pub mod object_resolver;
pub mod report_stream; pub mod report_stream;
pub mod vrp;

View File

@@ -9,6 +9,7 @@ use serde_json::value::RawValue;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::query::vrp::VrpRecord;
use crate::query_db::{ use crate::query_db::{
ObjectInstanceRecord, PublicationPointRecord, QUERY_DB_SCHEMA_VERSION, QueryDbError, ObjectInstanceRecord, PublicationPointRecord, QUERY_DB_SCHEMA_VERSION, QueryDbError,
QueryDbResult, QueryPage, RepositoryRecord, StatsRecord, QueryDbResult, QueryPage, RepositoryRecord, StatsRecord,
@@ -23,6 +24,7 @@ pub struct ReportSummary {
pub query_audit: Option<Value>, pub query_audit: Option<Value>,
pub download_stats: Option<Value>, pub download_stats: Option<Value>,
pub vrps_count: u64, pub vrps_count: u64,
pub vrps: Vec<VrpRecord>,
pub aspas_count: u64, pub aspas_count: u64,
pub warnings_count: u64, pub warnings_count: u64,
pub objects_count: u64, pub objects_count: u64,
@@ -50,6 +52,68 @@ pub enum ObjectLookup {
Sha256(String), 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ObjectResolutionMeta { pub struct ObjectResolutionMeta {
@@ -81,13 +145,37 @@ pub fn list_report_objects(
scope: ObjectScope, scope: ObjectScope,
limit: usize, limit: usize,
cursor: Option<&str>, 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>> { ) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?; let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?;
let file = File::open(report_path)?; let file = File::open(report_path)?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut deserializer = serde_json::Deserializer::from_reader(reader); let mut deserializer = serde_json::Deserializer::from_reader(reader);
let mut state = let mut state = ObjectScanState::new_list(
ObjectScanState::new_list(run_id, scope, limit, start_pp_index, start_object_index); run_id,
scope,
filter.clone(),
limit,
start_pp_index,
start_object_index,
);
ObjectScanSeed { state: &mut state } ObjectScanSeed { state: &mut state }
.deserialize(&mut deserializer) .deserialize(&mut deserializer)
.map_err(QueryDbError::from)?; .map_err(QueryDbError::from)?;
@@ -201,7 +289,13 @@ impl<'de> Visitor<'de> for ReportSummaryVisitor<'_> {
out.repos = pps.repos; out.repos = pps.repos;
out.stats = pps.stats; 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, "aspas" => out.aspas_count = map.next_value::<CountSeq>()?.0,
"download_stats" => out.download_stats = Some(map.next_value()?), "download_stats" => out.download_stats = Some(map.next_value()?),
"queryAudit" => out.query_audit = Some(map.next_value()?), "queryAudit" => out.query_audit = Some(map.next_value()?),
@@ -239,6 +333,62 @@ struct PublicationPointsSummary {
objects_count: u64, 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> { struct PublicationPointsSummaryVisitor<'a> {
run_id: &'a str, run_id: &'a str,
} }
@@ -530,6 +680,7 @@ struct ObjectScanState<'a> {
run_id: &'a str, run_id: &'a str,
scope: ObjectScope, scope: ObjectScope,
mode: ObjectScanMode, mode: ObjectScanMode,
filter: ObjectFilter,
limit: usize, limit: usize,
start_pp_index: u64, start_pp_index: u64,
start_object_index: u64, start_object_index: u64,
@@ -544,6 +695,7 @@ impl<'a> ObjectScanState<'a> {
fn new_list( fn new_list(
run_id: &'a str, run_id: &'a str,
scope: ObjectScope, scope: ObjectScope,
filter: ObjectFilter,
limit: usize, limit: usize,
start_pp_index: u64, start_pp_index: u64,
start_object_index: u64, start_object_index: u64,
@@ -552,6 +704,7 @@ impl<'a> ObjectScanState<'a> {
run_id, run_id,
scope, scope,
mode: ObjectScanMode::List, mode: ObjectScanMode::List,
filter,
limit, limit,
start_pp_index, start_pp_index,
start_object_index, start_object_index,
@@ -568,6 +721,7 @@ impl<'a> ObjectScanState<'a> {
run_id, run_id,
scope, scope,
mode: ObjectScanMode::Lookup(lookup), mode: ObjectScanMode::Lookup(lookup),
filter: ObjectFilter::default(),
limit: 1, limit: 1,
start_pp_index: 0, start_pp_index: 0,
start_object_index: 0, start_object_index: 0,
@@ -682,7 +836,7 @@ fn process_raw_publication_point(
let after_cursor = pp_index > state.start_pp_index let after_cursor = pp_index > state.start_pp_index
|| (pp_index == state.start_pp_index || (pp_index == state.start_pp_index
&& object_index >= state.start_object_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); state.data.push(record);
if state.data.len() == state.limit { if state.data.len() == state.limit {
state.next_cursor = Some(object_cursor(pp_index, object_index + 1)); 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"} {"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":[{}], "aspas":[{}],
"download_stats":{"eventsTotal":0}, "download_stats":{"eventsTotal":0},
"queryAudit":{"status":"complete","eventsPath":"validation-events.jsonl"} "queryAudit":{"status":"complete","eventsPath":"validation-events.jsonl"}

275
src/query/vrp.rs Normal file
View 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);
}
}

View File

@@ -9,10 +9,11 @@ use sha2::{Digest, Sha256};
use crate::query::artifact_manifest::build_artifact_manifest; use crate::query::artifact_manifest::build_artifact_manifest;
use crate::query::report_stream::{self, ObjectLookup, ObjectScope, ReportSummary}; use crate::query::report_stream::{self, ObjectLookup, ObjectScope, ReportSummary};
use crate::query::vrp::{self, VrpLookup, VrpRecord};
use crate::blob_store::ExternalRepoBytesDb; 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_META: &str = "meta";
pub const CF_RUNS: &str = "runs"; 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_EXPORT_JOBS: &str = "export_jobs";
pub const CF_STATS: &str = "stats"; pub const CF_STATS: &str = "stats";
pub const CF_REASON_INDEX: &str = "reason_index"; pub const CF_REASON_INDEX: &str = "reason_index";
pub const CF_VRPS: &str = "vrps";
pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[ pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
CF_META, CF_META,
@@ -40,6 +42,7 @@ pub const QUERY_DB_COLUMN_FAMILIES: &[&str] = &[
CF_EXPORT_JOBS, CF_EXPORT_JOBS,
CF_STATS, CF_STATS,
CF_REASON_INDEX, CF_REASON_INDEX,
CF_VRPS,
]; ];
const KEY_SCHEMA_VERSION: &[u8] = b"schema_version"; const KEY_SCHEMA_VERSION: &[u8] = b"schema_version";
@@ -92,6 +95,7 @@ pub struct QueryIndexSummary {
pub object_instances_indexed: u64, pub object_instances_indexed: u64,
pub object_projections_indexed: u64, pub object_projections_indexed: u64,
pub stats_indexed: u64, pub stats_indexed: u64,
pub vrps_indexed: u64,
pub latest_ready_run: Option<String>, pub latest_ready_run: Option<String>,
pub errors: Vec<String>, pub errors: Vec<String>,
} }
@@ -369,6 +373,52 @@ impl QueryDb {
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor) self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
} }
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>> { 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()) 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( pub fn get_object_by_instance_id(
&self, &self,
run_id: &str, run_id: &str,
@@ -628,6 +703,118 @@ impl QueryDb {
self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes()) 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( pub fn get_stat(
&self, &self,
run_id: &str, run_id: &str,
@@ -762,12 +949,20 @@ impl QueryDb {
(CF_EXPORT_JOBS, format!("export/{}/", run.run_id)), (CF_EXPORT_JOBS, format!("export/{}/", run.run_id)),
(CF_STATS, format!("stats/{}/", run.run_id)), (CF_STATS, format!("stats/{}/", run.run_id)),
(CF_REASON_INDEX, format!("reason/{}/", 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.delete_prefix_range(&mut batch, cf_name, &prefix)?;
} }
self.write_batch(batch) 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> { fn cf(&self, name: &'static str) -> QueryDbResult<&rocksdb::ColumnFamily> {
self.db self.db
.cf_handle(name) .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_instances_indexed += run_summary.object_instances_indexed;
summary.object_projections_indexed += run_summary.object_projections_indexed; summary.object_projections_indexed += run_summary.object_projections_indexed;
summary.stats_indexed += run_summary.stats_indexed; summary.stats_indexed += run_summary.stats_indexed;
summary.vrps_indexed += run_summary.vrps_indexed;
summary.latest_ready_run = run_summary.latest_ready_run; summary.latest_ready_run = run_summary.latest_ready_run;
} }
Err(err) => summary.errors.push(format!("{}: {err}", run_dir.display())), Err(err) => summary.errors.push(format!("{}: {err}", run_dir.display())),
@@ -1015,6 +1211,7 @@ struct SingleRunIndexSummary {
object_instances_indexed: u64, object_instances_indexed: u64,
object_projections_indexed: u64, object_projections_indexed: u64,
stats_indexed: u64, stats_indexed: u64,
vrps_indexed: u64,
latest_ready_run: Option<String>, latest_ready_run: Option<String>,
} }
@@ -1056,6 +1253,7 @@ fn index_run_dir(
run_record.index_status = "building".to_string(); run_record.index_status = "building".to_string();
run_record.index_error = None; run_record.index_error = None;
db.put_json_cf(CF_RUNS, run_key(&run_id).as_bytes(), &run_record)?; db.put_json_cf(CF_RUNS, run_key(&run_id).as_bytes(), &run_record)?;
db.clear_vrp_index(&run_id)?;
let indexed = let indexed =
match write_summary_index_records(db, &run_id, &report_summary, &artifact_manifest) { 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_instances_indexed: indexed.object_instances_indexed,
object_projections_indexed: indexed.object_projections_indexed, object_projections_indexed: indexed.object_projections_indexed,
stats_indexed: indexed.stats_indexed, stats_indexed: indexed.stats_indexed,
vrps_indexed: indexed.vrps_indexed,
latest_ready_run: if should_update_latest { latest_ready_run: if should_update_latest {
Some(run_id) Some(run_id)
} else { } else {
@@ -1138,6 +1337,7 @@ struct IndexWriteSummary {
object_instances_indexed: u64, object_instances_indexed: u64,
object_projections_indexed: u64, object_projections_indexed: u64,
stats_indexed: u64, stats_indexed: u64,
vrps_indexed: u64,
} }
fn write_summary_index_records( fn write_summary_index_records(
@@ -1150,6 +1350,7 @@ fn write_summary_index_records(
let mut batch = WriteBatch::default(); let mut batch = WriteBatch::default();
let mut pending = 0usize; let mut pending = 0usize;
let mut summary = IndexWriteSummary::default(); let mut summary = IndexWriteSummary::default();
let mut seen_vrp_keys = BTreeSet::new();
macro_rules! flush_if_needed { macro_rules! flush_if_needed {
() => { () => {
@@ -1187,6 +1388,18 @@ fn write_summary_index_records(
flush_if_needed!(); 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 artifacts_value = serde_json::to_value(artifact_manifest)?;
let stats = report_stream::stats_records_from_summary(run_id, report_summary, artifacts_value); let stats = report_stream::stats_records_from_summary(run_id, report_summary, artifacts_value);
for record in &stats { for record in &stats {
@@ -1447,6 +1660,56 @@ mod tests {
assert_eq!(db.count_cf(CF_OBJECT_INSTANCES).unwrap(), 0); 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] #[test]
fn failed_run_does_not_replace_previous_latest() { fn failed_run_does_not_replace_previous_latest() {
let temp = tempfile::tempdir().expect("tempdir"); let temp = tempfile::tempdir().expect("tempdir");

View File

@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use base64::Engine; use base64::Engine;
use rocksdb::checkpoint::Checkpoint;
use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch}; use rocksdb::{ColumnFamily, DB, Direction, IteratorMode, Options, WriteBatch};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -53,6 +54,13 @@ pub enum StorageError {
pub type StorageResult<T> = Result<T, 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)] #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct ChildCertificateCacheMmapLookup { pub struct ChildCertificateCacheMmapLookup {
pub projections: Vec<Option<ChildCertificateCacheProjection>>, pub projections: Vec<Option<ChildCertificateCacheProjection>>,
@@ -595,7 +603,7 @@ pub struct VcirFailedFetchReuseIdentity {
pub ta_context_digest: [u8; 32], pub ta_context_digest: [u8; 32],
#[serde(rename = "p")] #[serde(rename = "p")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32], pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "f")] #[serde(rename = "f")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub policy_fingerprint: [u8; 32], pub policy_fingerprint: [u8; 32],
@@ -1117,7 +1125,7 @@ pub struct RoaCacheObjectMeta {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaCacheProjectionContext { pub struct RoaCacheProjectionContext {
pub parent_context_digest: [u8; 32], pub ca_validation_context_digest: [u8; 32],
pub policy_fingerprint: [u8; 32], pub policy_fingerprint: [u8; 32],
pub object_meta: Vec<RoaCacheObjectMeta>, pub object_meta: Vec<RoaCacheObjectMeta>,
} }
@@ -1258,7 +1266,7 @@ pub struct RoaCacheProjection {
pub issuer_ca_sha256_hex: Option<String>, pub issuer_ca_sha256_hex: Option<String>,
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")] #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
#[serde(with = "serde_optional_bytes_32")] #[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(rename = "f", default, skip_serializing_if = "Option::is_none")]
#[serde(with = "serde_optional_bytes_32")] #[serde(with = "serde_optional_bytes_32")]
pub policy_fingerprint: Option<[u8; 32]>, pub policy_fingerprint: Option<[u8; 32]>,
@@ -1400,7 +1408,7 @@ pub struct ChildCertificateCacheProjection {
pub issuer_crl_sha256_hex: String, pub issuer_crl_sha256_hex: String,
#[serde(rename = "pc")] #[serde(rename = "pc")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32], pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "pf")] #[serde(rename = "pf")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub validation_policy_fingerprint: [u8; 32], pub validation_policy_fingerprint: [u8; 32],
@@ -1707,7 +1715,7 @@ pub struct PublicationPointCacheProjection {
pub ta_context_digest: [u8; 32], pub ta_context_digest: [u8; 32],
#[serde(rename = "pc")] #[serde(rename = "pc")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32], pub ca_validation_context_digest: [u8; 32],
#[serde(rename = "pf")] #[serde(rename = "pf")]
#[serde(with = "serde_bytes_32")] #[serde(with = "serde_bytes_32")]
pub validation_policy_fingerprint: [u8; 32], pub validation_policy_fingerprint: [u8; 32],
@@ -1743,7 +1751,7 @@ impl PublicationPointCacheProjection {
ca_cert_sha256: [u8; 32], ca_cert_sha256: [u8; 32],
manifest_sha256: [u8; 32], manifest_sha256: [u8; 32],
ta_context_digest: [u8; 32], ta_context_digest: [u8; 32],
parent_context_digest: [u8; 32], ca_validation_context_digest: [u8; 32],
validation_policy_fingerprint: [u8; 32], validation_policy_fingerprint: [u8; 32],
) -> StorageResult<Self> { ) -> StorageResult<Self> {
let projection = Self { let projection = Self {
@@ -1756,7 +1764,7 @@ impl PublicationPointCacheProjection {
manifest_sha256, manifest_sha256,
tal_id: vcir.tal_id.clone(), tal_id: vcir.tal_id.clone(),
ta_context_digest, ta_context_digest,
parent_context_digest, ca_validation_context_digest,
validation_policy_fingerprint, validation_policy_fingerprint,
instance_effective_not_before: vcir.last_successful_validation_time.clone(), instance_effective_not_before: vcir.last_successful_validation_time.clone(),
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(), instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
@@ -1811,7 +1819,7 @@ impl PublicationPointCacheProjection {
tal_id: self.tal_id.clone(), tal_id: self.tal_id.clone(),
ca_subject_name: String::from("publication-point-cache"), ca_subject_name: String::from("publication-point-cache"),
ca_ski: hex::encode(self.ca_cert_sha256), 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(), last_successful_validation_time: validation_time.clone(),
current_manifest_rsync_uri: self.manifest_rsync_uri.clone(), current_manifest_rsync_uri: self.manifest_rsync_uri.clone(),
current_crl_rsync_uri: self current_crl_rsync_uri: self
@@ -2042,7 +2050,7 @@ impl RoaCacheProjection {
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(), instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
issuer_ca_sha256_hex, 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), policy_fingerprint: Some(context.policy_fingerprint),
crl_sha256_by_uri, crl_sha256_by_uri,
entries, entries,
@@ -2063,10 +2071,10 @@ impl RoaCacheProjection {
if let Some(hash) = &self.issuer_ca_sha256_hex { if let Some(hash) = &self.issuer_ca_sha256_hex {
validate_sha256_hex("roa_cache_projection.issuer_ca_sha256_hex", hash)?; 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 { return Err(StorageError::InvalidData {
entity: "roa_cache_projection.context", 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(), .to_string(),
}); });
} }
@@ -2893,6 +2901,38 @@ fn write_publication_point_cache_projection_to_batch(
} }
impl RocksStore { 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> { pub fn open(path: &Path) -> StorageResult<Self> {
let mut base_opts = Options::default(); let mut base_opts = Options::default();
base_opts.create_if_missing(true); base_opts.create_if_missing(true);
@@ -2931,6 +2971,15 @@ impl RocksStore {
Self::open_with_external_stores(path, None, Some(repo_bytes_path)) 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( pub fn open_with_external_stores(
path: &Path, path: &Path,
raw_store_path: Option<&Path>, raw_store_path: Option<&Path>,
@@ -3147,6 +3196,53 @@ impl RocksStore {
.collect() .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<()> { pub fn put_raw_by_hash_entry(&self, entry: &RawByHashEntry) -> StorageResult<()> {
entry.validate_internal()?; entry.validate_internal()?;
if let Some(raw_store) = self.external_raw_store.as_ref() { if let Some(raw_store) = self.external_raw_store.as_ref() {
@@ -3206,6 +3302,9 @@ impl RocksStore {
return Ok(()); return Ok(());
} }
if let Some(repo_bytes) = self.external_repo_bytes.as_ref() { 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); return repo_bytes.put_blob_bytes_batch(blobs);
} }
if let Some(raw_store) = self.external_raw_store.as_ref() { 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)] #[cfg(test)]
#[path = "storage/tests.rs"] #[path = "storage/tests.rs"]
mod tests; mod tests;

View File

@@ -29,7 +29,7 @@ fn sample_child_certificate_cache_projection(
issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"), issuer_ca_sha256_hex: sha256_hex(b"issuer-ca"),
issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(), issuer_crl_uri: "rsync://example.test/repo/issuer.crl".to_string(),
issuer_crl_sha256_hex: sha256_hex(b"issuer-crl"), 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"), validation_policy_fingerprint: sha256_32(b"policy"),
effective_not_before: pack_time(0), effective_not_before: pack_time(0),
effective_until: pack_time(24), effective_until: pack_time(24),
@@ -294,7 +294,7 @@ fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCachePro
}) })
.expect("sample VCIR has a ROA output"); .expect("sample VCIR has a ROA output");
RoaCacheProjectionContext { RoaCacheProjectionContext {
parent_context_digest: [0x31; 32], ca_validation_context_digest: [0x31; 32],
policy_fingerprint: [0x32; 32], policy_fingerprint: [0x32; 32],
object_meta: vec![RoaCacheObjectMeta { object_meta: vec![RoaCacheObjectMeta {
source_object_uri: roa_output.source_object_uri.clone(), 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.bytes_cloned().expect("cloned repo bytes"), bytes);
assert_eq!(file.compute_sha256().expect("compute sha256"), file.sha256); 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}");
}

View File

@@ -3,6 +3,7 @@ use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::thread; use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -26,10 +27,13 @@ struct Args {
once: bool, once: bool,
out_metrics: Option<PathBuf>, out_metrics: Option<PathBuf>,
out_status: 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 { 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> { pub fn main_entry() -> Result<(), String> {
@@ -38,8 +42,16 @@ pub fn main_entry() -> Result<(), String> {
fn real_main() -> Result<(), String> { fn real_main() -> Result<(), String> {
let args = parse_args(&std::env::args().collect::<Vec<_>>())?; 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 { 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 metrics = render_metrics(&snapshot);
let status = render_status_json(&snapshot)?; let status = render_status_json(&snapshot)?;
if let Some(path) = args.out_metrics.as_ref() { if let Some(path) = args.out_metrics.as_ref() {
@@ -53,7 +65,13 @@ fn real_main() -> Result<(), String> {
return Ok(()); 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 scanner = Arc::clone(&shared);
let run_root = args.run_root.clone(); let run_root = args.run_root.clone();
let instance = args.instance.clone(); let instance = args.instance.clone();
@@ -62,8 +80,13 @@ fn real_main() -> Result<(), String> {
loop { loop {
thread::sleep(Duration::from_secs(poll_secs)); thread::sleep(Duration::from_secs(poll_secs));
let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone(); let previous_snapshot = scanner.read().expect("metrics lock poisoned").clone();
let next = let next = match scan_run_root_incremental_with_rtr_dir(
match scan_run_root_incremental(&run_root, &instance, Some(&previous_snapshot)) { &run_root,
&instance,
Some(&previous_snapshot),
rtr_report_dir.as_deref(),
&ccr_check_config,
) {
Ok(snapshot) => snapshot, Ok(snapshot) => snapshot,
Err(err) => { Err(err) => {
let mut previous = scanner.write().expect("metrics lock poisoned"); 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 once = false;
let mut out_metrics = None; let mut out_metrics = None;
let mut out_status = 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; let mut index = 1usize;
while index < argv.len() { while index < argv.len() {
match argv[index].as_str() { match argv[index].as_str() {
@@ -122,6 +148,24 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
index += 1; index += 1;
out_status = Some(PathBuf::from(value_at(argv, index, "--out-status")?)); 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()), "-h" | "--help" => return Err(usage().to_string()),
other => return Err(format!("unknown argument: {other}\n{}", usage())), other => return Err(format!("unknown argument: {other}\n{}", usage())),
} }
@@ -135,6 +179,9 @@ fn parse_args(argv: &[String]) -> Result<Args, String> {
once, once,
out_metrics, out_metrics,
out_status, 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>, top_pp_by_sync_duration: Vec<TopPublicationPoint>,
cir: Option<CirMetrics>, cir: Option<CirMetrics>,
ccr: Option<CcrMetrics>, ccr: Option<CcrMetrics>,
ccr_check: CcrCheckMetrics,
rtr: RtrMetrics, 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)] #[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ServiceMetrics { struct ServiceMetrics {
@@ -493,21 +598,13 @@ struct RunRecord {
} }
fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> { 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(); let rtr_report_dir = configured_rtr_report_dir();
scan_run_root_incremental_with_rtr_dir( scan_run_root_incremental_with_rtr_dir(
input_root, input_root,
instance, instance,
previous, None,
rtr_report_dir.as_deref(), rtr_report_dir.as_deref(),
&CcrCheckConfig::disabled(),
) )
} }
@@ -516,6 +613,7 @@ fn scan_run_root_incremental_with_rtr_dir(
instance: &str, instance: &str,
previous: Option<&MetricsSnapshot>, previous: Option<&MetricsSnapshot>,
rtr_report_dir: Option<&Path>, rtr_report_dir: Option<&Path>,
ccr_check_config: &CcrCheckConfig,
) -> Result<MetricsSnapshot, String> { ) -> Result<MetricsSnapshot, String> {
let started = Instant::now(); let started = Instant::now();
let runs_root = resolve_runs_root(input_root); 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)); 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_timestamp_seconds = unix_now_seconds();
snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64(); snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
snapshot.service.last_reload_success = snapshot.service.last_reload_success =
@@ -975,6 +1074,218 @@ fn consecutive_failures(records: &[RunRecord]) -> u64 {
count 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> { fn read_json_optional(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
if !path.exists() { if !path.exists() {
return None; return None;
@@ -1634,10 +1945,60 @@ fn render_metrics(snapshot: &MetricsSnapshot) -> String {
if let Some(ccr) = snapshot.ccr.as_ref() { if let Some(ccr) = snapshot.ccr.as_ref() {
render_ccr_metrics(&mut writer, instance, ccr); render_ccr_metrics(&mut writer, instance, ccr);
} }
render_ccr_check_metrics(&mut writer, instance, &snapshot.ccr_check);
render_rtr_metrics(&mut writer, instance, snapshot); render_rtr_metrics(&mut writer, instance, snapshot);
out 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) { fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
let rtr = &snapshot.rtr; let rtr = &snapshot.rtr;
let scan_now = snapshot.service.last_scan_timestamp_seconds; 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, "latestRun": snapshot.latest_run,
"cir": snapshot.cir, "cir": snapshot.cir,
"ccr": snapshot.ccr, "ccr": snapshot.ccr,
"ccrCheck": snapshot.ccr_check,
"rtr": snapshot.rtr, "rtr": snapshot.rtr,
"topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration, "topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
"topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count, "topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
@@ -3162,7 +3524,13 @@ mod tests {
) )
.expect("clients"); .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"); .expect("scan");
assert!(snapshot.rtr.enabled); assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1); assert_eq!(snapshot.rtr.files["source"].seen, 1);
@@ -3233,7 +3601,13 @@ mod tests {
) )
.expect("clients"); .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"); .expect("scan");
assert!(snapshot.rtr.enabled); assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1); 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"#)); 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> { fn sample_cir() -> Vec<u8> {
let rejected = vec![CirRejectedObject { let rejected = vec![CirRejectedObject {
object_uri: "rsync://repo.example/a/bad.roa".to_string(), object_uri: "rsync://repo.example/a/bad.roa".to_string(),

View File

@@ -180,7 +180,7 @@ pub struct RoaValidationCacheStats {
pub struct RoaValidationCacheInput<'a> { pub struct RoaValidationCacheInput<'a> {
enabled: bool, enabled: bool,
view: Option<&'a RoaValidationCacheView>, view: Option<&'a RoaValidationCacheView>,
parent_context_digest: Option<[u8; 32]>, ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>, policy_fingerprint: Option<[u8; 32]>,
} }
@@ -189,7 +189,7 @@ impl<'a> RoaValidationCacheInput<'a> {
Self { Self {
enabled: false, enabled: false,
view: None, view: None,
parent_context_digest: None, ca_validation_context_digest: None,
policy_fingerprint: None, policy_fingerprint: None,
} }
} }
@@ -198,20 +198,20 @@ impl<'a> RoaValidationCacheInput<'a> {
Self { Self {
enabled: true, enabled: true,
view, view,
parent_context_digest: None, ca_validation_context_digest: None,
policy_fingerprint: None, policy_fingerprint: None,
} }
} }
pub fn enabled_with_context( pub fn enabled_with_context(
view: Option<&'a RoaValidationCacheView>, view: Option<&'a RoaValidationCacheView>,
parent_context_digest: [u8; 32], ca_validation_context_digest: [u8; 32],
policy_fingerprint: [u8; 32], policy_fingerprint: [u8; 32],
) -> Self { ) -> Self {
Self { Self {
enabled: true, enabled: true,
view, view,
parent_context_digest: Some(parent_context_digest), ca_validation_context_digest: Some(ca_validation_context_digest),
policy_fingerprint: Some(policy_fingerprint), policy_fingerprint: Some(policy_fingerprint),
} }
} }
@@ -495,7 +495,7 @@ fn evaluate_roa_cache_crl_gate(
pub struct RoaValidationCacheView { pub struct RoaValidationCacheView {
entries_by_uri: HashMap<String, CachedRoaValidationResult>, entries_by_uri: HashMap<String, CachedRoaValidationResult>,
issuer_ca_sha256_hex: Option<String>, issuer_ca_sha256_hex: Option<String>,
parent_context_digest: Option<[u8; 32]>, ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>, policy_fingerprint: Option<[u8; 32]>,
crl_sha256_by_uri: HashMap<String, String>, crl_sha256_by_uri: HashMap<String, String>,
blocked: bool, blocked: bool,
@@ -519,7 +519,7 @@ impl RoaValidationCacheView {
let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> = let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
HashMap::with_capacity(projection.entries.len()); HashMap::with_capacity(projection.entries.len());
let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone(); 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 policy_fingerprint = projection.policy_fingerprint;
let crl_sha256_by_uri = projection let crl_sha256_by_uri = projection
.crl_sha256_by_uri .crl_sha256_by_uri
@@ -536,7 +536,7 @@ impl RoaValidationCacheView {
return Self { return Self {
entries_by_uri, entries_by_uri,
issuer_ca_sha256_hex, issuer_ca_sha256_hex,
parent_context_digest, ca_validation_context_digest,
policy_fingerprint, policy_fingerprint,
crl_sha256_by_uri, crl_sha256_by_uri,
blocked, blocked,
@@ -580,7 +580,7 @@ impl RoaValidationCacheView {
Self { Self {
entries_by_uri, entries_by_uri,
issuer_ca_sha256_hex, issuer_ca_sha256_hex,
parent_context_digest, ca_validation_context_digest,
policy_fingerprint, policy_fingerprint,
crl_sha256_by_uri, crl_sha256_by_uri,
blocked, blocked,
@@ -590,7 +590,7 @@ impl RoaValidationCacheView {
fn matches_current_context( fn matches_current_context(
&self, &self,
issuer_ca_der: &[u8], issuer_ca_der: &[u8],
parent_context_digest: Option<[u8; 32]>, ca_validation_context_digest: Option<[u8; 32]>,
policy_fingerprint: Option<[u8; 32]>, policy_fingerprint: Option<[u8; 32]>,
) -> bool { ) -> bool {
if self.blocked { if self.blocked {
@@ -604,8 +604,8 @@ impl RoaValidationCacheView {
return false; return false;
} }
if let Some(expected_parent_context) = self.parent_context_digest { if let Some(expected_ca_validation_context) = self.ca_validation_context_digest {
if Some(expected_parent_context) != parent_context_digest { if Some(expected_ca_validation_context) != ca_validation_context_digest {
return false; return false;
} }
} else { } else {
@@ -797,7 +797,7 @@ fn active_roa_cache_view<'a>(
let gate_started = Instant::now(); let gate_started = Instant::now();
if view.matches_current_context( if view.matches_current_context(
issuer_ca_der, issuer_ca_der,
roa_cache.parent_context_digest, roa_cache.ca_validation_context_digest,
roa_cache.policy_fingerprint, roa_cache.policy_fingerprint,
) { ) {
stats.context_gate_nanos = stats stats.context_gate_nanos = stats
@@ -3914,7 +3914,7 @@ mod tests {
OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time") 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_POLICY_FINGERPRINT: [u8; 32] = [0x71; 32];
const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl"; const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl";
const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa"; const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa";
@@ -3933,7 +3933,7 @@ mod tests {
RoaCacheProjection::from_vcir_with_context( RoaCacheProjection::from_vcir_with_context(
vcir, vcir,
Some(&RoaCacheProjectionContext { Some(&RoaCacheProjectionContext {
parent_context_digest: TEST_PARENT_CONTEXT, ca_validation_context_digest: TEST_CA_VALIDATION_CONTEXT,
policy_fingerprint: TEST_POLICY_FINGERPRINT, policy_fingerprint: TEST_POLICY_FINGERPRINT,
object_meta: vec![RoaCacheObjectMeta { object_meta: vec![RoaCacheObjectMeta {
source_object_uri: TEST_ROA_URI.to_string(), source_object_uri: TEST_ROA_URI.to_string(),
@@ -4071,7 +4071,7 @@ mod tests {
assert!(view.matches_current_context( assert!(view.matches_current_context(
issuer_der, issuer_der,
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time); 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"), fixed_time("2026-06-08T00:00:00Z"),
); );
let projection = sample_roa_cache_projection(&vcir, roa_hash); 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.policy_fingerprint, Some(TEST_POLICY_FINGERPRINT));
assert_eq!( assert_eq!(
projection.entries[0].outputs_effective_until_unix, projection.entries[0].outputs_effective_until_unix,
@@ -4245,7 +4248,7 @@ mod tests {
let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time); let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
assert!(!issuer_changed.matches_current_context( assert!(!issuer_changed.matches_current_context(
issuer_der, issuer_der,
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
@@ -4258,7 +4261,7 @@ mod tests {
)); ));
assert!(!parent_changed.matches_current_context( assert!(!parent_changed.matches_current_context(
issuer_der, issuer_der,
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some([0x98; 32]) Some([0x98; 32])
)); ));
@@ -4283,7 +4286,7 @@ mod tests {
RoaValidationCacheView::from_projection(&expired_projection, validation_time); RoaValidationCacheView::from_projection(&expired_projection, validation_time);
assert!(!expired_view.matches_current_context( assert!(!expired_view.matches_current_context(
issuer_der, issuer_der,
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
let mut crl_cache = sample_crl_cache(b"current-crl".to_vec()); let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
@@ -4563,7 +4566,7 @@ mod tests {
assert!(view.matches_current_context( assert!(view.matches_current_context(
issuer_der, issuer_der,
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
assert!(matches!( assert!(matches!(
@@ -4595,43 +4598,43 @@ mod tests {
let mut view = RoaValidationCacheView { let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(), entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: None, 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), policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
crl_sha256_by_uri: HashMap::new(), crl_sha256_by_uri: HashMap::new(),
blocked: false, blocked: false,
}; };
assert!(!view.matches_current_context( assert!(!view.matches_current_context(
b"issuer-ca", b"issuer-ca",
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
view.issuer_ca_sha256_hex = Some("00".repeat(32)); view.issuer_ca_sha256_hex = Some("00".repeat(32));
assert!(!view.matches_current_context( assert!(!view.matches_current_context(
b"issuer-ca", b"issuer-ca",
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca")); view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
assert!(view.matches_current_context( assert!(view.matches_current_context(
b"issuer-ca", b"issuer-ca",
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
assert!(!view.matches_current_context(b"issuer-ca", None, 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( assert!(!view.matches_current_context(
b"issuer-ca", b"issuer-ca",
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) 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; view.policy_fingerprint = None;
assert!(!view.matches_current_context( assert!(!view.matches_current_context(
b"issuer-ca", b"issuer-ca",
Some(TEST_PARENT_CONTEXT), Some(TEST_CA_VALIDATION_CONTEXT),
Some(TEST_POLICY_FINGERPRINT) Some(TEST_POLICY_FINGERPRINT)
)); ));
} }
@@ -4661,7 +4664,7 @@ mod tests {
let mut view = RoaValidationCacheView { let mut view = RoaValidationCacheView {
entries_by_uri: HashMap::new(), entries_by_uri: HashMap::new(),
issuer_ca_sha256_hex: Some(sha256_hex(issuer_der)), 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), policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]), crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]),
blocked: false, blocked: false,
@@ -4849,7 +4852,7 @@ mod tests {
false, false,
RoaValidationCacheInput::enabled_with_context( RoaValidationCacheInput::enabled_with_context(
Some(&view), Some(&view),
TEST_PARENT_CONTEXT, TEST_CA_VALIDATION_CONTEXT,
TEST_POLICY_FINGERPRINT, TEST_POLICY_FINGERPRINT,
), ),
) { ) {

View File

@@ -10,6 +10,8 @@ use crate::validation::publication_point::PublicationPointSnapshot;
use std::borrow::Cow; use std::borrow::Cow;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
pub const DEFAULT_MAX_CA_DEPTH: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunConfig { pub struct TreeRunConfig {
/// Max CA instance depth to process (0 = root only). /// Max CA instance depth to process (0 = root only).
@@ -37,7 +39,7 @@ pub struct TreeRunConfig {
impl Default for TreeRunConfig { impl Default for TreeRunConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_depth: None, max_depth: Some(DEFAULT_MAX_CA_DEPTH),
max_instances: None, max_instances: None,
compact_audit: false, compact_audit: false,
persist_vcir: true, 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)] #[derive(Clone, Debug)]
pub enum CaCertificateRef { pub enum CaCertificateRef {
InlineDer(Vec<u8>), 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_is_allowed(config, ca.depth) {
if ca.depth > max_depth {
continue; continue;
} }
}
let res = match runner.run_publication_point(ca) { let res = match runner.run_publication_point(ca) {
Ok(v) => v, Ok(v) => v,
@@ -393,6 +402,9 @@ pub fn run_tree_serial_audit_multi_root(
} }
publication_points.push(audit); publication_points.push(audit);
let Some(child_depth) = next_allowed_ca_depth(config, ca.depth) else {
continue;
};
let mut children = res.discovered_children; let mut children = res.discovered_children;
children.sort_by(|a, b| { children.sort_by(|a, b| {
a.handle a.handle
@@ -413,7 +425,7 @@ pub fn run_tree_serial_audit_multi_root(
for child in children { for child in children {
queue.push_back(QueuedCaInstance { queue.push_back(QueuedCaInstance {
id: next_id, id: next_id,
handle: child.handle.with_depth(ca.depth + 1), handle: child.handle.with_depth(child_depth),
parent_id: Some(node.id), parent_id: Some(node.id),
discovered_from: Some(child.discovered_from), discovered_from: Some(child.discovered_from),
}); });

View File

@@ -19,8 +19,8 @@ use crate::validation::objects::{
}; };
use crate::validation::tree::{ use crate::validation::tree::{
CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner, CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner,
TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput, TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput, ca_depth_is_allowed,
run_tree_serial_audit_multi_root, next_allowed_ca_depth, run_tree_serial_audit_multi_root,
}; };
use crate::validation::tree_runner::{ use crate::validation::tree_runner::{
FreshPublicationPointFinalizeOutput, FreshPublicationPointStage, Rpkiv1PublicationPointRunner, FreshPublicationPointFinalizeOutput, FreshPublicationPointStage, Rpkiv1PublicationPointRunner,
@@ -659,6 +659,7 @@ pub fn run_tree_parallel_phase2_audit_multi_root(
&mut finished, &mut finished,
ready, ready,
ready_queue_len_after_pop, ready_queue_len_after_pop,
config,
config.compact_audit, config.compact_audit,
); );
ready_batch_metrics.record(metrics); 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()) { if !visited_manifest_uris.insert(node.handle.manifest_rsync_uri.clone()) {
continue; continue;
} }
if let Some(max_depth) = config.max_depth { if !ca_depth_is_allowed(config, node.handle.depth) {
if node.handle.depth > max_depth {
continue; continue;
} }
}
*instances_started += 1; *instances_started += 1;
match repo_runtime.request_publication_point_repo(&node.handle, 0) { match repo_runtime.request_publication_point_repo(&node.handle, 0) {
Ok(RepoSyncRequestStatus::Ready { mut outcome, .. }) => { Ok(RepoSyncRequestStatus::Ready { mut outcome, .. }) => {
@@ -959,6 +958,7 @@ fn stage_ready_publication_point(
finished: &mut Vec<FinishedPublicationPoint>, finished: &mut Vec<FinishedPublicationPoint>,
ready: ReadyCaInstance, ready: ReadyCaInstance,
ready_queue_len_after_pop: usize, ready_queue_len_after_pop: usize,
config: &TreeRunConfig,
compact_audit: bool, compact_audit: bool,
) -> ReadyStageMetrics { ) -> ReadyStageMetrics {
let publication_point_started = Instant::now(); let publication_point_started = Instant::now();
@@ -991,6 +991,7 @@ fn stage_ready_publication_point(
next_id, next_id,
ca_queue, ca_queue,
&ready.node, &ready.node,
config,
result.discovered_children.clone(), result.discovered_children.clone(),
); );
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started); metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
@@ -1057,6 +1058,7 @@ fn stage_ready_publication_point(
next_id, next_id,
ca_queue, ca_queue,
&ready.node, &ready.node,
config,
result.discovered_children.clone(), result.discovered_children.clone(),
); );
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started); metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started);
@@ -1142,6 +1144,7 @@ fn stage_ready_publication_point(
next_id, next_id,
ca_queue, ca_queue,
&ready.node, &ready.node,
config,
fresh_stage.discovered_children.clone(), fresh_stage.discovered_children.clone(),
); );
metrics.child_enqueue_ms = elapsed_ms(child_enqueue_started); 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 { let roa_cache = if runner.enable_roa_validation_cache && has_roa {
RoaValidationCacheInput::enabled_with_context( RoaValidationCacheInput::enabled_with_context(
roa_cache_view.as_ref(), 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( crate::validation::tree_runner::publication_point_cache_policy_fingerprint(
runner.policy, runner.policy,
), ),
@@ -1412,8 +1415,13 @@ fn enqueue_discovered_children(
next_id: &mut u64, next_id: &mut u64,
ca_queue: &mut VecDeque<QueuedCaInstance>, ca_queue: &mut VecDeque<QueuedCaInstance>,
parent: &QueuedCaInstance, parent: &QueuedCaInstance,
config: &TreeRunConfig,
mut children: Vec<DiscoveredChildCaInstance>, mut children: Vec<DiscoveredChildCaInstance>,
) { ) {
let Some(child_depth) = next_allowed_ca_depth(config, parent.handle.depth) else {
return;
};
children.sort_by(|a, b| { children.sort_by(|a, b| {
a.handle a.handle
.manifest_rsync_uri .manifest_rsync_uri
@@ -1428,7 +1436,7 @@ fn enqueue_discovered_children(
let _ = runtime.prefetch_discovered_children(&children); let _ = runtime.prefetch_discovered_children(&children);
} }
for child in 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()); handle.parent_manifest_rsync_uri = Some(parent.handle.manifest_rsync_uri.clone());
ca_queue.push_back(QueuedCaInstance { ca_queue.push_back(QueuedCaInstance {
id: *next_id, id: *next_id,

View File

@@ -1,5 +1,3 @@
mod vcir_der;
use crate::analysis::timing::TimingHandle; use crate::analysis::timing::TimingHandle;
use crate::audit::{ use crate::audit::{
AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit, AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit,
@@ -69,7 +67,7 @@ use base64::Engine as _;
use x509_parser::prelude::FromDer; use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo; 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_PARALLEL_MIN_CHILDREN: usize = 256;
const PUBLICATION_POINT_CACHE_CHILD_RESTORE_MAX_WORKERS: usize = 16; 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); self.finish_publication_point_cache_miss(ca, "ta_context_mismatch", lookup_started);
return None; 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); self.finish_publication_point_cache_miss(ca, "parent_context_mismatch", lookup_started);
return None; return None;
} }
@@ -505,7 +504,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
ca_cert_sha256, ca_cert_sha256,
manifest_sha256, manifest_sha256,
ta_context_digest: ta_context_digest_for_ca(ca), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
}) })
} }
@@ -938,7 +937,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
Some(ChildCertificateValidationCacheContext { Some(ChildCertificateValidationCacheContext {
store: self.store, store: self.store,
issuer_ca_sha256: sha256_digest_32(issuer_ca_der.as_ref()), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(self.policy),
}) })
} else { } else {
@@ -1530,7 +1529,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
let roa_cache = if self.enable_roa_validation_cache && has_roa { let roa_cache = if self.enable_roa_validation_cache && has_roa {
RoaValidationCacheInput::enabled_with_context( RoaValidationCacheInput::enabled_with_context(
roa_cache_view.as_ref(), 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), publication_point_cache_policy_fingerprint(self.policy),
) )
} else { } else {
@@ -1946,7 +1945,7 @@ struct PublicationPointCacheIdentity {
ca_cert_sha256: [u8; 32], ca_cert_sha256: [u8; 32],
manifest_sha256: [u8; 32], manifest_sha256: [u8; 32],
ta_context_digest: [u8; 32], ta_context_digest: [u8; 32],
parent_context_digest: [u8; 32], ca_validation_context_digest: [u8; 32],
policy_fingerprint: [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(&[ hash_serialized_parts(&[
( (
"version", "version",
@@ -2058,7 +2057,7 @@ fn pack_time_window_contains(
struct ChildCertificateValidationCacheContext<'a> { struct ChildCertificateValidationCacheContext<'a> {
store: &'a RocksStore, store: &'a RocksStore,
issuer_ca_sha256: [u8; 32], issuer_ca_sha256: [u8; 32],
parent_context_digest: [u8; 32], ca_validation_context_digest: [u8; 32],
policy_fingerprint: [u8; 32], policy_fingerprint: [u8; 32],
} }
@@ -2066,7 +2065,7 @@ fn child_certificate_cache_key_sha256_hex(
child_cert_uri: &str, child_cert_uri: &str,
child_cert_sha256: &[u8; 32], child_cert_sha256: &[u8; 32],
issuer_ca_sha256: &[u8; 32], issuer_ca_sha256: &[u8; 32],
parent_context_digest: &[u8; 32], ca_validation_context_digest: &[u8; 32],
policy_fingerprint: &[u8; 32], policy_fingerprint: &[u8; 32],
) -> String { ) -> String {
let digest = hash_serialized_parts(&[ 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_uri", child_cert_uri.as_bytes().to_vec()),
("child_cert_sha256", child_cert_sha256.to_vec()), ("child_cert_sha256", child_cert_sha256.to_vec()),
("issuer_ca_sha256", issuer_ca_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()), ("policy_fingerprint", policy_fingerprint.to_vec()),
]); ]);
sha256_hex_from_32(&digest) 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.rsync_uri,
&f.sha256, &f.sha256,
&cache.issuer_ca_sha256, &cache.issuer_ca_sha256,
&cache.parent_context_digest, &cache.ca_validation_context_digest,
&cache.policy_fingerprint, &cache.policy_fingerprint,
)); ));
} }
@@ -3022,7 +3025,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
&f.rsync_uri, &f.rsync_uri,
&f.sha256, &f.sha256,
&cache.issuer_ca_sha256, &cache.issuer_ca_sha256,
&cache.parent_context_digest, &cache.ca_validation_context_digest,
&cache.policy_fingerprint, &cache.policy_fingerprint,
); );
let projection = ChildCertificateCacheProjection { 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_uri: issuer_crl_uri.clone(),
issuer_crl_sha256_hex: verified_crl.sha256_hex.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, validation_policy_fingerprint: cache.policy_fingerprint,
effective_not_before, effective_not_before,
effective_until, effective_until,
@@ -3239,7 +3243,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
&f.rsync_uri, &f.rsync_uri,
&f.sha256, &f.sha256,
&cache.issuer_ca_sha256, &cache.issuer_ca_sha256,
&cache.parent_context_digest, &cache.ca_validation_context_digest,
&cache.policy_fingerprint, &cache.policy_fingerprint,
); );
let projection = ChildCertificateCacheProjection { 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_ca_sha256_hex: sha256_hex_from_32(&cache.issuer_ca_sha256),
issuer_crl_uri: issuer_crl_uri.clone(), issuer_crl_uri: issuer_crl_uri.clone(),
issuer_crl_sha256_hex: verified_crl.sha256_hex.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, validation_policy_fingerprint: cache.policy_fingerprint,
effective_not_before, effective_not_before,
effective_until, effective_until,
@@ -4395,7 +4399,7 @@ fn failed_fetch_reuse_identity_for_fresh_result(
let identity = VcirFailedFetchReuseIdentity { let identity = VcirFailedFetchReuseIdentity {
current_ca_sha256, current_ca_sha256,
ta_context_digest: ta_context_digest_for_ca(ca), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
effective_not_before: PackTime::from_utc_offset_datetime(validation_time), effective_not_before: PackTime::from_utc_offset_datetime(validation_time),
effective_until, effective_until,
@@ -4418,7 +4422,7 @@ fn failed_fetch_reuse_identity_matches_current(
}; };
cached.current_ca_sha256 == current_ca_sha256 cached.current_ca_sha256 == current_ca_sha256
&& cached.ta_context_digest == ta_context_digest_for_ca(ca) && 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.policy_fingerprint == publication_point_cache_policy_fingerprint(policy)
&& cached.effective_until == vcir.instance_gate.instance_effective_until && cached.effective_until == vcir.instance_gate.instance_effective_until
&& cached.contains_validation_time(validation_time) && 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( .replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
&vcir, &vcir,
Some(&RoaCacheProjectionContext { 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), policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
object_meta: objects.roa_cache_object_meta.clone(), object_meta: objects.roa_cache_object_meta.clone(),
}), }),
@@ -5378,7 +5382,7 @@ fn build_publication_point_cache_projection_from_fresh(
ca_cert_sha256, ca_cert_sha256,
manifest_sha256, manifest_sha256,
ta_context_digest_for_ca(ca), 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), publication_point_cache_policy_fingerprint(policy),
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -5671,11 +5675,10 @@ fn build_vcir_ccr_manifest_projection_from_fresh(
.as_ref() .as_ref()
.ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())? .ok_or_else(|| "manifest EE certificate missing Subject Information Access".to_string())?
{ {
SubjectInfoAccess::Ee(ee_sia) => ee_sia SubjectInfoAccess::Ee(ee_sia) => vec![select_manifest_signed_object_location(
.access_descriptions &ca.manifest_rsync_uri,
.iter() &ee_sia.access_descriptions,
.map(encode_access_description_der_for_vcir_ccr_projection) )?],
.collect::<Result<Vec<_>, _>>()?,
SubjectInfoAccess::Ca(_) => { SubjectInfoAccess::Ca(_) => {
return Err( return Err(
"manifest EE certificate Subject Information Access has CA variant".to_string(), "manifest EE certificate Subject Information Access has CA variant".to_string(),

View File

@@ -1,5 +1,8 @@
use super::*; 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::data_model::roa::RoaAfi;
use crate::fetch::rsync::LocalDirRsyncFetcher; use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher}; use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
@@ -603,10 +606,13 @@ fn sample_vcir_for_projection(
manifest_ee_aki: vec![0x11; 20], manifest_ee_aki: vec![0x11; 20],
manifest_number_be: vec![1], manifest_number_be: vec![1],
manifest_this_update: PackTime::from_utc_offset_datetime(now), manifest_this_update: PackTime::from_utc_offset_datetime(now),
manifest_sia_locations_der: vec![vec![ manifest_sia_locations_der: vec![
0x30, 0x11, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x05, 0x86, 0x05, crate::ccr::manifest_location::encode_access_description_der(&AccessDescription {
b'r', b's', b'y', b'n', b'c', access_method_oid: OID_AD_SIGNED_OBJECT.to_string(),
]], access_location: manifest_uri.clone(),
})
.expect("encode signedObject"),
],
subordinate_skis: vec![vec![0x33; 20]], subordinate_skis: vec![vec![0x33; 20]],
}; };
ValidatedCaInstanceResult { ValidatedCaInstanceResult {
@@ -1252,12 +1258,13 @@ fn build_vcir_ccr_manifest_projection_from_fresh_real_snapshot_matches_manifest_
.as_ref() .as_ref()
.expect("manifest sia") .expect("manifest sia")
{ {
SubjectInfoAccess::Ee(ee_sia) => ee_sia SubjectInfoAccess::Ee(ee_sia) => vec![
.access_descriptions crate::ccr::manifest_location::select_manifest_signed_object_location(
.iter() &pack.manifest_rsync_uri,
.map(encode_access_description_der_for_vcir_ccr_projection) &ee_sia.access_descriptions,
.collect::<Result<Vec<_>, _>>() )
.expect("encode locations"), .expect("select manifest signedObject"),
],
SubjectInfoAccess::Ca(_) => panic!("manifest ee SIA should not be CA variant"), 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 { let cache_context = ChildCertificateValidationCacheContext {
store: &store, store: &store,
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
}; };
let validation_time = time::OffsetDateTime::now_utc(); 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.rsync_uri,
&child_file.sha256, &child_file.sha256,
&cache_context.issuer_ca_sha256, &cache_context.issuer_ca_sha256,
&cache_context.parent_context_digest, &cache_context.ca_validation_context_digest,
&cache_context.policy_fingerprint, &cache_context.policy_fingerprint,
); );
assert!( assert!(
@@ -1724,7 +1731,7 @@ fn discover_children_child_certificate_cache_rechecks_changed_valid_crl() {
let cache_context = ChildCertificateValidationCacheContext { let cache_context = ChildCertificateValidationCacheContext {
store: &store, store: &store,
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
}; };
let validation_time = time::OffsetDateTime::now_utc(); 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.rsync_uri,
&child_file.sha256, &child_file.sha256,
&cache_context.issuer_ca_sha256, &cache_context.issuer_ca_sha256,
&cache_context.parent_context_digest, &cache_context.ca_validation_context_digest,
&cache_context.policy_fingerprint, &cache_context.policy_fingerprint,
); );
let projection = store let projection = store
@@ -1832,7 +1839,7 @@ fn discover_children_child_certificate_cache_misses_when_current_crl_invalid() {
let cache_context = ChildCertificateValidationCacheContext { let cache_context = ChildCertificateValidationCacheContext {
store: &store, store: &store,
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
}; };
let validation_time = time::OffsetDateTime::now_utc(); 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 { let cache_context = ChildCertificateValidationCacheContext {
store: &store, store: &store,
issuer_ca_sha256: issuer.ca_certificate_sha256_32().unwrap(), 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), policy_fingerprint: publication_point_cache_policy_fingerprint(&policy),
}; };
let validation_time = time::OffsetDateTime::now_utc(); let validation_time = time::OffsetDateTime::now_utc();
@@ -2436,7 +2443,7 @@ fn seed_publication_point_cache_projection(
ca.ca_certificate_sha256_32().unwrap(), ca.ca_certificate_sha256_32().unwrap(),
sha256_32(b"manifest-bytes"), sha256_32(b"manifest-bytes"),
ta_context_digest_for_ca(ca), 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), publication_point_cache_policy_fingerprint(policy),
) )
.expect("build publication point projection"); .expect("build publication point projection");

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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);
}

View File

@@ -253,6 +253,7 @@ fn tree_enqueues_children_for_fresh_and_current_instance_vcir_results() {
fn tree_respects_max_depth_and_max_instances() { fn tree_respects_max_depth_and_max_instances() {
let root_manifest = "rsync://example.test/repo/root.mft"; let root_manifest = "rsync://example.test/repo/root.mft";
let child_manifest = "rsync://example.test/repo/child.mft"; let child_manifest = "rsync://example.test/repo/child.mft";
let grandchild_manifest = "rsync://example.test/repo/grandchild.mft";
let runner = MockRunner::default() let runner = MockRunner::default()
.with( .with(
@@ -301,6 +302,32 @@ fn tree_respects_max_depth_and_max_instances() {
audit: PublicationPointAudit::default(), audit: PublicationPointAudit::default(),
cir_fresh_objects: Vec::new(), cir_fresh_objects: Vec::new(),
cir_cached_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(), discovered_children: Vec::new(),
}, },
); );
@@ -324,6 +351,62 @@ fn tree_respects_max_depth_and_max_instances() {
.expect("run tree depth-limited"); .expect("run tree depth-limited");
assert_eq!(out.instances_processed, 1); assert_eq!(out.instances_processed, 1);
assert_eq!(out.instances_failed, 0); 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( let out = run_tree_serial(
ca_handle(root_manifest), ca_handle(root_manifest),

View File

@@ -1,81 +1,117 @@
# RPKI Explorer # 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. ## Backend requirements
- 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.
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>`. | Feature | Requirement |
- 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. | Parsed projections, raw download, exports | `--repo-bytes-db <path>` |
- Query service does not provide CORS headers; use a same-origin proxy. | 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 ## Development
Start query service first: Start the query service (example with a local query-db):
```bash ```bash
rpki_2/rpki/target/llvm-cov-target/debug/rpki_query_service \ rpki_2/rpki/target/debug/rpki_query_service \
--query-db .scratch/rpki_explorer_m3_api/query-db \ --query-db /path/to/query-db \
--listen 127.0.0.1:19571 --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 ```bash
cd rpki_2/rpki/ui/rpki-explorer cd rpki_2/rpki/ui/rpki-explorer
npm install 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 ```bash
cd rpki_2/rpki/ui/rpki-explorer
npm run build 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 ## Validation
```bash ```bash
npm run typecheck npm run typecheck # tsc project references
npm run lint npm run lint # eslint
npm run build npm test # vitest unit tests (lib + api contract)
npm audit --audit-level=high npm run build # production build
npm run test:e2e 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 ## Directory layout
Milestone screenshots are stored under:
```text ```text
specs/develop/20260617/m*_playwright/ 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, …
## Directory Layout src/lib/ formatting, cursor pager state machine, run context, guards
src/pages/ one file per route
```text src/styles/ design tokens + base/shell/components/pages CSS
src/api/ query-service client and TypeScript records tests/e2e/ Playwright specs + fixture-backed API mock
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
``` ```

View File

@@ -1,17 +1,14 @@
{ {
"name": "rpki-explorer", "name": "rpki-explorer",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rpki-explorer", "name": "rpki-explorer",
"version": "0.1.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.81.5", "@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", "lucide-react": "^0.468.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -22,8 +19,6 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@playwright/test": "^1.53.1", "@playwright/test": "^1.53.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.0.3", "@types/node": "^24.0.3",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
@@ -38,50 +33,6 @@
"vitest": "^4.1.9" "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": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -747,142 +698,6 @@
"react": "^18 || ^19" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -894,14 +709,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -925,15 +732,6 @@
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "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": { "node_modules/@types/d3-ease": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
@@ -964,12 +762,6 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -991,25 +783,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "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": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1055,7 +828,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -1501,48 +1274,6 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -1583,17 +1314,6 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -1617,16 +1337,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "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" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1769,13 +1473,6 @@
"node": ">= 8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1804,28 +1501,6 @@
"node": ">=12" "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": { "node_modules/d3-ease": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -1881,15 +1556,6 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -1935,41 +1601,6 @@
"node": ">=12" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2001,16 +1632,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2021,14 +1642,6 @@
"node": ">=8" "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": { "node_modules/es-module-lexer": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -2450,16 +2063,6 @@
"node": ">=0.8.19" "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": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -2499,14 +2102,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/js-yaml": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "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" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2901,16 +2485,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3152,36 +2726,6 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "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" "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": { "node_modules/redux": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -3456,19 +2986,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3912,34 +3429,6 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "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
}
}
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "rpki-explorer", "name": "rpki-explorer",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -9,13 +9,11 @@
"typecheck": "tsc -b --pretty false", "typecheck": "tsc -b --pretty false",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview --host 127.0.0.1", "preview": "vite preview --host 127.0.0.1",
"test": "vitest run",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.81.5", "@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", "lucide-react": "^0.468.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -26,8 +24,6 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@playwright/test": "^1.53.1", "@playwright/test": "^1.53.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.0.3", "@types/node": "^24.0.3",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",

View File

@@ -3,18 +3,24 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
outputDir: "./test-results", outputDir: "./test-results",
fullyParallel: true, fullyParallel: false,
workers: 1, workers: 1,
reporter: [["list"], ["html", { open: "never" }]], reporter: [["list"], ["html", { open: "never" }]],
use: { use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173", baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
trace: "retain-on-failure", 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: [ projects: [
{ {
name: "chromium", name: "chromium",
use: { ...devices["Desktop Chrome"] } use: { ...devices["Desktop Chrome"] },
} },
] ],
}); });

View 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");
});
});

View File

@@ -1,101 +1,101 @@
export interface ApiEnvelope<T> { /**
data: T; * Low-level fetch helpers for the rpki_query_service HTTP API.
page: { nextCursor: string | null; limit: number } | null; *
meta: { runId: string | null; schemaVersion: number }; * 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 { export class ApiError extends Error {
constructor( readonly status: number;
message: string,
readonly status: number constructor(status: number, message: string) {
) {
super(message); super(message);
this.name = "ApiError"; 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 { /** Build a URL with a query string, skipping empty values. */
if (!query) { export function withQuery(path: string, params?: QueryParams): string {
return path; if (!params) return path;
}
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of Object.entries(query)) { for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) { if (value === undefined || value === null || value === "") continue;
continue;
}
search.set(key, String(value)); search.set(key, String(value));
} }
const queryString = search.toString(); const qs = search.toString();
return queryString ? `${path}?${queryString}` : path; return qs ? `${path}?${qs}` : path;
} }
export async function getJson<T>(path: string, query?: JsonQuery): Promise<ApiEnvelope<T>> { async function parseErrorBody(res: Response): Promise<string> {
const response = await fetch(withQuery(path, query)); try {
const body = (await response.json().catch(() => ({}))) as { error?: string }; const body: unknown = await res.json();
if (!response.ok) { if (
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status); 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>> { /** Fetch JSON from the API, throwing ApiError on non-2xx. */
const response = await fetch(path, { 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 ?? {}), 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> { /** Trigger a browser download for a blob. */
const response = await fetch(path); export function saveBlob(blob: Blob, filename: string): void {
if (!response.ok) { const url = URL.createObjectURL(blob);
const body = (await response.json().catch(() => ({}))) as { error?: string }; const anchor = document.createElement("a");
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status); anchor.href = url;
} anchor.download = filename;
return response.blob(); document.body.appendChild(anchor);
} anchor.click();
anchor.remove();
export function normalizeEnvelope<T>(value: unknown): ApiEnvelope<T> { URL.revokeObjectURL(url);
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");
} }

View File

@@ -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}`);
}

View 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;
}

View 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`;
}

View File

@@ -1,103 +1,48 @@
import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react"; import { Suspense, lazy } from "react";
import type { LucideIcon } from "lucide-react"; import { Navigate, Route, Routes } from "react-router-dom";
import { useState } from "react"; import { Shell } from "../components/Shell";
import { Shell } from "../components/layout/Shell"; import { LoadingBlock } from "../components/StateBlock";
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";
type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api"; const OverviewPage = lazy(() => import("../pages/OverviewPage"));
const RepositoriesPage = lazy(() => import("../pages/RepositoriesPage"));
const navigationItems = [ const RepositoryDetailPage = lazy(() => import("../pages/RepositoryDetailPage"));
{ id: "overview", label: "Overview", icon: Home }, const PublicationPointsPage = lazy(() => import("../pages/PublicationPointsPage"));
{ id: "repositories", label: "Repositories", icon: Database }, const PublicationPointDetailPage = lazy(
{ id: "publication-points", label: "Publication Points", icon: GitBranch }, () => import("../pages/PublicationPointDetailPage"),
{ id: "objects", label: "Objects", icon: Braces }, );
{ id: "validation", label: "Validation", icon: ShieldCheck }, const ObjectsPage = lazy(() => import("../pages/ObjectsPage"));
{ id: "exports", label: "Exports", icon: PackageOpen }, const ObjectDetailPage = lazy(() => import("../pages/ObjectDetailPage"));
{ id: "runs", label: "Runs", icon: Activity }, const ValidationPage = lazy(() => import("../pages/ValidationPage"));
{ id: "api", label: "API", icon: FileSearch } const SearchPage = lazy(() => import("../pages/SearchPage"));
] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>; const ExportsPage = lazy(() => import("../pages/ExportsPage"));
const RunsPage = lazy(() => import("../pages/RunsPage"));
const comingSoonCopy: Record<Exclude<ViewId, "overview" | "repositories" | "objects">, { title: string; description: string; alternative: string }> = { const ApiStatusPage = lazy(() => import("../pages/ApiStatusPage"));
"publication-points": { const NotFoundPage = lazy(() => import("../pages/NotFoundPage"));
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."
}
};
export function App() { 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 ( return (
<RepositoriesPage <Shell>
browserState={repositoryBrowserState} <Suspense fallback={<LoadingBlock label="Loading page…" />}>
onBrowserStateChange={setRepositoryBrowserState} <Routes>
onOpenObject={openObject} <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 />}
/> />
); <Route path="/objects" element={<ObjectsPage />} />
} <Route path="/objects/:objectInstanceId" element={<ObjectDetailPage />} />
if (activeView === "overview") { <Route path="/validation" element={<ValidationPage />} />
return <OverviewPage />; <Route path="/search" element={<SearchPage />} />
} <Route path="/exports" element={<ExportsPage />} />
return <ComingSoonPage {...comingSoonCopy[activeView]} />; <Route path="/runs" element={<RunsPage />} />
}; <Route path="/api" element={<ApiStatusPage />} />
<Route path="/404" element={<NotFoundPage />} />
return ( <Route path="*" element={<Navigate to="/404" replace />} />
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}> </Routes>
{renderActiveView()} </Suspense>
</Shell> </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>
);
}

View File

@@ -1,7 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { queryClient } from "./queryClient"; import { queryClient } from "./queryClient";
export function AppProviders({ children }: PropsWithChildren) { export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
} }

View File

@@ -1,15 +1,22 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "../api/client";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 10_000, staleTime: 30_000,
gcTime: 10 * 60_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: { mutations: {
retry: false retry: false,
} },
} },
}); });

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

@@ -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)
};
}

View File

@@ -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>
);
}

View File

@@ -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("; ");
}

View File

@@ -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());
}

View File

@@ -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));
}

View File

@@ -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: [] };
}

View 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);
});
});

View 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],
);
}

View 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("—");
});
});

View 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() : "—";
}
}

View 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([]);
});
});

View 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 : [];
}

View 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)}`;
}

View 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 };
}

View 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" });
});
});

View 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" };
}

View File

@@ -1,13 +1,17 @@
import React from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import { createRoot } from "react-dom/client";
import { AppProviders } from "./app/providers"; import { AppProviders } from "./app/providers";
import { App } from "./app/App"; 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( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <StrictMode>
<AppProviders> <AppProviders>
<App /> <App />
</AppProviders> </AppProviders>
</React.StrictMode> </StrictMode>,
); );

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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