diff --git a/.env.example b/.env.example index 8bb9d13..b6b7b53 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ # Pinned Splice LocalNet version IMAGE_TAG=0.5.18 -BUNDLE_DIR=$HOME/.canton-devrel/bundle +BUNDLE_DIR=$HOME/.canton-builder/bundle diff --git a/.gitignore b/.gitignore index 3e69852..b7610e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ .env bundle/ -*.tar.gz \ No newline at end of file +*.tar.gz +docs/ +tests/.bats/ +validators.json +.registry.lock +nginx-customs/ +validators/ +last-validator-add-failure-*.log diff --git a/README.md b/README.md index 5954ce4..b7dad32 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,58 @@ canton builder reset # wipe everything, start clean | SV Ledger API (gRPC) | localhost:4901 | - | | PostgreSQL | localhost:5432 | - | +## Validators + +The default `canton builder start` brings up SV + `app-provider`. You can boot a subset, a superset, or add custom validators on top. + +### Boot-time flags + +```bash +canton builder start # SV + app-provider +canton builder start --validators app-provider # absolute set +canton builder start --only app-provider # alias for --validators +canton builder start --with app-user # additive +canton builder start --without app-provider # subtractive +canton builder start --with app-user --without app-provider +``` + +`sv` is infrastructure and is always on — passing it explicitly is an error. + +### Manage validators at runtime + +```bash +canton builder validator list # show all validators + health +canton builder validator info acme # ports, wallet URL, party hint +canton builder validator add acme # register + start a custom validator +canton builder validator add bob --port-base 7900 +canton builder validator stop acme # stop, keep data +canton builder validator start acme # bring back with existing ledger +canton builder validator rm acme # full delete (data + recipe) +``` + +Each custom validator joins the same local SV. Wallet UI is served via the localnet nginx on `:5500`: + +- `http://wallet.acme.localhost:5500` — wallet UI +- `http://localhost:5975` — JSON ledger API (port_base + 75) +- `http://localhost:5903/api/validator/readyz` — health probe + +### Default validators + +Persist your preferred default set at `~/.canton-builder/.env`: +```bash +DEFAULT_VALIDATORS=app-provider,app-user,acme +``` +Used by `canton builder start` when no flags are passed and no validators are currently registered as running. + +### Reset + +```bash +canton builder reset # wipe ledger data, keep validator recipes +canton builder reset --purge # also wipe ~/.canton-builder (factory reset) +``` + +--- + ## First Run On `canton builder start`, the tool: diff --git a/canton b/canton old mode 100644 new mode 100755 index c66e95b..ba2b87b --- a/canton +++ b/canton @@ -18,6 +18,7 @@ usage() { deploy Upload a prebuilt DAR to App Provider + App User participants logs [service] Tail container logs (optionally filter to one service) reset Wipe all data and stop containers, full clean slate + validator Manage validators (list, add, start, stop, rm, info) version Print version info EXAMPLES @@ -40,6 +41,7 @@ usage() { App User JSON API → http://localhost:2975 App Provider Wallet → http://wallet.localhost:3000 App User Wallet → http://wallet.localhost:2000 + Custom Wallets → http://wallet..localhost:5500 Scan UI → http://scan.localhost:4000 Keycloak → http://keycloak.localhost:8082 @@ -64,13 +66,16 @@ fi case "$DEVREL_COMMAND" in start) - exec "$CANTON_DEVREL_DIR/scripts/start.sh" + shift 2 + exec "$CANTON_DEVREL_DIR/scripts/start.sh" "$@" ;; stop) - exec "$CANTON_DEVREL_DIR/scripts/stop.sh" + shift 2 + exec "$CANTON_DEVREL_DIR/scripts/stop.sh" "$@" ;; status) - exec "$CANTON_DEVREL_DIR/scripts/status.sh" + shift 2 + exec "$CANTON_DEVREL_DIR/scripts/status.sh" "$@" ;; deploy) shift 2 @@ -81,7 +86,12 @@ case "$DEVREL_COMMAND" in exec "$CANTON_DEVREL_DIR/scripts/logs.sh" "${@:-}" ;; reset) - exec "$CANTON_DEVREL_DIR/scripts/reset.sh" + shift 2 + exec "$CANTON_DEVREL_DIR/scripts/reset.sh" "$@" + ;; + validator) + shift 2 + exec "$CANTON_DEVREL_DIR/scripts/validator.sh" "$@" ;; version|--version|-v) echo "canton builder v${VERSION}" diff --git a/overlays/attach-localnet.overlay.yaml b/overlays/attach-localnet.overlay.yaml new file mode 100644 index 0000000..5c77db9 --- /dev/null +++ b/overlays/attach-localnet.overlay.yaml @@ -0,0 +1,40 @@ +# Overlay applied to every per-custom validator- project. +# Attaches the validator to the shared 'localnet' network created by the +# localnet stack, and removes the per-project nginx host binding (UI is +# served via localnet's nginx on :5500 with wallet..localhost). +networks: + localnet: + external: true + +services: + participant: + networks: + - splice_validator + - localnet + # Bind gRPC + JSON ledger APIs to per-validator host ports so user scripts + # and `validator_info`'s advertised URLs work. Container ports match the + # bundle's validator participant config (5001 gRPC, 7575 JSON ledger). + ports: + - "${LEDGER_API_PORT}:5001" + - "${JSON_API_PORT}:7575" + validator: + networks: + - splice_validator + - localnet + # Bind /api/validator on the host so readyz and SDK clients can reach it. + # The bundle's per-validator nginx is unbound below (avoid :80 collisions + # across multiple validator- projects), so without this the validator + # backend would be reachable only from inside the splice_validator network. + ports: + - "${VALIDATOR_API_PORT}:5003" + wallet-web-ui: + networks: + - splice_validator + - localnet + ans-web-ui: + networks: + - splice_validator + - localnet + nginx: + # !reset overrides the base sequence rather than appending — Compose merge semantics. + ports: !reset [] diff --git a/overlays/customs.overlay.yaml b/overlays/customs.overlay.yaml new file mode 100644 index 0000000..1204850 --- /dev/null +++ b/overlays/customs.overlay.yaml @@ -0,0 +1,17 @@ +# Overlay applied to the localnet compose project. +# - Mounts a patched nginx.conf that also globs the customs/ subdir (the bundle's +# nginx.conf only includes /etc/nginx/conf.d/*.conf at the top level, so per- +# custom server blocks rendered into customs/ would otherwise be invisible). +# - Surfaces ~/.canton-builder/nginx-customs/*.conf inside nginx at +# /etc/nginx/conf.d/customs/ so wallet..localhost:5500 can be served. +# - Exposes nginx on :5500 to host. Port 5500 is chosen over the more obvious +# 5000 because macOS Monterey+ binds 5000 to AirPlay Receiver by default, +# which silently intercepts the connection and returns 403 with an +# `AirTunes/*` Server header — confusing to debug. +services: + nginx: + ports: + - "${HOST_BIND_IP:-127.0.0.1}:5500:5500" + volumes: + - ${OVERLAYS_DIR}/nginx-conf/nginx.conf:/etc/nginx/nginx.conf:ro + - ${CANTON_DEVREL_DIR}/nginx-customs:/etc/nginx/conf.d/customs:ro diff --git a/overlays/nginx-conf/nginx.conf b/overlays/nginx-conf/nginx.conf new file mode 100644 index 0000000..e5a6e33 --- /dev/null +++ b/overlays/nginx-conf/nginx.conf @@ -0,0 +1,31 @@ +events { + worker_connections 64; +} + +http { + include mime.types; + default_type application/octet-stream; + + # Logging + log_format json_combined escape=json + '{' + '"time_local":"$time_local",' + '"remote_addr":"$remote_addr",' + '"remote_user":"$remote_user",' + '"request":"$request",' + '"status": "$status",' + '"body_bytes_sent":"$body_bytes_sent",' + '"request_time":"$request_time",' + '"http_referrer":"$http_referer",' + '"http_user_agent":"$http_user_agent"' + '}'; + access_log /var/log/nginx/access.log json_combined; + error_log /var/log/nginx/error.log; + + include /etc/nginx/conf.d/*.conf; + # canton-devrel: pick up per-custom-validator server blocks rendered into + # the customs/ subdirectory. The bundle's nginx.conf only globs the parent + # dir, so without this line wallet..localhost requests get no match + # and nginx returns 444/403 from the default behavior. + include /etc/nginx/conf.d/customs/*.conf; +} diff --git a/overlays/party-hint.overlay.yaml b/overlays/party-hint.overlay.yaml new file mode 100644 index 0000000..a6e162d --- /dev/null +++ b/overlays/party-hint.overlay.yaml @@ -0,0 +1,17 @@ +# Workaround for a Splice v0.5.18 bundle bug. +# +# The bundle's env/app-user-auth-on.env and env/app-provider-auth-on.env set: +# APP_USER_PARTY_HINT=app_user_${PARTY_HINT} +# APP_PROVIDER_PARTY_HINT=app_provider_${PARTY_HINT} +# +# Splice v0.5.18 validates these against --. +# The literal `app_user_` / `app_provider_` prefixes contain underscores, +# so the resulting hints always fail validation regardless of PARTY_HINT. +# Splice exits 0 on init failure; restart: always loops it forever. +# +# `environment:` here overrides the values supplied via env_file:. +services: + splice: + environment: + APP_USER_PARTY_HINT: appuser-localparty-1 + APP_PROVIDER_PARTY_HINT: appprovider-localparty-1 diff --git a/overlays/splice-conf/sv-app.conf b/overlays/splice-conf/sv-app.conf new file mode 100644 index 0000000..88e3b2b --- /dev/null +++ b/overlays/splice-conf/sv-app.conf @@ -0,0 +1,166 @@ +_sv_participant_client { + admin-api { + address = canton + port = 4${PARTICIPANT_ADMIN_API_PORT_SUFFIX} + } + ledger-api { + client-config { + address = canton + port = 4${PARTICIPANT_LEDGER_API_PORT_SUFFIX} + } + } +} + +_splice-instance-names { + network-name = ${SPLICE_APP_UI_NETWORK_NAME} + network-favicon-url = ${SPLICE_APP_UI_NETWORK_FAVICON_URL} + amulet-name = ${SPLICE_APP_UI_AMULET_NAME} + amulet-name-acronym = ${SPLICE_APP_UI_AMULET_NAME_ACRONYM} + name-service-name = ${SPLICE_APP_UI_NAME_SERVICE_NAME} + name-service-name-acronym = ${SPLICE_APP_UI_NAME_SERVICE_NAME_ACRONYM} +} + +canton { + validator-apps.sv-validator_backend = ${_validator_backend} { + canton-identifier-config.participant = sv + onboarding = null + scan-client = null + scan-client = { + type = "trust-single" + url="http://localhost:5012" + } + sv-user=${AUTH_SV_VALIDATOR_USER_NAME} + sv-validator=true + storage.config.properties.databaseName = validator-sv + admin-api.port = 4${VALIDATOR_ADMIN_API_PORT_SUFFIX} + participant-client = ${_sv_participant_client} + } + + scan-apps.scan-app { + is-first-sv = true + domain-migration-id = 0 + storage = ${_storage} { + config.properties { + databaseName = scan + currentSchema = scan + } + } + + admin-api = { + address = "0.0.0.0" + port = 5012 + } + participant-client = ${_sv_participant_client} + sequencer-admin-client = { + address = canton + port = 5009 + } + mediator-admin-client = { + address = canton + port = 5007 + } + sv-user=${AUTH_SV_VALIDATOR_USER_NAME} + splice-instance-names = ${_splice-instance-names} + } + + sv-apps.sv { + latest-packages-only = ${LATEST_PACKAGES_ONLY} + domain-migration-id = 0 + expected-validator-onboardings = [ + { secret = ${APP_PROVIDER_VALIDATOR_ONBOARDING_SECRET} }, + { secret = ${APP_USER_VALIDATOR_ONBOARDING_SECRET} } + ] + scan { + # canton-devrel patch: register the SV's scan endpoint with its Docker DNS + # name so peer validators on the shared `localnet` network can reach it. + # The bundle ships both URLs as localhost:5012, which only works for the + # SV's own loopback and for the bundle's co-located built-in validators. + # Both URLs are patched: peer validators' BftScanConnection reads + # public-url from DsoRules, so leaving it as localhost was the root cause + # of "Connection refused at localhost:5012" on `validator add`. Host + # browsers reach scan via nginx (scan.localhost:4000), not direct, so + # rewriting public-url to a docker-DNS hostname is safe. Matches the + # bundle's own peer-mode SV setup at docker-compose/sv/compose.yaml. + public-url="http://splice:5012" + internal-url="http://splice:5012" + } + local-synchronizer-node { + sequencer { + admin-api { + address = canton + port = 5009 + } + internal-api { + address = canton + port = 5008 + } + external-public-api-url = "http://canton:5008" + } + mediator.admin-api { + address = canton + port = 5007 + } + } + + storage = ${_storage} { + config.properties { + databaseName = sv + currentSchema = sv + } + } + + admin-api = { + address = "0.0.0.0" + port = 5014 + } + participant-client = ${_sv_participant_client} + + domains { + global { + alias = "global" + url = ${?SPLICE_APP_SV_GLOBAL_DOMAIN_URL} + } + } + + automation { + paused-triggers = [ + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredLockedAmuletTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAnsSubscriptionTrigger" + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAnsEntryTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpireTransferPreapprovalsTrigger", + "org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTransferInstructionTrigger", + ] + } + + onboarding = { + type = found-dso + name = sv + first-sv-reward-weight-bps = 10000 + round-zero-duration = ${?SPLICE_APP_SV_ROUND_ZERO_DURATION} + initial-tick-duration = ${?SPLICE_APP_SV_INITIAL_TICK_DURATION} + initial-holding-fee = ${?SPLICE_APP_SV_INITIAL_HOLDING_FEE} + initial-amulet-price = ${?SPLICE_APP_SV_INITIAL_AMULET_PRICE} + is-dev-net = ${?SPLICE_SV_IS_DEVNET} + public-key = ${?SPLICE_APP_SV_PUBLIC_KEY} + private-key = ${?SPLICE_APP_SV_PRIVATE_KEY} + initial-round = ${?SPLICE_APP_SV_INITIAL_ROUND} + } + initial-amulet-price-vote = ${?SPLICE_APP_SV_INITIAL_AMULET_PRICE_VOTE} + comet-bft-config = { + enabled = false + enabled = ${?SPLICE_APP_SV_COMETBFT_ENABLED} + connection-uri = "" + connection-uri = ${?SPLICE_APP_SV_COMETBFT_CONNECTION_URI} + } + contact-point = "" + contact-point = ${?SPLICE_APP_CONTACT_POINT} + canton-identifier-config = { + participant = sv + sequencer = sv + mediator = sv + } + + splice-instance-names = ${_splice-instance-names} + } +} diff --git a/overlays/sv-scan-url.overlay.yaml b/overlays/sv-scan-url.overlay.yaml new file mode 100644 index 0000000..6bc3b75 --- /dev/null +++ b/overlays/sv-scan-url.overlay.yaml @@ -0,0 +1,13 @@ +# Mounts a patched SV app.conf over the bundle's so the SV registers +# http://splice:5012 (Docker DNS) as its scan.internal-url in DsoRules +# instead of http://localhost:5012. Without this, peer validators on the +# shared `localnet` network can't reach the SV's scan service after the +# BftScanConnection refreshes its list from DsoRules. +services: + splice: + volumes: + # Use absolute path via OVERLAYS_DIR (exported by common.sh). Docker compose + # resolves relative volume paths against the first -f file's directory + # (the bundle's compose.yaml), not this overlay's directory — relative + # paths here would point inside the bundle and fail. + - ${OVERLAYS_DIR}/splice-conf/sv-app.conf:/app/sv/${SV_PROFILE}/app.conf:ro diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bc04d3f..7fca626 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,13 +2,23 @@ set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" +source "$DEVREL_DIR/scripts/lib/registry.sh" + +TARGET_VALIDATOR="" +while [ $# -gt 0 ]; do + case "$1" in + --validator) TARGET_VALIDATOR="$2"; shift 2 ;; + *) break ;; + esac +done if [ $# -lt 1 ]; then echo "" - print_error "Usage: canton builder deploy " + print_error "Usage: canton builder deploy [--validator ] " echo "" - echo " Example:" + echo " Examples:" echo " canton builder deploy ./my-app/.daml/dist/my-app-0.0.1.dar" + echo " canton builder deploy --validator acme ./my-app/.daml/dist/my-app-0.0.1.dar" echo "" exit 1 fi @@ -24,10 +34,53 @@ print_header "Deploying DAR" echo " File: $DAR_FILENAME ($DAR_SIZE)" echo "" +declare -a TARGETS=() + +_builtin_port_base_for() { + case "$1" in + sv) echo 4900 ;; + app-provider) echo 3900 ;; + app-user) echo 2900 ;; + *) return 1 ;; + esac +} + +if [ -n "$TARGET_VALIDATOR" ]; then + if pb=$(_builtin_port_base_for "$TARGET_VALIDATOR"); then + TARGETS+=("$TARGET_VALIDATOR:$pb") + else + entry=$(registry_get "$TARGET_VALIDATOR") || { + print_error "no such validator '$TARGET_VALIDATOR'"; exit 1 + } + pb=$(echo "$entry" | jq -r .port_base) + TARGETS+=("$TARGET_VALIDATOR:$pb") + fi +else + while IFS= read -r entry; do + name=$(echo "$entry" | jq -r .name) + [ "$name" = "sv" ] && continue + type=$(echo "$entry" | jq -r .type) + if [ "$type" = "custom" ]; then + pb=$(echo "$entry" | jq -r .port_base) + else + pb=$(_builtin_port_base_for "$name") || continue + fi + TARGETS+=("$name:$pb") + done < <(registry_read | jq -c '.validators[] | select(.running == true)') + + if [ ${#TARGETS[@]} -eq 0 ]; then + print_error "No running validators found in the registry." + echo " Try: canton builder start (or: canton builder validator start )" + exit 1 + fi +fi + print_step "Checking validators are reachable..." -for port in 3903 2903; do +for target in "${TARGETS[@]}"; do + name="${target%%:*}"; pb="${target##*:}" + port=$((pb + 3)) if ! curl -fs "http://localhost:${port}/api/validator/readyz" &>/dev/null; then - print_error "Validator on port $port is not responding." + print_error "Validator '$name' (validator API port $port) is not responding." echo " Is LocalNet running? Try: canton builder start" exit 1 fi @@ -60,10 +113,10 @@ make_jwt() { base64 | tr '+/' '-_' | tr -d '=' | tr -d '\n') printf '%s' "${signing_input}.${sig}" } -print_step "Generating JWT tokens (HS256, secret: ${SECRET})..." +print_step "Generating JWT token (HS256, secret: ${SECRET})..." PROVIDER_TOKEN=$(make_jwt "ledger-api-user" "https://canton.network.global") -USER_TOKEN=$(make_jwt "ledger-api-user" "https://canton.network.global") -print_ok "Yayy! Tokens generated" +print_ok "Token generated" + upload_dar() { local name="$1" local port="$2" @@ -90,8 +143,12 @@ upload_dar() { exit 1 ;; esac } -upload_dar "App Provider" 3975 "$PROVIDER_TOKEN" -upload_dar "App User" 2975 "$USER_TOKEN" + +for target in "${TARGETS[@]}"; do + name="${target%%:*}"; pb="${target##*:}" + json_port=$((pb + 75)) + upload_dar "$name" "$json_port" "$PROVIDER_TOKEN" +done echo "" print_step "Resolving package ID..." @@ -124,6 +181,9 @@ echo "" echo " Your token for API calls (valid 24h):" echo " $PROVIDER_TOKEN" echo "" -echo " App Provider JSON API: http://localhost:3975" -echo " App User JSON API: http://localhost:2975" -echo "" \ No newline at end of file +echo " JSON APIs:" +for target in "${TARGETS[@]}"; do + name="${target%%:*}"; pb="${target##*:}" + printf " %-16s http://localhost:%d\n" "$name" "$((pb + 75))" +done +echo "" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index cf477cf..3b36ad5 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -27,6 +27,16 @@ fi BUNDLE_DIR="${BUNDLE_DIR:-$HOME/.canton-builder/bundle}" LOCALNET_DIR="$BUNDLE_DIR/splice-node/docker-compose/localnet" +VALIDATOR_BUNDLE_DIR="$BUNDLE_DIR/splice-node/docker-compose/validator" + +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +# Export OVERLAYS_DIR so docker compose can interpolate it inside overlay YAMLs +# (volume source paths). Without export, child processes see it as unset and +# silently produce empty interpolations. +export OVERLAYS_DIR="$REPO_DIR/overlays" + +CANTON_DEVREL_DIR="${CANTON_DEVREL_DIR:-$HOME/.canton-builder}" + COMPOSE_CMD=( docker compose --env-file "$LOCALNET_DIR/compose.env" diff --git a/scripts/lib/compose.sh b/scripts/lib/compose.sh new file mode 100644 index 0000000..d16fdae --- /dev/null +++ b/scripts/lib/compose.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Build docker compose argv arrays for the localnet (infra) and per-custom projects. +[[ -n "${_COMPOSE_SH_LOADED:-}" ]] && return; _COMPOSE_SH_LOADED=1 + +# Paths set by callers (common.sh) — but provide defaults so the lib is usable standalone. +LOCALNET_DIR="${LOCALNET_DIR:-$HOME/.canton-builder/bundle/splice-node/docker-compose/localnet}" +VALIDATOR_BUNDLE_DIR="${VALIDATOR_BUNDLE_DIR:-$HOME/.canton-builder/bundle/splice-node/docker-compose/validator}" +OVERLAYS_DIR="${OVERLAYS_DIR:-$REPO_DIR/overlays}" + +# Print one argv element per line: full docker compose argv for the localnet project. +# Profile flags are appended based on *_PROFILE env vars. Unset == "on" so that +# broad-coverage callers (stop, logs, reset) target every profile by default, +# while `start` can opt specific built-ins out by setting them to "off". +infra_compose_argv() { + cat < "$f" < "$conf" </dev/null | grep -qx nginx; then + docker exec nginx nginx -s reload >/dev/null 2>&1 + fi +} diff --git a/scripts/lib/portalloc.sh b/scripts/lib/portalloc.sh new file mode 100644 index 0000000..18ea39a --- /dev/null +++ b/scripts/lib/portalloc.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Port-base allocation. Pure functions (probe is overridable for tests). +[[ -n "${_PORTALLOC_SH_LOADED:-}" ]] && return; _PORTALLOC_SH_LOADED=1 + +PORT_BASE_START=5900 +PORT_BASE_STEP=1000 +PORT_BASE_MAX_RETRIES=10 + +# Derived host ports for a given port_base. +# Spec §6.1: +# N+1 ledger API (gRPC) +# N+3 validator API / readyz +# N+75 JSON API +# (N-900 wallet UI host port is NOT bound for customs; nginx serves :5500.) +_derived_ports() { + local n="$1" + echo $((n + 1)) + echo $((n + 3)) + echo $((n + 75)) +} + +# Override in tests; default uses /dev/tcp. +port_in_use() { + local p="$1" + (exec 3<>/dev/tcp/127.0.0.1/"$p") 2>/dev/null && { exec 3<&- 3>&-; return 0; } || return 1 +} + +_all_derived_free() { + local base="$1" + local p + while read -r p; do + if port_in_use "$p"; then return 1; fi + done < <(_derived_ports "$base") + return 0 +} + +allocate_port_base() { + local base=$PORT_BASE_START + local tries=0 + while [ "$tries" -lt "$PORT_BASE_MAX_RETRIES" ]; do + if _all_derived_free "$base"; then + echo "$base" + return 0 + fi + base=$((base + PORT_BASE_STEP)) + tries=$((tries + 1)) + done + echo "no free port range found near $PORT_BASE_START — close conflicting processes or pass --port-base N" >&2 + return 1 +} + +validate_explicit_port_base() { + local base="$1" + if [[ ! "$base" =~ ^[0-9]+$ ]]; then + echo "--port-base must be a positive integer" >&2; return 1 + fi + if (( base % 1000 != 900 )); then + echo "--port-base must end in 900 (e.g. 5900, 6900, 7900)" >&2; return 1 + fi + local p + while read -r p; do + if port_in_use "$p"; then + echo "derived port $p is in use; pick a different --port-base" >&2; return 1 + fi + done < <(_derived_ports "$base") + return 0 +} diff --git a/scripts/lib/registry.sh b/scripts/lib/registry.sh new file mode 100644 index 0000000..5cfc1a1 --- /dev/null +++ b/scripts/lib/registry.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Atomic JSON registry under ~/.canton-builder/validators.json. +[[ -n "${_REGISTRY_SH_LOADED:-}" ]] && return; _REGISTRY_SH_LOADED=1 + +CANTON_DEVREL_DIR="${CANTON_DEVREL_DIR:-$HOME/.canton-builder}" +REGISTRY_FILE="$CANTON_DEVREL_DIR/validators.json" +REGISTRY_LOCK="$CANTON_DEVREL_DIR/.registry.lock" + +_registry_ensure_dir() { + mkdir -p "$CANTON_DEVREL_DIR" +} + +_registry_skeleton() { + echo '{"version":1,"validators":[]}' +} + +# Atomic write: write to temp then rename. Reader-safe. +_registry_write_atomic() { + local content="$1" + _registry_ensure_dir + local tmp="$REGISTRY_FILE.$$.tmp" + printf '%s\n' "$content" > "$tmp" + mv "$tmp" "$REGISTRY_FILE" +} + +# Print the current registry to stdout. Returns empty skeleton if file missing. +registry_read() { + if [ -f "$REGISTRY_FILE" ]; then + cat "$REGISTRY_FILE" + else + _registry_skeleton + fi +} + +# Acquire the registry lock, run a function, release. +# Uses flock if available; mkdir-based fallback otherwise. +registry_with_lock() { + _registry_ensure_dir + if command -v flock >/dev/null 2>&1; then + ( + flock 9 + "$@" + ) 9>"$REGISTRY_LOCK" + else + local lockdir="${REGISTRY_LOCK}.d" + local tries=0 + while ! mkdir "$lockdir" 2>/dev/null; do + tries=$((tries+1)) + if [ "$tries" -gt 300 ]; then + echo "registry: could not acquire lock after 30s" >&2 + return 1 + fi + sleep 0.1 + done + trap "rmdir '$lockdir' 2>/dev/null" RETURN + "$@" + rmdir "$lockdir" 2>/dev/null + trap - RETURN + fi +} + +# Insert or update a validator entry. +# Args: name type port_base party_hint running +# type=builtin|custom ; port_base may be empty string for builtins. +registry_upsert_validator() { + local name="$1" type="$2" port_base="$3" party_hint="$4" running="$5" + local current + current=$(registry_read) + local new + new=$(echo "$current" | jq \ + --arg name "$name" --arg type "$type" \ + --argjson port_base "${port_base:-null}" \ + --arg party_hint "$party_hint" \ + --argjson running "$running" \ + '.validators = ((.validators | map(select(.name != $name))) + + [{name:$name, type:$type, port_base:$port_base, party_hint:$party_hint, running:$running}])') + _registry_write_atomic "$new" +} + +# Mark a single field on an existing entry. Field must be string "running" or numeric "port_base". +registry_set_running() { + local name="$1" running="$2" + local current + current=$(registry_read) + local new + new=$(echo "$current" | jq --arg name "$name" --argjson running "$running" \ + '(.validators[] | select(.name == $name) | .running) = $running') + _registry_write_atomic "$new" +} + +registry_remove_validator() { + local name="$1" + local current + current=$(registry_read) + local new + new=$(echo "$current" | jq --arg name "$name" \ + '.validators = (.validators | map(select(.name != $name)))') + _registry_write_atomic "$new" +} + +# Return a single entry's JSON to stdout. Exit non-zero if absent. +registry_get() { + local name="$1" + local hit + hit=$(registry_read | jq -c --arg name "$name" '.validators[] | select(.name == $name)') + if [ -z "$hit" ]; then + return 1 + fi + printf '%s\n' "$hit" +} + +# Print one validator name per line for entries where running=true. +registry_list_running() { + registry_read | jq -r '.validators[] | select(.running == true) | .name' +} + +# Print all custom validator names (running or not), one per line. +registry_list_customs() { + registry_read | jq -r '.validators[] | select(.type == "custom") | .name' +} diff --git a/scripts/lib/resolve.sh b/scripts/lib/resolve.sh new file mode 100644 index 0000000..b5bc667 --- /dev/null +++ b/scripts/lib/resolve.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Resolve the active validator set from CLI flags + registry + DEFAULT_VALIDATORS env. +# Prints four `KEY=value` lines to stdout. Exits non-zero with a message on error. +[[ -n "${_RESOLVE_SH_LOADED:-}" ]] && return; _RESOLVE_SH_LOADED=1 + +# Built-in validator names recognized by `--absolute`/`--with`/`--without` and registry. +BUILTIN_NAMES=(app-provider app-user) + +_is_builtin() { + local n="$1" b + for b in "${BUILTIN_NAMES[@]}"; do [[ "$n" == "$b" ]] && return 0; done + return 1 +} + +_is_custom_in_registry() { + local n="$1" + registry_get "$n" >/dev/null 2>&1 && \ + [ "$(registry_get "$n" | jq -r .type)" = "custom" ] +} + +# Convert comma-separated list to newline-separated. +_split() { echo "$1" | tr ',' '\n' | sed '/^$/d'; } + +# Reject sv and unknown names. $1 = list of names (newline-separated). +_validate_names() { + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + if [[ "$n" == "sv" ]]; then + echo "sv is infrastructure; always on" >&2; return 1 + fi + if _is_builtin "$n"; then continue; fi + if _is_custom_in_registry "$n"; then continue; fi + echo "no such validator '$n'; run \`canton builder validator list\`" >&2; return 1 + done <<< "$1" +} + +resolve_active_set() { + local absolute="" with="" without="" + while [ $# -gt 0 ]; do + case "$1" in + --absolute) absolute="$2"; shift 2 ;; + --with) with="$2"; shift 2 ;; + --without) without="$2"; shift 2 ;; + *) echo "unknown resolve flag: $1" >&2; return 1 ;; + esac + done + + if [ -n "$absolute" ] && { [ -n "$with" ] || [ -n "$without" ]; }; then + echo "--validators/--only is absolute; use only --with/--without to modify" >&2 + return 1 + fi + + local active_names="" + + if [ -n "$absolute" ]; then + _validate_names "$(_split "$absolute")" || return 1 + active_names="$absolute" + else + # Step 2: registry running set + local running + running=$(registry_list_running 2>/dev/null | paste -sd, -) + if [ -n "$running" ]; then + active_names="$running" + elif [ -n "${DEFAULT_VALIDATORS:-}" ]; then + _validate_names "$(_split "$DEFAULT_VALIDATORS")" || return 1 + active_names="$DEFAULT_VALIDATORS" + else + active_names="app-provider" + fi + # Apply --with / --without + if [ -n "$with" ]; then + _validate_names "$(_split "$with")" || return 1 + local item + while IFS= read -r item; do + [ -z "$item" ] && continue + if ! echo ",$active_names," | grep -q ",$item,"; then + active_names="${active_names:+$active_names,}$item" + fi + done <<< "$(_split "$with")" + fi + if [ -n "$without" ]; then + # sv check is enough; unknown names in --without are a no-op? Spec §3.1 says reject unknown. + _validate_names "$(_split "$without")" || return 1 + local item + while IFS= read -r item; do + [ -z "$item" ] && continue + active_names=$(echo "$active_names" | tr ',' '\n' | grep -vx "$item" | paste -sd, -) + done <<< "$(_split "$without")" + fi + fi + + # Bucket into builtins (profile flags) and customs. + local app_provider=off app_user=off customs="" + local n + while IFS= read -r n; do + [ -z "$n" ] && continue + case "$n" in + app-provider) app_provider=on ;; + app-user) app_user=on ;; + *) customs="${customs:+$customs,}$n" ;; + esac + done < <(_split "$active_names") + + echo "SV_PROFILE=on" + echo "APP_PROVIDER_PROFILE=$app_provider" + echo "APP_USER_PROFILE=$app_user" + echo "CUSTOMS=$customs" +} diff --git a/scripts/lib/validate_name.sh b/scripts/lib/validate_name.sh new file mode 100644 index 0000000..03da320 --- /dev/null +++ b/scripts/lib/validate_name.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Validator-name validation. Pure function. +[[ -n "${_VALIDATE_NAME_SH_LOADED:-}" ]] && return; _VALIDATE_NAME_SH_LOADED=1 + +# Names reserved by infra services or built-in validators. +RESERVED_NAMES=(sv app-provider app-user postgres splice canton nginx scan keycloak) +NAME_REGEX='^[a-z][a-z0-9-]{1,30}$' + +validate_validator_name() { + local name="$1" + if [[ ! "$name" =~ $NAME_REGEX ]]; then + echo "name must match $NAME_REGEX (lowercase letter, then 1-30 of [a-z0-9-])" >&2 + return 1 + fi + local r + for r in "${RESERVED_NAMES[@]}"; do + if [[ "$name" == "$r" ]]; then + echo "name '$name' is reserved (infra or built-in); pick another" >&2 + return 1 + fi + done + return 0 +} diff --git a/scripts/lib/validator.sh b/scripts/lib/validator.sh new file mode 100644 index 0000000..f2e08be --- /dev/null +++ b/scripts/lib/validator.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash +[[ -n "${_VALIDATOR_SH_LOADED:-}" ]] && return; _VALIDATOR_SH_LOADED=1 + +# All sibling libs live in the same directory. +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$LIB_DIR/common.sh" +source "$LIB_DIR/registry.sh" +source "$LIB_DIR/portalloc.sh" +source "$LIB_DIR/validate_name.sh" +source "$LIB_DIR/customenv.sh" +source "$LIB_DIR/nginxcustom.sh" +source "$LIB_DIR/compose.sh" + +# Built-in port bases (fixed by bundle). +_builtin_port_base() { + case "$1" in + sv) echo 4900 ;; + app-provider) echo 3900 ;; + app-user) echo 2900 ;; + *) return 1 ;; + esac +} + +_builtin_wallet_url() { + case "$1" in + sv) echo "http://wallet.localhost:4000" ;; + app-provider) echo "http://wallet.localhost:3000" ;; + app-user) echo "http://wallet.localhost:2000" ;; + esac +} + +_health_check_url() { + local entry="$1" + local name port_base type + name=$(echo "$entry" | jq -r .name) + type=$(echo "$entry" | jq -r .type) + if [ "$type" = "custom" ]; then + port_base=$(echo "$entry" | jq -r .port_base) + echo "http://localhost:$((port_base + 3))/api/validator/readyz" + else + port_base=$(_builtin_port_base "$name") || return 1 + echo "http://localhost:$((port_base + 3))/api/validator/readyz" + fi +} + +_is_healthy() { + local url="$1" + curl -fs --max-time 2 "$url" >/dev/null 2>&1 +} + +# Mint a one-time onboarding secret from the SV's DevNet prepare endpoint. +# The bundle's SV hard-codes expected-validator-onboardings to just the two +# built-in (app-provider, app-user) secrets, so a custom validator that sends +# anything else — including an empty string — gets 401 "Unknown secret" during +# onboarding. SPLICE_SV_IS_DEVNET=true (our localnet's default) gates this +# endpoint open. The response body IS the opaque secret token (a base64 JSON +# envelope); the validator's start.sh passes it through to SPLICE_APP_VALIDATOR_ +# ONBOARDING_SECRET, and the SV decodes it server-side. Do not parse. +_fetch_onboarding_secret() { + local url="${SV_SPONSOR_HOST_URL:-http://sv.localhost:4000}/api/sv/v0/devnet/onboard/validator/prepare" + local tmpfile http_code + tmpfile=$(mktemp) + if ! http_code=$(curl -sS --max-time 10 -X POST -o "$tmpfile" -w '%{http_code}' "$url" 2>>"$tmpfile.err"); then + print_error "failed to reach SV at $url: $(cat "$tmpfile.err" 2>/dev/null)" >&2 + rm -f "$tmpfile" "$tmpfile.err" + return 1 + fi + if [ "$http_code" != "200" ]; then + print_error "SV prepare endpoint returned HTTP $http_code: $(cat "$tmpfile")" >&2 + rm -f "$tmpfile" "$tmpfile.err" + return 1 + fi + if [ ! -s "$tmpfile" ]; then + print_error "SV prepare endpoint returned an empty body" >&2 + rm -f "$tmpfile" "$tmpfile.err" + return 1 + fi + cat "$tmpfile" + rm -f "$tmpfile" "$tmpfile.err" +} + +validator_list() { + print_header "Validators" + printf " %-16s %-8s %-8s %-10s %s\n" NAME TYPE INTENT HEALTH WALLET + # Always show sv first (infra). + local sv_url; sv_url="http://localhost:4903/api/validator/readyz" + local sv_health=DOWN + _is_healthy "$sv_url" && sv_health=UP + printf " %-16s %-8s %-8s %-10s %s\n" sv infra always "$sv_health" "http://wallet.localhost:4000" + + registry_read | jq -c '.validators[]' | while read -r entry; do + local name type running url health wallet + name=$(echo "$entry" | jq -r .name) + type=$(echo "$entry" | jq -r .type) + running=$(echo "$entry" | jq -r .running) + url=$(_health_check_url "$entry") + health=DOWN; _is_healthy "$url" && health=UP + if [ "$type" = "custom" ]; then + wallet="http://wallet.$name.localhost:5500" + else + wallet=$(_builtin_wallet_url "$name") + fi + [ "$health" = "DOWN" ] && wallet="—" + printf " %-16s %-8s %-8s %-10s %s\n" "$name" "$type" "$running" "$health" "$wallet" + done +} + +validator_info() { + local name="$1" + if [ "$name" = "sv" ]; then + print_header "sv (infrastructure)" + echo " Wallet: http://wallet.localhost:4000" + echo " JSON API: http://localhost:4975" + echo " Ledger API: localhost:4901 (gRPC)" + echo " Validator API: http://localhost:4903" + return 0 + fi + local entry + if ! entry=$(registry_get "$name"); then + print_error "no such validator '$name'; run \`canton builder validator list\`" + return 1 + fi + local type port_base party_hint + type=$(echo "$entry" | jq -r .type) + party_hint=$(echo "$entry" | jq -r .party_hint) + if [ "$type" = "custom" ]; then + port_base=$(echo "$entry" | jq -r .port_base) + print_header "$name (custom)" + echo " Party hint: $party_hint" + echo " Wallet: http://wallet.$name.localhost:5500" + echo " JSON API: http://localhost:$((port_base + 75))" + echo " Ledger API: localhost:$((port_base + 1)) (gRPC)" + echo " Validator API: http://localhost:$((port_base + 3))" + else + port_base=$(_builtin_port_base "$name") + print_header "$name (built-in)" + echo " Wallet: $(_builtin_wallet_url "$name")" + echo " JSON API: http://localhost:$((port_base + 75))" + echo " Ledger API: localhost:$((port_base + 1)) (gRPC)" + echo " Validator API: http://localhost:$((port_base + 3))" + fi +} + +# Wait for the per-custom validator's readyz. Default 300s; configurable via +# CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S for slower hosts. Cold-booting a 4th +# validator on top of a running localnet involves DB migration → participant +# identity init → onboarding handshake against the SV — easily over 90s on +# memory-constrained Docker Desktops. +_wait_for_custom_ready() { + local name="$1" port_base="$2" + local url="http://localhost:$((port_base + 3))/api/validator/readyz" + local total_s="${CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S:-300}" + local interval_s=5 + local max_attempts=$((total_s / interval_s)) + local attempts=0 elapsed=0 next_progress=30 + while [ "$attempts" -lt "$max_attempts" ]; do + if _is_healthy "$url"; then return 0; fi + sleep "$interval_s" + attempts=$((attempts + 1)) + elapsed=$((attempts * interval_s)) + if [ "$elapsed" -ge "$next_progress" ]; then + print_step "still waiting for '$name' readyz (${elapsed}s / ${total_s}s)…" + next_progress=$((elapsed + 30)) + fi + done + return 1 +} + +# Dump validator + participant logs to a file the user can inspect after rollback. +# Path is outside validators// so it survives _undo's rm -rf. +# Best-effort: failures (e.g. container already gone) are swallowed. +_dump_failure_logs() { + local name="$1" + local out="$CANTON_DEVREL_DIR/last-validator-add-failure-${name}.log" + mkdir -p "$(dirname "$out")" + { + echo "=== $(date -u +%FT%TZ) — readyz timeout for validator-$name ===" + echo + echo "--- docker ps for project validator-$name ---" + docker ps -a --filter "name=validator-${name}-" --format \ + 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null || true + echo + for svc in validator participant nginx postgres-splice; do + echo "--- docker logs --tail 200 validator-${name}-${svc}-1 ---" + docker logs --tail 200 "validator-${name}-${svc}-1" 2>&1 || true + echo + done + } >"$out" 2>/dev/null +} + +# Check `docker compose ls` for a phantom project of the same name. +_phantom_project_exists() { + local name="$1" + docker compose ls --format json 2>/dev/null \ + | jq -e --arg p "validator-$name" '.[] | select(.Name == $p)' >/dev/null +} + +# Check infra (localnet stack's nginx container) is up. +_infra_running() { + docker ps --format '{{.Names}}' 2>/dev/null | grep -qx nginx +} + +validator_add() { + local name="" + local explicit_port_base="" + while [ $# -gt 0 ]; do + case "$1" in + --port-base) explicit_port_base="$2"; shift 2 ;; + -*) print_error "unknown flag: $1"; return 1 ;; + *) name="$1"; shift ;; + esac + done + [ -z "$name" ] && { print_error "usage: validator add [--port-base N]"; return 1; } + + # ── Pre-flight validation (no side effects yet) ────────────────────────────── + validate_validator_name "$name" || return 1 + if registry_get "$name" >/dev/null 2>&1; then + print_error "'$name' already exists; use \`validator start $name\`"; return 1 + fi + if _phantom_project_exists "$name"; then + print_error "compose project 'validator-$name' already exists; resolve manually then retry" + return 1 + fi + if [ -f "$CANTON_DEVREL_DIR/validators/$name/.add-failed" ]; then + print_error "previous add for '$name' failed mid-rollback; run \`validator rm $name --force\`" + return 1 + fi + + # Allocate port base. + local port_base + if [ -n "$explicit_port_base" ]; then + validate_explicit_port_base "$explicit_port_base" || return 1 + port_base="$explicit_port_base" + else + port_base=$(allocate_port_base) || return 1 + fi + local party_hint="${name}-validator-1" + + # Ensure infra is up; if not, bring it up. + if ! _infra_running; then + print_step "Infra not running — starting localnet first…" + "$REPO_DIR/scripts/start.sh" || { print_error "could not start infra"; return 1; } + fi + + # ── Side-effecting steps, each tracked for rollback ───────────────────────── + ADD_STEPS=() + _undo() { + print_warning "Rolling back validator add for '$name'…" + local step + while [ "${#ADD_STEPS[@]}" -gt 0 ]; do + step="${ADD_STEPS[-1]}" + unset 'ADD_STEPS[-1]' + case "$step" in + registry) + registry_with_lock registry_remove_validator "$name" || return 1 ;; + envfile) + rm -rf "$CANTON_DEVREL_DIR/validators/$name" ;; + nginxconf) + remove_nginx_conf "$name" ;; + composeup) + mapfile -t argv < <(custom_compose_argv "$name") + "${argv[@]}" down -v >/dev/null 2>&1 || true ;; + nginxreload) + reload_nginx ;; + esac + done + } + + # 1. registry entry, running:false + registry_with_lock registry_upsert_validator "$name" custom "$port_base" "$party_hint" false \ + || { print_error "registry write failed"; return 1; } + ADD_STEPS+=(registry) + + # 2. mint a fresh onboarding secret from the SV's DevNet prepare endpoint. + # No rollback step needed — the token expires in ~1h and the SV has no + # release endpoint; an unused one just ages out. + print_step "Requesting onboarding secret from SV…" + local onboarding_secret + if ! onboarding_secret=$(_fetch_onboarding_secret); then + _undo; print_error "could not fetch onboarding secret from SV"; return 1 + fi + + # 3. env file + render_custom_env "$name" "$port_base" "$party_hint" "$onboarding_secret" \ + || { _undo; print_error "render env failed"; return 1; } + ADD_STEPS+=(envfile) + + # 3. nginx conf + render_nginx_conf "$name" \ + || { _undo; print_error "render nginx conf failed"; return 1; } + ADD_STEPS+=(nginxconf) + + # 4. compose up + mapfile -t argv < <(custom_compose_argv "$name") + if ! "${argv[@]}" up -d; then + _undo; print_error "docker compose up failed for validator-$name"; return 1 + fi + ADD_STEPS+=(composeup) + + # 5. wait for readyz + if ! _wait_for_custom_ready "$name" "$port_base"; then + local timeout="${CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S:-300}" + _dump_failure_logs "$name" + local logfile="$CANTON_DEVREL_DIR/last-validator-add-failure-${name}.log" + _undo + print_error "validator '$name' did not become ready within ${timeout}s" + [ -f "$logfile" ] && print_warning "Last logs captured at: $logfile" + return 1 + fi + + # 6. reload nginx (so wallet..localhost is served) + reload_nginx + ADD_STEPS+=(nginxreload) + + # 7. mark running:true + registry_with_lock registry_set_running "$name" true + + print_ok "Validator '$name' is up." + echo " Wallet: http://wallet.$name.localhost:5500" + echo " JSON API: http://localhost:$((port_base + 75))" + echo " Party: $party_hint" +} + +validator_start() { + local name="$1" + [ -z "$name" ] && { print_error "usage: validator start "; return 1; } + if [ "$name" = "sv" ]; then + print_error "sv is infrastructure; controlled by \`canton builder start\`"; return 1 + fi + if ! _infra_running; then + print_error "infra not running; run \`canton builder start\` first"; return 1 + fi + local entry + if ! entry=$(registry_get "$name"); then + print_error "no such validator '$name'; run \`validator add $name\`"; return 1 + fi + local type + type=$(echo "$entry" | jq -r .type) + if [ "$type" = "builtin" ]; then + print_step "Starting built-in '$name' (toggles ${name^^}_PROFILE=on; brief infra restart)…" + print_warning "SV will be unavailable ~10s; customs will reconnect." + case "$name" in + app-provider) export APP_PROVIDER_PROFILE=on ;; + app-user) export APP_USER_PROFILE=on ;; + esac + registry_with_lock registry_upsert_validator "$name" builtin "" "" true + mapfile -t argv < <(infra_compose_argv) + "${argv[@]}" up -d + else + local port_base + port_base=$(echo "$entry" | jq -r .port_base) + print_step "Starting custom '$name'…" + mapfile -t argv < <(custom_compose_argv "$name") + "${argv[@]}" up -d + _wait_for_custom_ready "$name" "$port_base" || { + print_error "'$name' did not become ready within 90s"; return 1 + } + registry_with_lock registry_set_running "$name" true + fi + print_ok "'$name' is running." +} + +validator_stop() { + local name="$1" + [ -z "$name" ] && { print_error "usage: validator stop "; return 1; } + if [ "$name" = "sv" ]; then + print_error "sv is infrastructure; controlled by \`canton builder stop\`"; return 1 + fi + local entry + if ! entry=$(registry_get "$name"); then + print_error "no such validator '$name'"; return 1 + fi + local type + type=$(echo "$entry" | jq -r .type) + if [ "$type" = "builtin" ]; then + print_step "Stopping built-in '$name' (toggles ${name^^}_PROFILE=off; brief infra restart)…" + print_warning "SV will be unavailable ~10s; customs will reconnect." + case "$name" in + app-provider) export APP_PROVIDER_PROFILE=off ;; + app-user) export APP_USER_PROFILE=off ;; + esac + registry_with_lock registry_set_running "$name" false + mapfile -t argv < <(infra_compose_argv) + "${argv[@]}" up -d + else + print_step "Stopping custom '$name' (volumes preserved)…" + mapfile -t argv < <(custom_compose_argv "$name") + "${argv[@]}" stop + registry_with_lock registry_set_running "$name" false + fi + print_ok "'$name' stopped." +} + +validator_rm() { + local name="" force=0 + while [ $# -gt 0 ]; do + case "$1" in + --force) force=1; shift ;; + *) name="$1"; shift ;; + esac + done + [ -z "$name" ] && { print_error "usage: validator rm [--force]"; return 1; } + if [ "$name" = "sv" ]; then + print_error "sv is infrastructure; controlled by \`canton builder stop\`"; return 1 + fi + local entry + entry=$(registry_get "$name") || { + if [ "$force" -eq 1 ] && [ -d "$CANTON_DEVREL_DIR/validators/$name" ]; then + print_warning "no registry entry, but recipe dir exists — force-cleaning" + else + print_error "no such validator '$name'"; return 1 + fi + } + if [ -n "$entry" ]; then + local type + type=$(echo "$entry" | jq -r .type) + if [ "$type" = "builtin" ]; then + print_error "built-ins cannot be removed; use \`validator stop $name\`"; return 1 + fi + fi + + print_step "Removing custom '$name' (data wiped)…" + mapfile -t argv < <(custom_compose_argv "$name") + "${argv[@]}" down -v 2>/dev/null || true + rm -rf "$CANTON_DEVREL_DIR/validators/$name" + remove_nginx_conf "$name" + registry_with_lock registry_remove_validator "$name" + reload_nginx + print_ok "'$name' removed." +} diff --git a/scripts/logs.sh b/scripts/logs.sh index 6ec8834..09237a7 100755 --- a/scripts/logs.sh +++ b/scripts/logs.sh @@ -2,16 +2,29 @@ set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" +source "$DEVREL_DIR/scripts/lib/compose.sh" + +VALIDATOR="" +while [ $# -gt 0 ]; do + case "$1" in + --validator) VALIDATOR="$2"; shift 2 ;; + *) break ;; + esac +done + SERVICE="${1:-}" echo "" -if [ -n "$SERVICE" ]; then - print_step "Tailing logs for: $SERVICE (Ctrl+C to stop)" - echo "" - "${COMPOSE_CMD[@]}" logs -f --tail=50 "$SERVICE" +if [ -n "$VALIDATOR" ]; then + print_step "Tailing logs for validator-$VALIDATOR ${SERVICE:+(service: $SERVICE)}" + mapfile -t argv < <(custom_compose_argv "$VALIDATOR") + exec "${argv[@]}" logs -f --tail=50 ${SERVICE:+"$SERVICE"} else - print_step "Tailing all logs (Ctrl+C to stop)" - echo " Tip: filter to one service, canton builder logs " - echo " Common services: canton, splice, postgres, nginx" - echo "" - "${COMPOSE_CMD[@]}" logs -f --tail=30 -fi \ No newline at end of file + if [ -n "$SERVICE" ]; then + print_step "Tailing logs for: $SERVICE (Ctrl+C to stop)" + else + print_step "Tailing all infra logs (Ctrl+C to stop)" + echo " Tip: --validator to tail a custom validator's logs." + fi + mapfile -t argv < <(infra_compose_argv) + exec "${argv[@]}" logs -f --tail=50 ${SERVICE:+"$SERVICE"} +fi diff --git a/scripts/reset.sh b/scripts/reset.sh index 3f5ff80..67c65b2 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -1,34 +1,57 @@ #!/usr/bin/env bash - set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" -print_header "Canton builder Full Reset" -print_warning "This will DELETE all LocalNet data:" -echo " • All ledger state (contracts, transactions)" -echo " • All party registrations" -echo " • All Canton Coin balances" -echo " • All uploaded DARs" -echo " • All PostgreSQL volumes" +source "$DEVREL_DIR/scripts/lib/registry.sh" +source "$DEVREL_DIR/scripts/lib/compose.sh" + +PURGE=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + *) print_error "unknown flag: $arg"; exit 1 ;; + esac +done + +print_header "Canton Builder Tool Reset" +print_warning "This will DELETE ledger data for:" +echo " • All built-in validators (sv, app-provider, app-user)" +echo " • All custom validators in the registry" +if [ "$PURGE" -eq 1 ]; then + print_warning "AND wipe runtime state under $CANTON_DEVREL_DIR:" + echo " bundle/, validators/, nginx-customs/, validators.json, .registry.lock" + echo " (install files and .env are preserved)" +fi echo "" read -rp " Are you sure? Type 'yes' to confirm: " confirm +[ "$confirm" = "yes" ] || { echo " Aborted."; exit 0; } -if [ "$confirm" != "yes" ]; then - echo " Aborted. Nothing was changed." - exit 0 +echo "" +print_step "Tearing down custom validator projects…" +for n in $(registry_read | jq -r '.validators[] | select(.type=="custom") | .name'); do + print_step " validator-$n" + mapfile -t argv < <(custom_compose_argv "$n") + "${argv[@]}" down -v 2>/dev/null || true +done + +print_step "Tearing down localnet…" +mapfile -t argv < <(infra_compose_argv) +"${argv[@]}" down -v 2>/dev/null || true + +if [ "$PURGE" -eq 1 ]; then + print_step "Wiping runtime state in ${CANTON_DEVREL_DIR}…" + rm -rf "$CANTON_DEVREL_DIR/bundle" + rm -rf "$CANTON_DEVREL_DIR/validators" + rm -rf "$CANTON_DEVREL_DIR/nginx-customs" + rm -f "$CANTON_DEVREL_DIR/validators.json" + rm -f "$CANTON_DEVREL_DIR/.registry.lock" fi echo "" -print_step "Stopping containers and removing volumes..." -docker compose \ - --env-file "$LOCALNET_DIR/compose.env" \ - --env-file "$LOCALNET_DIR/env/common.env" \ - -f "$LOCALNET_DIR/compose.yaml" \ - -f "$LOCALNET_DIR/resource-constraints.yaml" \ - --profile sv --profile app-provider --profile app-user \ - down -v 2>/dev/null || true - +print_ok "Reset complete." +if [ "$PURGE" -eq 1 ]; then + echo " Runtime state wiped. Next 'canton builder start' re-downloads the bundle and starts fresh." +else + echo " Registry preserved. Next 'canton builder start' brings the same shape back with fresh ledgers." +fi echo "" -print_ok "Reset complete. All data wiped." -echo " Start fresh: canton builder start" -echo "" \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh index 77b14ea..c72d4d8 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -3,7 +3,30 @@ set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" +source "$DEVREL_DIR/scripts/lib/registry.sh" +source "$DEVREL_DIR/scripts/lib/resolve.sh" +source "$DEVREL_DIR/scripts/lib/compose.sh" + +ABSOLUTE="" WITH="" WITHOUT="" +while [ $# -gt 0 ]; do + case "$1" in + --validators|--only) ABSOLUTE="$2"; shift 2 ;; + --with) WITH="$2"; shift 2 ;; + --without) WITHOUT="$2"; shift 2 ;; + *) print_error "unknown flag: $1"; exit 1 ;; + esac +done + +RESOLVE_ARGS=() +[ -n "$ABSOLUTE" ] && RESOLVE_ARGS+=(--absolute "$ABSOLUTE") +[ -n "$WITH" ] && RESOLVE_ARGS+=(--with "$WITH") +[ -n "$WITHOUT" ] && RESOLVE_ARGS+=(--without "$WITHOUT") + +RESOLVED=$(resolve_active_set "${RESOLVE_ARGS[@]}") || exit 1 +eval "$(echo "$RESOLVED" | sed 's/^/export /')" + print_header "Canton Builder Tool Starting LocalNet" +print_step "Active set: SV(on) APP_PROVIDER($APP_PROVIDER_PROFILE) APP_USER($APP_USER_PROFILE) CUSTOMS(${CUSTOMS:-none})" if ! docker info &>/dev/null; then print_error "Docker is not running. Start Docker Desktop and try again." @@ -93,12 +116,24 @@ else fi print_step "Pulling Canton Network images (first run: ~3-5 min, then cached)..." -"${COMPOSE_CMD[@]}" pull --quiet 2>/dev/null || { +mapfile -t INFRA_ARGV < <(infra_compose_argv) +"${INFRA_ARGV[@]}" pull --quiet 2>/dev/null || { print_warning "Silent pull failed — retrying with output..." - "${COMPOSE_CMD[@]}" pull + "${INFRA_ARGV[@]}" pull } print_step "Starting LocalNet..." -"${COMPOSE_CMD[@]}" up -d --remove-orphans +"${INFRA_ARGV[@]}" up -d --remove-orphans + +if [ -n "${CUSTOMS:-}" ]; then + IFS=',' read -ra CUSTOM_NAMES <<< "$CUSTOMS" + for n in "${CUSTOM_NAMES[@]}"; do + [ -z "$n" ] && continue + print_step "Starting custom validator '$n'…" + mapfile -t CUSTOM_ARGV < <(custom_compose_argv "$n") + "${CUSTOM_ARGV[@]}" up -d + done +fi + print_step "Waiting for validators to be ready..." echo " This takes ~5 minutes on first run. Hang tight." echo "" @@ -124,9 +159,22 @@ wait_for_validator() { } FAILED=0 -wait_for_validator "Super Validator" 4903 || FAILED=1 -wait_for_validator "App Provider" 3903 || FAILED=1 -wait_for_validator "App User" 2903 || FAILED=1 +wait_for_validator "Super Validator" 4903 || FAILED=1 +if [ "$APP_PROVIDER_PROFILE" = "on" ]; then + wait_for_validator "App Provider" 3903 || FAILED=1 +fi +if [ "$APP_USER_PROFILE" = "on" ]; then + wait_for_validator "App User" 2903 || FAILED=1 +fi +if [ -n "${CUSTOMS:-}" ]; then + IFS=',' read -ra CUSTOM_NAMES <<< "$CUSTOMS" + for n in "${CUSTOM_NAMES[@]}"; do + [ -z "$n" ] && continue + entry=$(registry_get "$n") || continue + pb=$(echo "$entry" | jq -r .port_base) + wait_for_validator "$n" $((pb + 3)) || FAILED=1 + done +fi echo "" if [ $FAILED -eq 1 ]; then @@ -137,17 +185,22 @@ if [ $FAILED -eq 1 ]; then exit 1 fi -print_header "Yayy! LocalNet is up!" +registry_with_lock registry_upsert_validator app-provider builtin "" "" \ + "$( [ "$APP_PROVIDER_PROFILE" = "on" ] && echo true || echo false )" +registry_with_lock registry_upsert_validator app-user builtin "" "" \ + "$( [ "$APP_USER_PROFILE" = "on" ] && echo true || echo false )" + +print_header "LocalNet is up! 🎉" echo "" echo " Wallet UIs:" -echo " App User → http://wallet.localhost:2000 (login: app-user)" -echo " App Provider → http://wallet.localhost:3000 (login: app-provider)" +[ "$APP_USER_PROFILE" = "on" ] && echo " App User → http://wallet.localhost:2000 (login: app-user)" +[ "$APP_PROVIDER_PROFILE" = "on" ] && echo " App Provider → http://wallet.localhost:3000 (login: app-provider)" echo " Scan → http://scan.localhost:4000" echo " SV UI → http://sv.localhost:4000" echo "" echo " JSON Ledger API:" -echo " App Provider → http://localhost:3975" -echo " App User → http://localhost:2975" +[ "$APP_PROVIDER_PROFILE" = "on" ] && echo " App Provider → http://localhost:3975" +[ "$APP_USER_PROFILE" = "on" ] && echo " App User → http://localhost:2975" echo "" echo " Deploy your DAR: canton builder deploy ./your-project.dar" echo " Check status: canton builder status" diff --git a/scripts/status.sh b/scripts/status.sh index c487975..d7750a7 100755 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -1,57 +1,29 @@ #!/usr/bin/env bash - set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" -print_header "Canton Builder Tool Network Status" -check_http() { - local label="$1" - local url="$2" - local extra="${3:-}" - printf " %-34s" "$label" - if curl -fs "$url" &>/dev/null; then - echo -e "${GREEN}● UP${NC} ${extra}" - else - echo -e "${RED}● DOWN${NC} ${extra}" - fi -} - -echo -e " ${BOLD}Validators${NC}" -check_http "Super Validator" "http://localhost:4903/api/validator/readyz" "(port 4903)" -check_http "App Provider Validator" "http://localhost:3903/api/validator/readyz" "(port 3903)" -check_http "App User Validator" "http://localhost:2903/api/validator/readyz" "(port 2903)" -echo "" -echo -e " ${BOLD}JSON Ledger API${NC}" -check_http "App Provider JSON API" "http://localhost:3975/readyz" "(http://localhost:3975)" -check_http "App User JSON API" "http://localhost:2975/readyz" "(http://localhost:2975)" -check_http "SV JSON API" "http://localhost:4975/readyz" "(http://localhost:4975)" +source "$DEVREL_DIR/scripts/lib/registry.sh" +source "$DEVREL_DIR/scripts/lib/validator.sh" -echo "" -echo -e " ${BOLD}UIs${NC}" -check_http "App User Wallet" "http://wallet.localhost:2000" "(http://wallet.localhost:2000)" -check_http "App Provider Wallet" "http://wallet.localhost:3000" "(http://wallet.localhost:3000)" -check_http "Scan UI" "http://scan.localhost:4000" "(http://scan.localhost:4000)" -check_http "SV UI" "http://sv.localhost:4000" "(http://sv.localhost:4000)" +print_header "Canton Builder Tool Network Status" -echo "" -echo -e " ${BOLD}Running containers${NC}" -"${COMPOSE_CMD[@]}" ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null | sed 's/^/ /' \ - || echo " (LocalNet not running)" +validator_list echo "" echo -e " ${BOLD}Port Reference${NC}" echo " ────────────────────────────────────────────────" -printf " %-38s %s\n" "App Provider Ledger API (gRPC)" "localhost:3901" -printf " %-38s %s\n" "App User Ledger API (gRPC)" "localhost:2901" -printf " %-38s %s\n" "SV Ledger API (gRPC)" "localhost:4901" -printf " %-38s %s\n" "App Provider JSON API" "localhost:3975" -printf " %-38s %s\n" "App User JSON API" "localhost:2975" -printf " %-38s %s\n" "SV JSON API" "localhost:4975" -printf " %-38s %s\n" "App Provider Validator API" "localhost:3903" -printf " %-38s %s\n" "App User Validator API" "localhost:2903" -printf " %-38s %s\n" "SV Validator API" "localhost:4903" -printf " %-38s %s\n" "PostgreSQL" "localhost:5432" +printf " %-38s %s\n" "SV Ledger API (gRPC)" "localhost:4901" +printf " %-38s %s\n" "SV JSON API" "localhost:4975" +printf " %-38s %s\n" "SV Validator API" "localhost:4903" +registry_read | jq -r '.validators[] | select(.type=="builtin")' | while read -r line; do + : # rendered above by validator_list — skip; this section is just for the customs. +done +registry_read | jq -r '.validators[] | select(.type=="custom") | "\(.name) \(.port_base)"' \ + | while read -r name pb; do + printf " %-38s %s\n" "$name JSON API" "localhost:$((pb + 75))" + printf " %-38s %s\n" "$name Validator API" "localhost:$((pb + 3))" + printf " %-38s %s\n" "$name Ledger API" "localhost:$((pb + 1))" + done +printf " %-38s %s\n" "PostgreSQL" "localhost:5432" echo " ────────────────────────────────────────────────" echo "" -echo " Wallet login: app-user | app-provider | sv" -echo "" \ No newline at end of file diff --git a/scripts/stop.sh b/scripts/stop.sh index 2d4eab3..fb5c837 100755 --- a/scripts/stop.sh +++ b/scripts/stop.sh @@ -1,22 +1,25 @@ #!/usr/bin/env bash - set -euo pipefail DEVREL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$DEVREL_DIR/scripts/lib/common.sh" +source "$DEVREL_DIR/scripts/lib/registry.sh" +source "$DEVREL_DIR/scripts/lib/compose.sh" + +print_header "Canton Builder Tool Stopping LocalNet" -print_header "Canton builder Tool Stopping LocalNet" -print_step "Stopping containers (data volumes preserved)..." +# Registry running flags are NOT cleared on stop — they encode "what to start next time". +for n in $(registry_read | jq -r '.validators[] | select(.type=="custom") | .name'); do + print_step "Stopping custom validator '$n'…" + mapfile -t argv < <(custom_compose_argv "$n") + "${argv[@]}" stop 2>/dev/null || true +done -docker compose \ - --env-file "$LOCALNET_DIR/compose.env" \ - --env-file "$LOCALNET_DIR/env/common.env" \ - -f "$LOCALNET_DIR/compose.yaml" \ - -f "$LOCALNET_DIR/resource-constraints.yaml" \ - --profile sv --profile app-provider --profile app-user \ - down +print_step "Stopping localnet (data volumes preserved)…" +mapfile -t argv < <(infra_compose_argv) +"${argv[@]}" down echo "" print_ok "LocalNet stopped. Data volumes preserved." -echo " Resume: canton builder start" +echo " Resume: canton builder start" echo " Full wipe: canton builder reset" -echo "" \ No newline at end of file +echo "" diff --git a/scripts/validator.sh b/scripts/validator.sh new file mode 100755 index 0000000..674287b --- /dev/null +++ b/scripts/validator.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$REPO_DIR/scripts/lib/validator.sh" + +VERB="${1:-}" +shift || true + +case "$VERB" in + list) validator_list ;; + info) validator_info "$@" ;; + add) validator_add "$@" ;; + start) validator_start "$@" ;; + stop) validator_stop "$@" ;; + rm) validator_rm "$@" ;; + ""|help|--help|-h) + cat < [args] + +VERBS + list List all validators (built-in + custom) + info Show ports, wallet URL, party hint for one validator + add [--port-base N] Register and start a new custom validator + start Start an existing validator + stop Stop a validator (data preserved) + rm [--force] Remove a custom validator (built-ins can't be removed) + +EXAMPLES + canton builder validator list + canton builder validator add acme + canton builder validator add bob --port-base 7900 + canton builder validator info acme + canton builder validator stop acme + canton builder validator rm acme +EOF + ;; + *) + echo "unknown verb: $VERB" >&2 + echo "run 'canton builder validator help' for usage" >&2 + exit 1 + ;; +esac diff --git a/tests/integration/test_lifecycle.bats b/tests/integration/test_lifecycle.bats new file mode 100755 index 0000000..8eb245c --- /dev/null +++ b/tests/integration/test_lifecycle.bats @@ -0,0 +1,76 @@ +#!/usr/bin/env bats +# Slow Docker-gated lifecycle integration test. Run with CI_INTEGRATION=1. + +load ../test_helper + +setup() { + if [ "${CI_INTEGRATION:-0}" != "1" ]; then + skip "set CI_INTEGRATION=1 to run integration suite" + fi + if ! command -v docker >/dev/null; then + skip "docker not available" + fi + REPO_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + export REPO_DIR + # NB: this test uses the user's real ~/.canton-builder (no isolation possible + # since the Docker daemon is shared); cleanup happens in teardown. +} + +teardown() { + "$REPO_DIR/canton" builder reset --purge <<< "yes" >/dev/null 2>&1 || true +} + +@test "start no flags brings up app-provider only (no app-user)" { + "$REPO_DIR/canton" builder start + run curl -fs http://localhost:3903/api/validator/readyz + [ "$status" -eq 0 ] + run curl -fs --max-time 3 http://localhost:2903/api/validator/readyz + [ "$status" -ne 0 ] +} + +@test "start --with app-user brings up both builtins" { + "$REPO_DIR/canton" builder start --with app-user + curl -fs http://localhost:3903/api/validator/readyz + curl -fs http://localhost:2903/api/validator/readyz +} + +@test "validator add acme produces a working 4th validator" { + "$REPO_DIR/canton" builder start + "$REPO_DIR/canton" builder validator add acme + curl -fs http://localhost:5903/api/validator/readyz + run curl -fs --max-time 3 --resolve wallet.acme.localhost:5500:127.0.0.1 http://wallet.acme.localhost:5500 + [ "$status" -eq 0 ] +} + +@test "stop preserves intent; start brings same shape back; data persists" { + "$REPO_DIR/canton" builder start + "$REPO_DIR/canton" builder validator add acme + "$REPO_DIR/canton" builder validator stop acme + "$REPO_DIR/canton" builder stop + "$REPO_DIR/canton" builder start + # acme was stopped before global stop → must NOT auto-start. + run curl -fs --max-time 3 http://localhost:5903/api/validator/readyz + [ "$status" -ne 0 ] + # Bring acme back; ledger should still be intact. + "$REPO_DIR/canton" builder validator start acme + curl -fs http://localhost:5903/api/validator/readyz +} + +@test "validator rm wipes everything for that custom" { + "$REPO_DIR/canton" builder start + "$REPO_DIR/canton" builder validator add acme + "$REPO_DIR/canton" builder validator rm acme + [ ! -d "$HOME/.canton-builder/validators/acme" ] + [ ! -f "$HOME/.canton-builder/nginx-customs/acme.conf" ] + run jq -e '.validators[] | select(.name=="acme")' "$HOME/.canton-builder/validators.json" + [ "$status" -ne 0 ] +} + +@test "reset preserves recipes, reset --purge wipes them" { + "$REPO_DIR/canton" builder start + "$REPO_DIR/canton" builder validator add acme + echo "yes" | "$REPO_DIR/canton" builder reset + [ -d "$HOME/.canton-builder/validators/acme" ] + echo "yes" | "$REPO_DIR/canton" builder reset --purge + [ ! -d "$HOME/.canton-builder" ] +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..d3262be --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run the bats test suites. Vendored bats-core under tests/.bats on first run. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BATS_DIR="$REPO_DIR/tests/.bats" + +if [ ! -x "$BATS_DIR/bin/bats" ]; then + echo "Installing bats-core into $BATS_DIR..." + git clone --depth 1 https://github.com/bats-core/bats-core.git "$BATS_DIR" >/dev/null +fi + +SUITE="${1:-unit}" +exec "$BATS_DIR/bin/bats" "$REPO_DIR/tests/$SUITE" diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..f537d13 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,45 @@ +# Shared helpers for all bats tests. +# Each test gets a fresh isolated $CANTON_DEVREL_DIR; teardown removes it. + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +setup() { + TMPHOME="$(mktemp -d -t canton-devrel-test.XXXXXX)" + export TMPHOME + export CANTON_DEVREL_DIR="$TMPHOME" + export REPO_DIR + export DEVREL_DIR="$REPO_DIR" + export PATH="$TMPHOME/stubs:$PATH" + mkdir -p "$TMPHOME/stubs" +} + +teardown() { + rm -rf "$TMPHOME" +} + +# Stub docker so unit tests never touch a real daemon. +# Calls are recorded one-per-line in $TMPHOME/docker-calls. +stub_docker() { + cat > "$TMPHOME/stubs/docker" <<'EOF' +#!/usr/bin/env bash +echo "$@" >> "$TMPHOME/docker-calls" +case "$*" in + "compose ls --format json") echo '[]'; exit 0 ;; + *exec*nginx*nginx*-s*reload*) exit 0 ;; + *) exit 0 ;; +esac +EOF + chmod +x "$TMPHOME/stubs/docker" +} + +# Record arbitrary external commands the same way (curl, openssl, etc.). +stub_cmd() { + local name="$1" + local exit_code="${2:-0}" + cat > "$TMPHOME/stubs/$name" <> "$TMPHOME/cmd-calls" +exit $exit_code +EOF + chmod +x "$TMPHOME/stubs/$name" +} diff --git a/tests/unit/test_compose.bats b/tests/unit/test_compose.bats new file mode 100644 index 0000000..b995981 --- /dev/null +++ b/tests/unit/test_compose.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + export VALIDATOR_BUNDLE_DIR="$TMPHOME/bundle/validator" + export LOCALNET_DIR="$TMPHOME/bundle/localnet" + export OVERLAYS_DIR="$REPO_DIR/overlays" + mkdir -p "$VALIDATOR_BUNDLE_DIR" "$LOCALNET_DIR" + # Bundle .env that we expect custom_compose_argv to include. + printf 'NGINX_VERSION=1.27.1\nSPLICE_DB_USER=cnadmin\n' >"$VALIDATOR_BUNDLE_DIR/.env" + source "$REPO_DIR/scripts/lib/compose.sh" +} +teardown() { rm -rf "$TMPHOME"; } + +@test "custom_compose_argv includes the bundle .env as --env-file" { + mapfile -t argv < <(custom_compose_argv acme) + # Must contain the bundle .env path right after an --env-file flag. + local found=0 i + for ((i=0; i<${#argv[@]}-1; i++)); do + if [ "${argv[$i]}" = "--env-file" ] && [ "${argv[$((i+1))]}" = "$VALIDATOR_BUNDLE_DIR/.env" ]; then + found=1 + break + fi + done + [ "$found" -eq 1 ] +} + +@test "custom_compose_argv places bundle .env before the rendered env (rendered overrides bundle)" { + mapfile -t argv < <(custom_compose_argv acme) + local rendered="$CANTON_DEVREL_DIR/validators/acme/env" + local bundle="$VALIDATOR_BUNDLE_DIR/.env" + local bundle_pos=-1 rendered_pos=-1 i + for ((i=0; i<${#argv[@]}; i++)); do + [ "${argv[$i]}" = "$bundle" ] && bundle_pos=$i + [ "${argv[$i]}" = "$rendered" ] && rendered_pos=$i + done + [ "$bundle_pos" -ge 0 ] + [ "$rendered_pos" -ge 0 ] + [ "$bundle_pos" -lt "$rendered_pos" ] +} + +@test "custom_compose_argv still emits the project name validator-" { + mapfile -t argv < <(custom_compose_argv acme) + local found=0 i + for ((i=0; i<${#argv[@]}-1; i++)); do + if [ "${argv[$i]}" = "-p" ] && [ "${argv[$((i+1))]}" = "validator-acme" ]; then + found=1; break + fi + done + [ "$found" -eq 1 ] +} diff --git a/tests/unit/test_customenv.bats b/tests/unit/test_customenv.bats new file mode 100644 index 0000000..f695dca --- /dev/null +++ b/tests/unit/test_customenv.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + export IMAGE_TAG=1.2.3 + # Fake bundle VERSION so render_custom_env can derive IMAGE_TAG when caller doesn't set it. + export BUNDLE_DIR="$TMPHOME/bundle" + mkdir -p "$BUNDLE_DIR/splice-node" + echo "0.5.18" >"$BUNDLE_DIR/splice-node/VERSION" + source "$REPO_DIR/scripts/lib/customenv.sh" +} +teardown() { rm -rf "$TMPHOME"; } + +@test "render_custom_env writes load-bearing keys" { + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + local f="$TMPHOME/validators/acme/env" + [ -f "$f" ] + grep -q "IMAGE_TAG=1.2.3" "$f" + grep -q 'SPONSOR_SV_ADDRESS=http://splice:5014' "$f" + grep -q 'SCAN_ADDRESS=http://splice:5012' "$f" + grep -q '^ONBOARDING_SECRET="fake-secret-token"$' "$f" + grep -q "PARTY_HINT=acme-validator-1" "$f" + grep -q "PORT_BASE=5900" "$f" + grep -q "LEDGER_API_PORT=5901" "$f" + grep -q "VALIDATOR_API_PORT=5903" "$f" + grep -q "JSON_API_PORT=5975" "$f" +} + +@test "render_custom_env is idempotent (rewrites cleanly)" { + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + render_custom_env acme 5900 acme-validator-1 "fake-secret-token-2" + local n + n=$(grep -c "PORT_BASE=" "$TMPHOME/validators/acme/env") + [ "$n" -eq 1 ] +} + +@test "render_custom_env defaults IMAGE_REPO to ghcr.io (matches bundle/localnet)" { + unset IMAGE_REPO + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + grep -q "^IMAGE_REPO=ghcr.io/digital-asset/decentralized-canton-sync/docker/$" \ + "$TMPHOME/validators/acme/env" +} + +@test "render_custom_env honors caller-provided IMAGE_REPO" { + export IMAGE_REPO="example.com/repo/" + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + grep -q "^IMAGE_REPO=example.com/repo/$" "$TMPHOME/validators/acme/env" + unset IMAGE_REPO +} + +@test "render_custom_env derives IMAGE_TAG from bundle VERSION when caller hasn't set one" { + unset IMAGE_TAG + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + grep -q "^IMAGE_TAG=0.5.18$" "$TMPHOME/validators/acme/env" +} + +@test "render_custom_env writes PARTICIPANT_IDENTIFIER (defaults to party_hint, mirroring bundle start.sh)" { + # Bundle's validator/.env leaves PARTICIPANT_IDENTIFIER empty. compose.yaml + # passes it through as SPLICE_APP_VALIDATOR_PARTICIPANT_IDENTIFIER. If empty, + # the validator crashes with "Daml-LF Party is empty" during NodeInitializer. + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + grep -q "^PARTICIPANT_IDENTIFIER=acme-validator-1$" "$TMPHOME/validators/acme/env" +} + +@test "render_custom_env writes ONBOARDING_SECRET from the 4th argument" { + # Bundle's SV only knows two pre-shared secrets (APP_PROVIDER/APP_USER). + # For custom validators, validator_add fetches a fresh secret from the SV's + # DevNet prepare endpoint and threads it through here. An empty secret would + # get rejected as "Unknown secret" by the SV during onboarding. + render_custom_env acme 5900 acme-validator-1 "eyJzcG9uc29yaW5nU3Yi==" + grep -q '^ONBOARDING_SECRET="eyJzcG9uc29yaW5nU3Yi=="$' "$TMPHOME/validators/acme/env" +} + +@test "render_custom_env writes SPLICE_APP_UI_* defaults matching localnet common.env" { + render_custom_env acme 5900 acme-validator-1 "fake-secret-token" + local f="$TMPHOME/validators/acme/env" + grep -q '^SPLICE_APP_UI_NETWORK_NAME=Splice$' "$f" + grep -q '^SPLICE_APP_UI_AMULET_NAME=Amulet$' "$f" + grep -q '^SPLICE_APP_UI_AMULET_NAME_ACRONYM=AMT$' "$f" + grep -q '^SPLICE_APP_UI_NAME_SERVICE_NAME=Amulet Name Service$' "$f" + grep -q '^SPLICE_APP_UI_NAME_SERVICE_NAME_ACRONYM=ANS$' "$f" + grep -q '^SPLICE_APP_UI_NETWORK_FAVICON_URL=' "$f" +} diff --git a/tests/unit/test_customs_overlay.bats b/tests/unit/test_customs_overlay.bats new file mode 100644 index 0000000..9c631e0 --- /dev/null +++ b/tests/unit/test_customs_overlay.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats +# Localnet's nginx is the only host-bound entry point for custom validator wallet +# UIs. Two non-obvious requirements: +# - It must publish a host port that doesn't collide with macOS AirPlay +# Receiver (which binds :5000 by default on Monterey+). We use :5500. +# - It must include the rendered customs/*.conf files. The bundle's nginx.conf +# only globs /etc/nginx/conf.d/*.conf (non-recursive), so we mount an +# extended nginx.conf that also globs /etc/nginx/conf.d/customs/*.conf. +load ../test_helper + +setup() { + CUSTOMS_OVERLAY="$REPO_DIR/overlays/customs.overlay.yaml" + NGINX_CONF="$REPO_DIR/overlays/nginx-conf/nginx.conf" + NGINXCUSTOM_LIB="$REPO_DIR/scripts/lib/nginxcustom.sh" +} + +@test "customs overlay publishes nginx on host :5500 (avoids macOS AirPlay :5000)" { + grep -qE '"?\$\{HOST_BIND_IP[^}]*\}:5500:5500"?' "$CUSTOMS_OVERLAY" +} + +@test "customs overlay does NOT publish nginx on the AirPlay-claimed :5000" { + ! grep -qE ':5000:5000' "$CUSTOMS_OVERLAY" +} + +@test "customs overlay mounts the patched nginx.conf via OVERLAYS_DIR" { + grep -qE '\$\{OVERLAYS_DIR\}/nginx-conf/nginx\.conf:/etc/nginx/nginx\.conf' "$CUSTOMS_OVERLAY" +} + +@test "customs overlay mounts the per-validator confs at /etc/nginx/conf.d/customs" { + grep -qE '\$\{CANTON_DEVREL_DIR\}/nginx-customs:/etc/nginx/conf\.d/customs' "$CUSTOMS_OVERLAY" +} + +@test "patched nginx.conf includes the customs subdirectory glob" { + grep -qE '^\s*include\s+/etc/nginx/conf\.d/customs/\*\.conf\s*;' "$NGINX_CONF" +} + +@test "patched nginx.conf preserves the bundle's primary include" { + grep -qE '^\s*include\s+/etc/nginx/conf\.d/\*\.conf\s*;' "$NGINX_CONF" +} + +@test "rendered per-validator conf listens on the new port" { + source "$NGINXCUSTOM_LIB" + local tmp; tmp="$(mktemp -d)" + export CANTON_DEVREL_DIR="$tmp" + render_nginx_conf acme + local conf="$tmp/nginx-customs/acme.conf" + [ -f "$conf" ] + grep -qE '^\s*listen 5500;\s*$' "$conf" + ! grep -qE '^\s*listen 5000;\s*$' "$conf" + rm -rf "$tmp" +} + +@test "customs overlay is wired into infra_compose_argv (so mount actually applies)" { + source "$REPO_DIR/scripts/lib/common.sh" + source "$REPO_DIR/scripts/lib/compose.sh" + mapfile -t argv < <(infra_compose_argv) + printf '%s\n' "${argv[@]}" | grep -q 'customs.overlay.yaml' +} diff --git a/tests/unit/test_fetch_onboarding_secret.bats b/tests/unit/test_fetch_onboarding_secret.bats new file mode 100644 index 0000000..5cb4e80 --- /dev/null +++ b/tests/unit/test_fetch_onboarding_secret.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats +# _fetch_onboarding_secret mints a one-time DevNet onboarding token from the +# SV's prepare endpoint. The body is opaque (a base64 JSON envelope) and the +# helper must return it verbatim, fail loudly on non-200, and fail loudly on +# transport errors. Curl is stubbed so the test stays hermetic. +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + # Source the lib via a wrapper so we can stub curl before any call. + source "$REPO_DIR/scripts/lib/common.sh" + source "$REPO_DIR/scripts/lib/validator.sh" +} +teardown() { rm -rf "$TMPHOME"; unset -f curl; } + +_stub_curl() { + # Stub mimics `curl -o -w '%{http_code}' …` — writes body to the -o + # file and prints the status code to stdout, like the real flag combination. + local body="$1" code="$2" exitcode="${3:-0}" + eval "curl() { + local out=\"\" + while [ \$# -gt 0 ]; do + case \"\$1\" in + -o) out=\"\$2\"; shift 2;; + *) shift;; + esac + done + [ -n \"\$out\" ] && printf '%s' '$body' > \"\$out\" + printf '%s' '$code' + return $exitcode + }" +} + +@test "_fetch_onboarding_secret returns the body verbatim on HTTP 200" { + _stub_curl "eyJzcG9uc29yaW5nU3Yi==" 200 + run _fetch_onboarding_secret + [ "$status" -eq 0 ] + [ "$output" = "eyJzcG9uc29yaW5nU3Yi==" ] +} + +@test "_fetch_onboarding_secret fails on HTTP 503" { + _stub_curl "service unavailable" 503 + run _fetch_onboarding_secret + [ "$status" -ne 0 ] + echo "$output" | grep -q '503' +} + +@test "_fetch_onboarding_secret fails on empty body even with HTTP 200" { + _stub_curl "" 200 + run _fetch_onboarding_secret + [ "$status" -ne 0 ] + echo "$output" | grep -qi 'empty' +} + +@test "_fetch_onboarding_secret fails when curl exits non-zero (network error)" { + _stub_curl "" 000 7 + run _fetch_onboarding_secret + [ "$status" -ne 0 ] +} + +@test "_fetch_onboarding_secret hits the documented endpoint path" { + # Verify the URL the helper requests, by having the stub record it. + local recorder="$TMPHOME/url.log" + eval "curl() { + while [ \$# -gt 0 ]; do + case \"\$1\" in + -o) shift 2;; + http*) echo \"\$1\" > '$recorder'; shift;; + *) shift;; + esac + done + printf '200' + return 0 + }" + # The stub writes nothing to the -o file → body is empty → helper exits 1, + # but the URL was still recorded. + _fetch_onboarding_secret || true + grep -q '/api/sv/v0/devnet/onboard/validator/prepare$' "$recorder" +} + +@test "_fetch_onboarding_secret honors SV_SPONSOR_HOST_URL override" { + local recorder="$TMPHOME/url.log" + eval "curl() { + while [ \$# -gt 0 ]; do + case \"\$1\" in + -o) shift 2;; + http*) echo \"\$1\" > '$recorder'; shift;; + *) shift;; + esac + done + printf '200' + return 0 + }" + SV_SPONSOR_HOST_URL='http://other-host:1234' _fetch_onboarding_secret || true + grep -q '^http://other-host:1234/api/sv/v0/devnet/onboard/validator/prepare$' "$recorder" +} diff --git a/tests/unit/test_harness.bats b/tests/unit/test_harness.bats new file mode 100644 index 0000000..f4577be --- /dev/null +++ b/tests/unit/test_harness.bats @@ -0,0 +1,13 @@ +#!/usr/bin/env bats +load ../test_helper + +@test "TMPHOME is created and isolated" { + [ -d "$TMPHOME" ] + [ "$CANTON_DEVREL_DIR" = "$TMPHOME" ] +} + +@test "stub_docker intercepts docker calls" { + stub_docker + docker compose ls + grep -q "compose ls" "$TMPHOME/docker-calls" +} diff --git a/tests/unit/test_overlay_ports.bats b/tests/unit/test_overlay_ports.bats new file mode 100644 index 0000000..2083441 --- /dev/null +++ b/tests/unit/test_overlay_ports.bats @@ -0,0 +1,29 @@ +#!/usr/bin/env bats +# The custom-validator stack inherits no host port mappings: the bundle's nginx +# (the only host-bound service) is unbound by attach-localnet.overlay.yaml to +# avoid 80:80 collisions across multiple validator- projects. The overlay +# must re-bind the validator backend + participant ledger ports so the host can +# reach /api/validator/readyz, the gRPC ledger API, and the JSON ledger API at +# the host ports `validator_info` advertises. +load ../test_helper + +setup() { + OVERLAY="$REPO_DIR/overlays/attach-localnet.overlay.yaml" +} + +@test "overlay binds host VALIDATOR_API_PORT to validator:5003" { + grep -qE '^\s*-\s*"?\$\{VALIDATOR_API_PORT\}:5003"?' "$OVERLAY" +} + +@test "overlay binds host LEDGER_API_PORT to participant:5001 (gRPC)" { + grep -qE '^\s*-\s*"?\$\{LEDGER_API_PORT\}:5001"?' "$OVERLAY" +} + +@test "overlay binds host JSON_API_PORT to participant:7575 (JSON)" { + grep -qE '^\s*-\s*"?\$\{JSON_API_PORT\}:7575"?' "$OVERLAY" +} + +@test "overlay still unbinds nginx host ports (avoid :80 collision)" { + # The unbind is required to allow multiple validator- projects to coexist. + grep -qE 'ports:\s*!reset\s*\[\]' "$OVERLAY" +} diff --git a/tests/unit/test_portalloc.bats b/tests/unit/test_portalloc.bats new file mode 100644 index 0000000..ee0c547 --- /dev/null +++ b/tests/unit/test_portalloc.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + # Override TCP probe with a stub that reads $TMPHOME/busy-ports (newline-separated). + source "$REPO_DIR/scripts/lib/portalloc.sh" + port_in_use() { + local p="$1" + grep -qx "$p" "$TMPHOME/busy-ports" 2>/dev/null + } +} +teardown() { rm -rf "$TMPHOME"; } + +@test "allocates 5900 when all derived ports free" { + : > "$TMPHOME/busy-ports" + run allocate_port_base + [ "$status" -eq 0 ] + [ "$output" = "5900" ] +} + +@test "bumps to 6900 when 5900's ledger port is busy" { + echo 5901 > "$TMPHOME/busy-ports" + run allocate_port_base + [ "$status" -eq 0 ] + [ "$output" = "6900" ] +} + +@test "fails after 10 retries" { + for n in 5901 6901 7901 8901 9901 10901 11901 12901 13901 14901; do + echo "$n" >> "$TMPHOME/busy-ports" + done + run allocate_port_base + [ "$status" -ne 0 ] + [[ "$output" == *"no free port range"* ]] +} + +@test "validate_explicit_port_base accepts 5900" { + : > "$TMPHOME/busy-ports" + run validate_explicit_port_base 5900 + [ "$status" -eq 0 ] +} + +@test "validate_explicit_port_base rejects 5950 (must end in 900)" { + run validate_explicit_port_base 5950 + [ "$status" -ne 0 ] + [[ "$output" == *"must end in 900"* ]] +} + +@test "validate_explicit_port_base rejects 5900 when a derived port is busy" { + echo 5975 > "$TMPHOME/busy-ports" + run validate_explicit_port_base 5900 + [ "$status" -ne 0 ] + [[ "$output" == *"in use"* ]] +} diff --git a/tests/unit/test_registry.bats b/tests/unit/test_registry.bats new file mode 100644 index 0000000..bcbf14b --- /dev/null +++ b/tests/unit/test_registry.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + source "$REPO_DIR/scripts/lib/registry.sh" +} +teardown() { rm -rf "$TMPHOME"; } + +@test "read of missing registry returns empty skeleton" { + run registry_read + [ "$status" -eq 0 ] + echo "$output" | jq -e '.version == 1 and (.validators | length) == 0' +} + +@test "upsert inserts a new validator entry" { + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 false + registry_read | jq -e '.validators[] | select(.name=="acme" and .type=="custom" and .port_base==5900 and .party_hint=="acme-validator-1" and .running==false)' +} + +@test "upsert updates an existing entry by name" { + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 false + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 true + local count + count=$(registry_read | jq '[.validators[] | select(.name=="acme")] | length') + [ "$count" -eq 1 ] + registry_read | jq -e '.validators[] | select(.name=="acme" and .running==true)' +} + +@test "remove deletes a validator" { + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 false + registry_with_lock registry_remove_validator acme + local count + count=$(registry_read | jq '[.validators[] | select(.name=="acme")] | length') + [ "$count" -eq 0 ] +} + +@test "concurrent writers serialize under lock" { + for i in 1 2 3 4 5; do + registry_with_lock registry_upsert_validator "v$i" custom $((5900 + 1000*i)) "v$i-validator-1" false & + done + wait + local count + count=$(registry_read | jq '.validators | length') + [ "$count" -eq 5 ] +} + +@test "registry_get returns a single entry as JSON" { + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 true + run registry_get acme + [ "$status" -eq 0 ] + echo "$output" | jq -e '.name == "acme" and .port_base == 5900' +} + +@test "registry_get returns non-zero for missing entry" { + run registry_get acme + [ "$status" -ne 0 ] +} + +@test "registry_list_running returns only running validators" { + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 true + registry_with_lock registry_upsert_validator bob custom 6900 bob-validator-1 false + run registry_list_running + [ "$status" -eq 0 ] + [[ "$output" == *"acme"* ]] + [[ "$output" != *"bob"* ]] +} diff --git a/tests/unit/test_resolve.bats b/tests/unit/test_resolve.bats new file mode 100644 index 0000000..97759d8 --- /dev/null +++ b/tests/unit/test_resolve.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + source "$REPO_DIR/scripts/lib/registry.sh" + source "$REPO_DIR/scripts/lib/resolve.sh" +} +teardown() { rm -rf "$TMPHOME"; } + +# helper to extract a single line from `output` +field() { echo "$output" | grep -E "^$1=" | cut -d= -f2-; } + +@test "no flags, empty registry, no DEFAULT_VALIDATORS → app-provider only" { + unset DEFAULT_VALIDATORS + run resolve_active_set + [ "$status" -eq 0 ] + [ "$(field SV_PROFILE)" = "on" ] + [ "$(field APP_PROVIDER_PROFILE)" = "on" ] + [ "$(field APP_USER_PROFILE)" = "off" ] + [ "$(field CUSTOMS)" = "" ] +} + +@test "no flags, registry has running customs → those plus running builtins" { + registry_with_lock registry_upsert_validator app-provider builtin "" "" true + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 true + run resolve_active_set + [ "$status" -eq 0 ] + [ "$(field APP_PROVIDER_PROFILE)" = "on" ] + [ "$(field APP_USER_PROFILE)" = "off" ] + [ "$(field CUSTOMS)" = "acme" ] +} + +@test "no flags, DEFAULT_VALIDATORS=app-user,acme used when registry empty" { + # Pre-register acme so _validate_names recognises it; running=false so the + # registry-running fallback is skipped and DEFAULT_VALIDATORS is exercised. + registry_with_lock registry_upsert_validator acme custom 5900 acme-validator-1 false + export DEFAULT_VALIDATORS=app-user,acme + run resolve_active_set + [ "$(field APP_PROVIDER_PROFILE)" = "off" ] + [ "$(field APP_USER_PROFILE)" = "on" ] + [ "$(field CUSTOMS)" = "acme" ] +} + +@test "--absolute app-provider overrides everything else" { + registry_with_lock registry_upsert_validator app-user builtin "" "" true + export DEFAULT_VALIDATORS=app-user,acme + run resolve_active_set --absolute app-provider + [ "$(field APP_PROVIDER_PROFILE)" = "on" ] + [ "$(field APP_USER_PROFILE)" = "off" ] + [ "$(field CUSTOMS)" = "" ] +} + +@test "--with app-user adds on top of resolved set" { + run resolve_active_set --with app-user + [ "$(field APP_PROVIDER_PROFILE)" = "on" ] + [ "$(field APP_USER_PROFILE)" = "on" ] +} + +@test "--without app-provider removes from resolved set" { + run resolve_active_set --without app-provider + [ "$(field APP_PROVIDER_PROFILE)" = "off" ] + [ "$(field APP_USER_PROFILE)" = "off" ] +} + +@test "rejects sv in --absolute" { + run resolve_active_set --absolute sv,app-user + [ "$status" -ne 0 ] + [[ "$output" == *"sv is infrastructure"* ]] +} + +@test "rejects sv in --with" { + run resolve_active_set --with sv + [ "$status" -ne 0 ] +} + +@test "rejects mixing --absolute with --with" { + run resolve_active_set --absolute app-provider --with app-user + [ "$status" -ne 0 ] + [[ "$output" == *"absolute"* ]] +} + +@test "unknown name in --absolute errors" { + # No registry entry, not a built-in → unknown. + run resolve_active_set --absolute mystery + [ "$status" -ne 0 ] + [[ "$output" == *"no such validator"* ]] +} diff --git a/tests/unit/test_sv_scan_overlay.bats b/tests/unit/test_sv_scan_overlay.bats new file mode 100644 index 0000000..ac94587 --- /dev/null +++ b/tests/unit/test_sv_scan_overlay.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats +# The bundle's localnet SV registers its scan URL in DsoRules as +# http://localhost:5012. That URL is unreachable from peer containers +# (e.g. a custom validator on the localnet network) because `localhost` +# inside the peer is its own loopback. The overlay below ships a patched +# app.conf that registers http://splice:5012 instead, so BftScanConnection +# from peer validators can reach the SV's scan service via Docker DNS. +load ../test_helper + +setup() { + SV_CONF="$REPO_DIR/overlays/splice-conf/sv-app.conf" + OVERLAY="$REPO_DIR/overlays/sv-scan-url.overlay.yaml" +} + +@test "patched SV app.conf sets scan.internal-url to splice:5012" { + grep -qE '^\s*internal-url\s*=\s*"http://splice:5012"\s*$' "$SV_CONF" +} + +@test "patched SV app.conf sets scan.public-url to splice:5012" { + # Peer validators read public-url (not internal-url) from DsoRules via + # BftScanConnection; both must point at the docker-DNS hostname for + # peer-on-localnet reachability. Matches the bundle's own peer-mode SV + # setup at docker-compose/sv/compose.yaml which patches both URLs. + grep -qE '^\s*public-url\s*=\s*"http://splice:5012"\s*$' "$SV_CONF" +} + +@test "patched SV app.conf no longer registers localhost:5012 in sv-apps.sv.scan" { + # Spot-check: both URLs in the sv-apps.sv.scan block are now docker DNS. + # Other localhost:5012 occurrences (e.g. SV-validator's own scan-client) + # may remain because they correctly target the SV's own loopback. + ! grep -qE '^\s*(public-url|internal-url)\s*=\s*"http://localhost:5012"' "$SV_CONF" +} + +@test "compose overlay mounts our patched file over /app/sv//app.conf" { + grep -qE '\$\{OVERLAYS_DIR\}/splice-conf/sv-app\.conf:/app/sv/\$\{SV_PROFILE\}/app\.conf' "$OVERLAY" +} + +@test "common.sh exports OVERLAYS_DIR so docker compose can interpolate it" { + # Verify in a subshell that OVERLAYS_DIR is exported, not just shell-local. + run bash -c "source $REPO_DIR/scripts/lib/common.sh && env | grep -E '^OVERLAYS_DIR='" + [ "$status" -eq 0 ] +} + +@test "infra_compose_argv includes the sv-scan-url overlay" { + source "$REPO_DIR/scripts/lib/common.sh" + source "$REPO_DIR/scripts/lib/compose.sh" + mapfile -t argv < <(infra_compose_argv) + printf '%s\n' "${argv[@]}" | grep -q 'sv-scan-url.overlay.yaml' +} diff --git a/tests/unit/test_validate_name.bats b/tests/unit/test_validate_name.bats new file mode 100644 index 0000000..f094cda --- /dev/null +++ b/tests/unit/test_validate_name.bats @@ -0,0 +1,48 @@ +#!/usr/bin/env bats +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export CANTON_DEVREL_DIR="$TMPHOME" + source "$REPO_DIR/scripts/lib/validate_name.sh" +} +teardown() { rm -rf "$TMPHOME"; } + +@test "accepts a simple lowercase name" { + run validate_validator_name "acme" + [ "$status" -eq 0 ] +} + +@test "accepts hyphens and digits" { + run validate_validator_name "acme-1" + [ "$status" -eq 0 ] +} + +@test "rejects names starting with a digit" { + run validate_validator_name "1acme" + [ "$status" -ne 0 ] + [[ "$output" == *"must match"* ]] +} + +@test "rejects uppercase" { + run validate_validator_name "Acme" + [ "$status" -ne 0 ] +} + +@test "rejects names too short" { + run validate_validator_name "a" + [ "$status" -ne 0 ] +} + +@test "rejects names too long (>31 chars)" { + run validate_validator_name "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # 33 + [ "$status" -ne 0 ] +} + +@test "rejects reserved names" { + for name in sv app-provider app-user postgres splice canton nginx scan keycloak; do + run validate_validator_name "$name" + [ "$status" -ne 0 ] + [[ "$output" == *"reserved"* ]] + done +} diff --git a/tests/unit/test_wait_ready.bats b/tests/unit/test_wait_ready.bats new file mode 100644 index 0000000..7f510c5 --- /dev/null +++ b/tests/unit/test_wait_ready.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# Behavior of _wait_for_custom_ready and the failure-log dump. +# sleep is stubbed to a no-op so tests run instantly; _is_healthy is stubbed +# to return controlled responses via a counter file. +load ../test_helper + +setup() { + TMPHOME="$(mktemp -d)" + export TMPHOME + export CANTON_DEVREL_DIR="$TMPHOME" + export REPO_DIR + mkdir -p "$TMPHOME/stubs" + export PATH="$TMPHOME/stubs:$PATH" + # Source the validator lib so we can call _wait_for_custom_ready directly. + source "$REPO_DIR/scripts/lib/common.sh" + source "$REPO_DIR/scripts/lib/validator.sh" + # Stub sleep so the test runs in milliseconds, not minutes. + eval 'sleep() { return 0; }' +} +teardown() { rm -rf "$TMPHOME"; } + +@test "default timeout is 300s (60 attempts of 5s)" { + # _is_healthy always fails → loop should run exactly DEFAULT_ATTEMPTS times. + local hit_count_file="$TMPHOME/healthchecks" + echo 0 >"$hit_count_file" + eval '_is_healthy() { + local n; n=$(cat "'"$hit_count_file"'"); n=$((n + 1)); echo "$n" >"'"$hit_count_file"'" + return 1 + }' + run _wait_for_custom_ready acme 5900 + [ "$status" -eq 1 ] + local hits; hits=$(cat "$hit_count_file") + [ "$hits" -eq 60 ] +} + +@test "CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S overrides the default" { + export CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S=30 + local hit_count_file="$TMPHOME/healthchecks" + echo 0 >"$hit_count_file" + eval '_is_healthy() { + local n; n=$(cat "'"$hit_count_file"'"); n=$((n + 1)); echo "$n" >"'"$hit_count_file"'" + return 1 + }' + run _wait_for_custom_ready acme 5900 + [ "$status" -eq 1 ] + local hits; hits=$(cat "$hit_count_file") + # 30s / 5s = 6 attempts + [ "$hits" -eq 6 ] + unset CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S +} + +@test "returns 0 as soon as readyz returns healthy" { + export CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S=300 + local hit_count_file="$TMPHOME/healthchecks" + echo 0 >"$hit_count_file" + # Healthy on the 3rd call. + eval '_is_healthy() { + local n; n=$(cat "'"$hit_count_file"'"); n=$((n + 1)); echo "$n" >"'"$hit_count_file"'" + [ "$n" -ge 3 ] + }' + run _wait_for_custom_ready acme 5900 + [ "$status" -eq 0 ] + local hits; hits=$(cat "$hit_count_file") + [ "$hits" -eq 3 ] +} + +@test "prints a progress line at least every ~30s during the wait" { + export CANTON_DEVREL_VALIDATOR_READY_TIMEOUT_S=60 + eval '_is_healthy() { return 1; }' + run _wait_for_custom_ready acme 5900 + [ "$status" -eq 1 ] + # Expect at least one progress line. Match the keyword we'll emit. + echo "$output" | grep -q "still waiting" +} + +@test "_dump_failure_logs writes a file that survives _undo's rm -rf" { + # Stub docker so the dump call doesn't need a real daemon. + stub_docker + _dump_failure_logs acme + # Path is at the top of CANTON_DEVREL_DIR so it isn't wiped by the rollback + # that does `rm -rf validators//`. + [ -f "$TMPHOME/last-validator-add-failure-acme.log" ] +}