diff --git a/docker/fxsupport/linux/ipfs-cluster/ipfs-cluster-container-init.d.sh b/docker/fxsupport/linux/ipfs-cluster/ipfs-cluster-container-init.d.sh index f635f735..e0b42d3f 100644 --- a/docker/fxsupport/linux/ipfs-cluster/ipfs-cluster-container-init.d.sh +++ b/docker/fxsupport/linux/ipfs-cluster/ipfs-cluster-container-init.d.sh @@ -70,11 +70,19 @@ get_poolcreator_peerid() { while [ $attempt -le $max_attempts ]; do response=$(curl -s --connect-timeout 10 --max-time 15 "${endpoint}" 2>/dev/null) cluster_peer_id=$(echo "$response" | jq -r '."ipfs-cluster-peerid" // empty' 2>/dev/null) + # Federation: prefer the trusted-peer ARRAY if the pool API provides it; fall back to + # the single legacy field. Result is a comma-separated list for CLUSTER_CRDT_TRUSTEDPEERS. + cluster_trusted_csv=$(echo "$response" | jq -r '(."ipfs-cluster-trustedpeers" // []) | map(select(. != null and . != "")) | join(",")' 2>/dev/null) kubo_peer_id=$(echo "$response" | jq -r '."kubo-peerid" // empty' 2>/dev/null) - if [ -n "$cluster_peer_id" ] && [ "$cluster_peer_id" != "null" ]; then - log "Fetched master cluster peer ID: $cluster_peer_id (attempt $attempt)" - export CLUSTER_CRDT_TRUSTEDPEERS="$cluster_peer_id" + resolved_trusted="$cluster_trusted_csv" + if [ -z "$resolved_trusted" ] || [ "$resolved_trusted" = "null" ]; then + resolved_trusted="$cluster_peer_id" + fi + + if [ -n "$resolved_trusted" ] && [ "$resolved_trusted" != "null" ]; then + log "Fetched trusted cluster peers: $resolved_trusted (attempt $attempt)" + export CLUSTER_CRDT_TRUSTEDPEERS="$resolved_trusted" if [ -n "$kubo_peer_id" ] && [ "$kubo_peer_id" != "null" ]; then MASTER_KUBO_PEERID="$kubo_peer_id" log "Fetched master kubo peer ID: $kubo_peer_id" @@ -182,6 +190,11 @@ append_or_replace "/.env.cluster" "CLUSTER_PEERNAME" "${CLUSTER_PEERNAME}" get_poolcreator_peerid append_or_replace "/.env.cluster" "CLUSTER_CRDT_TRUSTEDPEERS" "${CLUSTER_CRDT_TRUSTEDPEERS}" + # Federation: CLUSTER_CRDT_TRUSTEDPEERS may now be a comma-separated set of writers. + # The PRIMARY (first) peer is the bootstrap/tunnel target (the master); single-peer + # multiaddr construction below must use it, never the whole comma-separated list. + PRIMARY_TRUSTED_PEER=$(printf '%s' "${CLUSTER_CRDT_TRUSTEDPEERS}" | cut -d',' -f1) + # Add master's kubo peer to follower's kubo Peering for faster content discovery if [ -n "${MASTER_KUBO_PEERID}" ] && [ "${MASTER_KUBO_PEERID}" != "${ipfs_peer_id}" ]; then # Add direct kubo addresses from API (non-localhost, non-relay) @@ -306,8 +319,8 @@ append_or_replace "/.env.cluster" "CLUSTER_PEERNAME" "${CLUSTER_PEERNAME}" } } | if $trust_peer != "" then - .cluster.peer_addresses = ["/ip4/127.0.0.1/tcp/19096/p2p/" + $trust_peer] - | .consensus.crdt.trusted_peers = [$trust_peer] + .cluster.peer_addresses = ["/ip4/127.0.0.1/tcp/19096/p2p/" + ($trust_peer | split(",")[0])] + | .consensus.crdt.trusted_peers = ($trust_peer | split(",")) else . end ' "${IPFS_CLUSTER_PATH}/service.json" > "$service_temp" \ && [ -s "$service_temp" ] \ @@ -338,7 +351,7 @@ append_or_replace "/.env.cluster" "CLUSTER_PEERNAME" "${CLUSTER_PEERNAME}" append_or_replace "/.env.cluster" "CLUSTER_FOLLOWERMODE" "${CLUSTER_FOLLOWERMODE}" # Construct the DNS fallback address (always reliable) - constructed_addr="/dns4/${poolName}.pools.functionyard.fula.network/tcp/9096/p2p/${CLUSTER_CRDT_TRUSTEDPEERS}" + constructed_addr="/dns4/${poolName}.pools.functionyard.fula.network/tcp/9096/p2p/${PRIMARY_TRUSTED_PEER}" # Populate peerstore and select bootstrap address if [ -n "${CLUSTER_BOOTSTRAP_ADDRS}" ]; then diff --git a/tests/e2e/phase-1/10-master.sh b/tests/e2e/phase-1/10-master.sh new file mode 100644 index 00000000..8761275f --- /dev/null +++ b/tests/e2e/phase-1/10-master.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# +# fxe2e Phase-1 e2e — role: SIMULATED MASTER (test server only, NEVER production). +# +# Provisions an isolated test cluster master that mirrors the production master's +# SHAPE (systemd unit -> docker run, env-driven, CLUSTER_CRDT_TRUSTEDPEERS on both the +# Environment= line and the ExecStart -e flag) but with prefixed names + shifted ports +# so the REAL phase-1-setup-writer.sh can run with its defaults on the same box. +# Idempotent: re-run safe. Cluster name carries a random-but-fixed suffix so the +# derived secret is not guessable on a public test box. +# +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +. "$HERE/../../../update-scripts/lib/phase-common.sh" +PC_TAG="fxe2e-master" + +CLUSTERNAME="${FXE2E_CLUSTERNAME:-fxe2e-vt9q4z}" +SECRET="$(printf '%s' "$CLUSTERNAME" | sha256sum | cut -d' ' -f1)" +BASE=/opt/fxe2e/master; KUBO_DIR="$BASE/kubo"; CL_DIR="$BASE/cluster" +KUBO_IMAGE="${KUBO_IMAGE:-ipfs/kubo:release}" +CLUSTER_IMAGE="${CLUSTER_IMAGE:-ipfs/ipfs-cluster:stable}" +[ "$(id -u)" = 0 ] || die "run as root" +pc_have docker || die "docker required (run phase-1-setup-writer.sh first or install docker)" +mkdir -p "$KUBO_DIR" "$CL_DIR" + +# ---- kubo (shifted ports: swarm 14001, API 127.0.0.1:15001, gw 127.0.0.1:18080) ---- +# --entrypoint ipfs bypasses the image's auto-init entrypoint (double-init bug); config is +# applied only on FRESH init (one-shot `ipfs config` needs the repo lock — re-runs with the +# daemon up would fail; port changes require stopping the unit + wiping is fine: TEST ONLY). +k() { docker run --rm --entrypoint ipfs -e IPFS_PATH=/data/ipfs -v "$KUBO_DIR":/data/ipfs "$KUBO_IMAGE" "$@"; } +if [ -f "$KUBO_DIR/config" ]; then info "master kubo repo exists — skip init+config" +else + info "init master kubo" + k init --profile=server >/dev/null + k config Addresses.API /ip4/127.0.0.1/tcp/15001 >/dev/null + k config Addresses.Gateway /ip4/127.0.0.1/tcp/18080 >/dev/null + k config --json Addresses.Swarm '["/ip4/0.0.0.0/tcp/14001"]' >/dev/null +fi +MASTER_KUBO_PEERID="$(jq -r '.Identity.PeerID // empty' "$KUBO_DIR/config")"; [ -n "$MASTER_KUBO_PEERID" ] || die "no master kubo peer id" + +ku_ch="$(cat </dev/null 2>&1 && break; [ "$i" = 30 ] && die "master kubo not healthy on :15001"; sleep 3; done +info "master kubo healthy ($MASTER_KUBO_PEERID)" + +# ---- cluster (shifted ports: swarm 19096, REST 127.0.0.1:19094, proxy 127.0.0.1:19095) ---- +cl() { docker run --rm -e IPFS_CLUSTER_PATH=/data/ipfs-cluster -e CLUSTER_SECRET="$SECRET" -v "$CL_DIR":/data/ipfs-cluster --entrypoint ipfs-cluster-service "$CLUSTER_IMAGE" "$@"; } +if [ -f "$CL_DIR/identity.json" ]; then info "master cluster identity exists — skip init"; else info "init master cluster"; cl init >/dev/null 2>&1 || cl init >/dev/null; fi +MASTER_CLUSTER_PEERID="$(jq -r '.id' "$CL_DIR/identity.json")" +{ [ -n "$MASTER_CLUSTER_PEERID" ] && [ "$MASTER_CLUSTER_PEERID" != null ]; } || die "no master cluster peer id" + +# Trust line mirrors prod shape: present on BOTH Environment= and ExecStart -e so +# phase-1-master-trust.sh (UNIT_PATH/SERVICE_NAME overrides) edits it exactly like prod. +# Re-runs PRESERVE the current trusted list (master-trust may have appended writers — +# regenerating from the template must never revert that). +TRUST_LINE="$MASTER_CLUSTER_PEERID" +if [ -f /etc/systemd/system/fxe2e-master-ipfscluster.service ]; then + cur_trust="$(grep -oE 'CLUSTER_CRDT_TRUSTEDPEERS=[^" ]+' /etc/systemd/system/fxe2e-master-ipfscluster.service | head -1 | cut -d= -f2- || true)" + [ -n "$cur_trust" ] && TRUST_LINE="$cur_trust" +fi +cl_ch="$(cat </dev/null 2>&1 && break; [ "$i" = 30 ] && die "master cluster API not healthy on :19094"; sleep 3; done +info "master cluster healthy" + +PUB_IP="${PUBLIC_HOST:-$(hostname -I | awk '{print $1}')}" +cat </dev/null + docker run --rm --entrypoint ipfs -e IPFS_PATH=/data/ipfs -v "$kdir":/data/ipfs "$KUBO_IMAGE" config Addresses.API "/ip4/127.0.0.1/tcp/$kapi" >/dev/null + docker run --rm --entrypoint ipfs -e IPFS_PATH=/data/ipfs -v "$kdir":/data/ipfs "$KUBO_IMAGE" config Addresses.Gateway "/ip4/127.0.0.1/tcp/$gw" >/dev/null + docker run --rm --entrypoint ipfs -e IPFS_PATH=/data/ipfs -v "$kdir":/data/ipfs "$KUBO_IMAGE" config --json Addresses.Swarm "[\"/ip4/0.0.0.0/tcp/$kswarm\"]" >/dev/null + fi + + if [ ! -f "$cdir/identity.json" ]; then + info "[$name] init cluster" + docker run --rm -e IPFS_CLUSTER_PATH=/data/ipfs-cluster -e CLUSTER_SECRET="$SECRET" -v "$cdir":/data/ipfs-cluster --entrypoint ipfs-cluster-service "$CLUSTER_IMAGE" init >/dev/null 2>&1 || true + fi + printf '/ip4/127.0.0.1/tcp/19096/p2p/%s\n' "$MASTER_ID" > "$cdir/peerstore" + case ",$trusted," in *",$WRITER_ID,"*) printf '/ip4/127.0.0.1/tcp/9096/p2p/%s\n' "$WRITER_ID" >> "$cdir/peerstore";; esac + + docker rm -f "fxe2e_${name}_ipfs" "fxe2e_${name}_cluster" >/dev/null 2>&1 || true + docker run -d --restart unless-stopped --name "fxe2e_${name}_ipfs" --network host -e IPFS_PATH=/data/ipfs -v "$kdir":/data/ipfs "$KUBO_IMAGE" >/dev/null + for i in $(seq 1 30); do curl -s -X POST "http://127.0.0.1:$kapi/api/v0/id" >/dev/null 2>&1 && break; [ "$i" = 30 ] && die "[$name] kubo not healthy on :$kapi"; sleep 3; done + + docker run -d --restart unless-stopped --name "fxe2e_${name}_cluster" --network host \ + -e IPFS_CLUSTER_PATH=/data/ipfs-cluster \ + -e CLUSTER_SECRET="$SECRET" \ + -e CLUSTER_CLUSTERNAME="$CLUSTERNAME" \ + -e CLUSTER_FOLLOWERMODE=true \ + -e CLUSTER_CRDT_TRUSTEDPEERS="$trusted" \ + -e CLUSTER_LISTENMULTIADDRESS="/ip4/0.0.0.0/tcp/$clswarm" \ + -e CLUSTER_RESTAPI_HTTPLISTENMULTIADDRESS="/ip4/127.0.0.1/tcp/$clrest" \ + -e CLUSTER_IPFSPROXY_LISTENMULTIADDRESS="/ip4/127.0.0.1/tcp/$proxy" \ + -e CLUSTER_PINSVCAPI_HTTPLISTENMULTIADDRESS="/ip4/127.0.0.1/tcp/$pinsvc" \ + -e CLUSTER_IPFSHTTP_NODEMULTIADDRESS="/ip4/127.0.0.1/tcp/$kapi" \ + -e CLUSTER_REPLICATIONFACTORMIN=2 -e CLUSTER_REPLICATIONFACTORMAX=4 \ + -e CLUSTER_DISABLEREPINNING=false \ + -e CLUSTER_PEERNAME="$peername" \ + -e CLUSTER_MONITORPINGINTERVAL=15s \ + -v "$cdir":/data/ipfs-cluster \ + "$CLUSTER_IMAGE" daemon --upgrade --bootstrap "/ip4/127.0.0.1/tcp/19096/p2p/$MASTER_ID" >/dev/null + for i in $(seq 1 30); do docker exec "fxe2e_${name}_cluster" ipfs-cluster-ctl --host "/ip4/127.0.0.1/tcp/$clrest" id >/dev/null 2>&1 && break; [ "$i" = 30 ] && die "[$name] cluster API not healthy on :$clrest"; sleep 3; done + info "[$name] healthy (trusts: $trusted)" + + # Deterministic bitswap on one box: connect this follower's kubo to master + writer kubo. + docker exec "fxe2e_${name}_ipfs" ipfs swarm connect "/ip4/127.0.0.1/tcp/14001/p2p/$MASTER_KUBO_ID" >/dev/null 2>&1 || true + [ -n "$WRITER_KUBO_ID" ] && docker exec "fxe2e_${name}_ipfs" ipfs swarm connect "/ip4/127.0.0.1/tcp/4001/p2p/$WRITER_KUBO_ID" >/dev/null 2>&1 || true +} + +mk_follower fA "$MASTER_ID,$WRITER_ID" 24001 25001 29096 29094 fxe2e-follower-new +mk_follower fB "$MASTER_ID" 34001 35001 39096 39094 fxe2e-follower-old + +cat < both followers pin it (old + new) +# D2 master DOWN : pin via WRITER -> updated follower pins it; OLD follower keeps +# serving existing pins and does NOT see the writer pin (mixed fleet) +# D3 master BACK : CRDT reconverges — master learns the writer-era pin; nothing lost +# D4 idempotency : re-run setup-writer + master-trust -> no changes, peerset stable +# +# Production-grade: every assert polls with a deadline; any FAIL exits 1. +# +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" + +PASS=0; FAIL=0 +ok() { echo "ok - $1"; PASS=$((PASS+1)); } +bad() { echo "FAIL - $1"; FAIL=$((FAIL+1)); } + +mctl() { docker exec fxe2e_m_cluster ipfs-cluster-ctl --host /ip4/127.0.0.1/tcp/19094 "$@"; } +wctl() { docker exec ipfs_cluster ipfs-cluster-ctl "$@"; } +actl() { docker exec fxe2e_fA_cluster ipfs-cluster-ctl --host /ip4/127.0.0.1/tcp/29094 "$@"; } +bctl() { docker exec fxe2e_fB_cluster ipfs-cluster-ctl --host /ip4/127.0.0.1/tcp/39094 "$@"; } + +A_ID="$(jq -r '.id' /opt/fxe2e/fA/cluster/identity.json)" +B_ID="$(jq -r '.id' /opt/fxe2e/fB/cluster/identity.json)" +M_ID="$(jq -r '.id' /opt/fxe2e/master/cluster/identity.json)" +# shellcheck disable=SC1091 +W_ID="$(. /opt/fula-writer/.env; printf '%s' "$NEW_CLUSTER_PEERID")" + +# poll JSON status until .peer_map[peerid].status == "pinned" (deadline secs) +pin_state() { "$1" --enc=json status "$2" 2>/dev/null | jq -r --arg p "$3" '.peer_map[$p].status // "absent"' 2>/dev/null || echo absent; } +wait_pinned() { # $1=ctlfn $2=cid $3=peerid $4=deadline $5=label + local t=0 + while [ "$t" -lt "$4" ]; do + [ "$(pin_state "$1" "$2" "$3")" = "pinned" ] && { ok "$5"; return 0; } + sleep 5; t=$((t+5)) + done + bad "$5 (timeout ${4}s)"; "$1" status "$2" 2>/dev/null | sed 's/^/ /' | head -8; return 1 +} + +echo "== D0 topology ==" +for i in $(seq 1 24); do + n="$(mctl peers ls 2>/dev/null | grep -c '^12D3KooW' || true)" + [ "${n:-0}" -ge 4 ] && break; sleep 5 +done +n="$(mctl peers ls 2>/dev/null | grep -c '^12D3KooW' || true)" +[ "${n:-0}" -ge 4 ] && ok "D0 master sees >=4 cluster peers ($n)" || bad "D0 master sees $n peers (want >=4)" + +echo "== D1 baseline: pin via MASTER reaches old+new followers ==" +CID1="$(echo "fxe2e-baseline-$(date +%s)" | docker exec -i fxe2e_m_ipfs ipfs add -q)" +[ -n "$CID1" ] && ok "D1 content added on master kubo ($CID1)" || bad "D1 could not add content" +mctl pin add "$CID1" >/dev/null 2>&1 || bad "D1 master pin add failed" +wait_pinned actl "$CID1" "$A_ID" 180 "D1 follower A (updated) pinned baseline CID" +wait_pinned bctl "$CID1" "$B_ID" 180 "D1 follower B (old) pinned baseline CID" + +PRE_DOWN_MASTER_PINS="$(mctl status --filter pinned 2>/dev/null | grep -c '^[A-Za-z0-9]' || true)" + +echo "== D2 master DOWN: writer keeps the network writable; old follower unaffected ==" +systemctl stop fxe2e-master-ipfscluster.service +sleep 3 +CID2="$(echo "fxe2e-writer-era-$(date +%s)" | docker exec -i ipfs_host ipfs add -q)" +wctl pin add "$CID2" >/dev/null 2>&1 && ok "D2 pin add via WRITER succeeded with master down" || bad "D2 writer pin add failed" +wait_pinned actl "$CID2" "$A_ID" 240 "D2 follower A (updated) pinned writer-era CID with master DOWN" +if [ "$(pin_state bctl "$CID2" "$B_ID")" = "pinned" ]; then + bad "D2 follower B (old) unexpectedly pinned a writer-issued CID (should not trust writer)" +else + ok "D2 follower B (old) does NOT see writer-issued pin (expected mixed-fleet behavior)" +fi +[ "$(pin_state bctl "$CID1" "$B_ID")" = "pinned" ] \ + && ok "D2 follower B (old) still serves its existing pin during master outage" \ + || bad "D2 follower B (old) lost its existing pin" + +echo "== D3 master BACK: CRDT reconverges, nothing lost ==" +systemctl start fxe2e-master-ipfscluster.service +for i in $(seq 1 30); do docker exec fxe2e_m_cluster ipfs-cluster-ctl --host /ip4/127.0.0.1/tcp/19094 id >/dev/null 2>&1 && break; sleep 5; done +t=0; got="" +while [ "$t" -lt 300 ]; do + if mctl status "$CID2" 2>/dev/null | grep -qi 'PINNED'; then got=1; break; fi + sleep 10; t=$((t+10)) +done +[ -n "$got" ] && ok "D3 master converged to writer-era pin after restart" || bad "D3 master never learned writer-era pin (300s)" +POST_UP_MASTER_PINS="$(mctl status --filter pinned 2>/dev/null | grep -c '^[A-Za-z0-9]' || true)" +[ "${POST_UP_MASTER_PINS:-0}" -ge "${PRE_DOWN_MASTER_PINS:-0}" ] \ + && ok "D3 pinset never shrank (pre=$PRE_DOWN_MASTER_PINS post=$POST_UP_MASTER_PINS)" \ + || bad "D3 pinset shrank (pre=$PRE_DOWN_MASTER_PINS post=$POST_UP_MASTER_PINS)" +wait_pinned bctl "$CID1" "$B_ID" 90 "D3 follower B (old) still healthy after master bounce" + +echo "== D4 idempotency: re-runs are no-ops ==" +if (cd "$HERE/../../../update-scripts" && bash phase-1-setup-writer.sh >/tmp/fxe2e-rerun-writer.log 2>&1); then + grep -qiE 'unchanged|skip' /tmp/fxe2e-rerun-writer.log && ok "D4 setup-writer re-run: no-op paths taken" || ok "D4 setup-writer re-run exited 0" +else + bad "D4 setup-writer re-run failed (see /tmp/fxe2e-rerun-writer.log)" +fi +if UNIT_PATH=/etc/systemd/system/fxe2e-master-ipfscluster.service SERVICE_NAME=fxe2e-master-ipfscluster \ + ENV_FILE=/opt/fxe2e/master-trust.env NEW_WRITER_PEERID="$W_ID" \ + bash "$HERE/../../../update-scripts/phase-1-master-trust.sh" >/tmp/fxe2e-rerun-trust.log 2>&1; then + grep -qi 'already trusted' /tmp/fxe2e-rerun-trust.log && ok "D4 master-trust re-run: already-trusted no-op" || ok "D4 master-trust re-run exited 0" +else + bad "D4 master-trust re-run failed (see /tmp/fxe2e-rerun-trust.log)" +fi +n2="$(mctl peers ls 2>/dev/null | grep -c '^12D3KooW' || true)" +[ "${n2:-0}" -ge 4 ] && ok "D4 peerset stable after re-runs ($n2)" || bad "D4 peerset shrank ($n2)" + +echo +echo "RESULT: pass=$PASS fail=$FAIL" +[ "$FAIL" = 0 ] || exit 1 diff --git a/tests/test-cluster-federation-parse.sh b/tests/test-cluster-federation-parse.sh new file mode 100755 index 00000000..509c4892 --- /dev/null +++ b/tests/test-cluster-federation-parse.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Test the Phase 1 federation parse logic used by ipfs-cluster-container-init.d.sh: +# - prefer the ipfs-cluster-trustedpeers ARRAY (join as CSV), filtering empty/null +# - fall back to the single ipfs-cluster-peerid when the array is absent +# - PRIMARY = first element of the CSV (bootstrap/tunnel target) +# - jq split(",") rebuilds the array and split(",")[0] = primary (mirrors the +# service.json jq: trusted_peers = ($trust_peer|split(",")), peer_addresses uses [0]) +set -euo pipefail +command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed (runs on edge/CI where jq is present)"; exit 0; } + +A="12D3KooWMASTERaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +B="12D3KooWWRITERbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +ARR_JQ='(."ipfs-cluster-trustedpeers" // []) | map(select(. != null and . != "")) | join(",")' +fail=0 +pass() { echo "ok - $1"; } +bad() { echo "FAIL - $1: expected [$3] got [$2]"; fail=1; } +eq() { [ "$2" = "$3" ] && pass "$1" || bad "$1" "$2" "$3"; } + +# array present -> CSV +csv="$(echo "{\"ipfs-cluster-peerid\":\"$A\",\"ipfs-cluster-trustedpeers\":[\"$A\",\"$B\"]}" | jq -r "$ARR_JQ")" +eq "array -> csv" "$csv" "$A,$B" + +# array absent -> fall back to single peerid +resp2="{\"ipfs-cluster-peerid\":\"$A\"}" +csv2="$(echo "$resp2" | jq -r "$ARR_JQ")" +resolved="$csv2" +if [ -z "$resolved" ] || [ "$resolved" = "null" ]; then resolved="$(echo "$resp2" | jq -r '."ipfs-cluster-peerid" // empty')"; fi +eq "fallback to single" "$resolved" "$A" + +# array filters empty/null entries +csv3="$(echo "{\"ipfs-cluster-trustedpeers\":[\"$A\",\"\",null,\"$B\"]}" | jq -r "$ARR_JQ")" +eq "filter empty/null" "$csv3" "$A,$B" + +# PRIMARY = first of CSV +eq "primary = first" "$(printf '%s' "$A,$B" | cut -d',' -f1)" "$A" + +# service.json jq behaviour: split rebuilds array, [0] is primary +eq "split -> array" "$(printf '%s' "$A,$B" | jq -Rc 'split(",")')" "[\"$A\",\"$B\"]" +eq "split[0] = primary" "$(printf '%s' "$A,$B" | jq -rR 'split(",")[0]')" "$A" + +# single value stays a 1-element array (backward-compat) +eq "single -> 1-elem array" "$(printf '%s' "$A" | jq -Rc 'split(",")')" "[\"$A\"]" + +[ "$fail" = "0" ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; } diff --git a/tests/test-phase-1-master-trust.sh b/tests/test-phase-1-master-trust.sh new file mode 100755 index 00000000..24d08ffa --- /dev/null +++ b/tests/test-phase-1-master-trust.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Tests phase-1-master-trust.sh against a fixture systemd unit (NO_RESTART=1, no docker). +# Verifies additive append to both lines, backup, idempotency, halt/validation, and +# re-run-reuses-saved-peer-id. ENV_FILE points at temp files. +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/../update-scripts/phase-1-master-trust.sh" +[ -f "$SCRIPT" ] || { echo "FAIL: not found $SCRIPT"; exit 1; } +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +UNIT="$TMP/ipfscluster.service" +MASTER="12D3KooWS79EhkPU7ESUwgG4vyHHzW9FDNZLoWVth9b5N5NSrvaj" +NEW="12D3KooWNEWwriter00000000000000000000000000000000000001" +fresh_unit() { cat > "$UNIT" </dev/null +grep -q "Environment=\"CLUSTER_CRDT_TRUSTEDPEERS=$MASTER,$NEW\"" "$UNIT" && pass "Environment= appended" || bad "Environment= appended" +grep -q -- "-e CLUSTER_CRDT_TRUSTEDPEERS=$MASTER,$NEW " "$UNIT" && pass "ExecStart -e appended" || bad "ExecStart -e appended" +ls "$UNIT".bak.* >/dev/null 2>&1 && pass "backup created" || bad "backup created" + +NEW_WRITER_PEERID="$NEW" NO_RESTART=1 UNIT_PATH="$UNIT" ENV_FILE="$TMP/a.env" bash "$SCRIPT" >/dev/null +occ="$(grep -c "CLUSTER_CRDT_TRUSTEDPEERS=$MASTER,$NEW" "$UNIT" || true)" +[ "$occ" = 2 ] && pass "idempotent (no double append)" || bad "idempotent (got $occ, want 2)" + +if NO_RESTART=1 UNIT_PATH="$UNIT" ENV_FILE="$TMP/halt.env" bash "$SCRIPT" >/dev/null 2>&1; then bad "halts without peer id"; else pass "halts without peer id"; fi +if NEW_WRITER_PEERID="not-a-peer" NO_RESTART=1 UNIT_PATH="$UNIT" ENV_FILE="$TMP/bad.env" bash "$SCRIPT" >/dev/null 2>&1; then bad "rejects bad peer id"; else pass "rejects bad peer id"; fi + +# re-run with NO peer id supplied -> reuses the saved one from .env (else it would halt) +fresh_unit +NEW_WRITER_PEERID="$NEW" NO_RESTART=1 UNIT_PATH="$UNIT" ENV_FILE="$TMP/s.env" bash "$SCRIPT" >/dev/null +if NO_RESTART=1 UNIT_PATH="$UNIT" ENV_FILE="$TMP/s.env" bash "$SCRIPT" >/dev/null 2>&1 && grep -q "CLUSTER_CRDT_TRUSTEDPEERS=$MASTER,$NEW" "$UNIT"; then pass "re-run reuses saved peer id"; else bad "re-run reuses saved peer id"; fi + +[ "$fail" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; } diff --git a/tests/test-phase-1-setup-writer.sh b/tests/test-phase-1-setup-writer.sh new file mode 100755 index 00000000..8548b44e --- /dev/null +++ b/tests/test-phase-1-setup-writer.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Dry-run tests for phase-1-setup-writer.sh (no docker/root/network: DRY_RUN=1 + master +# info supplied). Verifies validation, ip4/dns4 detection, secret, zero side effects, +# .env persistence, and re-run-reuses-saved-value. ENV_FILE points at temp files. +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/../update-scripts/phase-1-setup-writer.sh" +[ -f "$SCRIPT" ] || { echo "FAIL: not found $SCRIPT"; exit 1; } +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +M="12D3KooWMasterAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +BS="/dns4/1.pools.functionyard.fula.network/tcp/9096/p2p/$M" +fail=0; pass(){ echo "ok - $1"; }; bad(){ echo "FAIL - $1"; fail=1; } + +run() { DRY_RUN=1 ENV_FILE="$1" PUBLIC_HOST="$2" BASE_DIR="$3" MASTER_CLUSTER_PEERID="$M" MASTER_CLUSTER_BOOTSTRAP="$BS" bash "$SCRIPT" 2>&1; } + +out="$(run "$TMP/a.env" 1.2.3.4 "$TMP/w")"; rc=$? +[ "$rc" = 0 ] && pass "dry-run exits 0" || bad "dry-run exits 0 (rc=$rc)" +echo "$out" | grep -q "/ip4" && pass "ipv4 announce" || bad "ipv4 announce" +echo "$out" | grep -q "no system changes" && pass "declares no changes" || bad "declares no changes" +echo "$out" | grep -q "secret=" && pass "derives secret" || bad "derives secret" +[ ! -d "$TMP/w" ] && pass "no base dir created in dry-run" || bad "no base dir created in dry-run" +[ -f "$TMP/a.env" ] && pass "saves params to .env" || bad "saves params to .env" + +out2="$(run "$TMP/b.env" writer.example.com "$TMP/w2")" +echo "$out2" | grep -q "/dns4" && pass "dns4 announce" || bad "dns4 announce" + +# re-run with NO PUBLIC_HOST supplied -> reuses the saved value from a.env +out3="$(DRY_RUN=1 ENV_FILE="$TMP/a.env" MASTER_CLUSTER_PEERID="$M" MASTER_CLUSTER_BOOTSTRAP="$BS" bash "$SCRIPT" 2>&1)"; rc3=$? +{ [ "$rc3" = 0 ] && echo "$out3" | grep -q "1.2.3.4"; } && pass "re-run reuses saved PUBLIC_HOST" || bad "re-run reuses saved PUBLIC_HOST" + +# halts without PUBLIC_HOST (fresh env, nothing supplied) +if DRY_RUN=1 ENV_FILE="$TMP/halt.env" MASTER_CLUSTER_PEERID="$M" MASTER_CLUSTER_BOOTSTRAP="$BS" bash "$SCRIPT" >/dev/null 2>&1; then bad "halts without PUBLIC_HOST"; else pass "halts without PUBLIC_HOST"; fi + +[ "$fail" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; } diff --git a/tests/test-phase-common.sh b/tests/test-phase-common.sh new file mode 100755 index 00000000..9f11f247 --- /dev/null +++ b/tests/test-phase-common.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Tests the shared phase-common helpers: env save/load (+ precedence), interactive +# prompt-with-default (forced via PC_FORCE_INTERACTIVE + piped stdin), non-interactive +# required/validation, and write-if-changed idempotency + backup. +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +LIB="$HERE/../update-scripts/lib/phase-common.sh" +[ -f "$LIB" ] || { echo "FAIL: lib not found $LIB"; exit 1; } +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +fail=0; pass(){ echo "ok - $1"; }; bad(){ echo "FAIL - $1"; fail=1; } + +# shellcheck disable=SC1090 +. "$LIB" # note: die() exits, so negative cases run in subshells + +# save/load round-trip +FOO=hello; BAR=world +pc_save_env "$TMP/p.env" FOO BAR >/dev/null +unset FOO BAR +pc_load_env "$TMP/p.env" >/dev/null +{ [ "${FOO:-}" = hello ] && [ "${BAR:-}" = world ]; } && pass "save/load round-trip" || bad "save/load round-trip" + +# CLI/env value beats a saved .env value +FOO=cli; pc_load_env "$TMP/p.env" >/dev/null +[ "$FOO" = cli ] && pass "env beats saved .env" || bad "env beats saved .env (got $FOO)" + +# non-interactive: missing required -> die +( unset BAZ; PC_FORCE_INTERACTIVE=0; pc_prompt BAZ "Baz" >/dev/null 2>&1 ) && bad "noninteractive missing -> die" || pass "noninteractive missing -> die" +# non-interactive: present + valid -> kept +( QUX=12D3KooWabc; PC_FORCE_INTERACTIVE=0; pc_prompt QUX "Qux" '^12D3KooW' >/dev/null 2>&1 && [ "$QUX" = 12D3KooWabc ] ) && pass "noninteractive valid kept" || bad "noninteractive valid kept" +# non-interactive: present + invalid -> die +( BADV=nope; PC_FORCE_INTERACTIVE=0; pc_prompt BADV "Badv" '^12D3KooW' >/dev/null 2>&1 ) && bad "noninteractive invalid -> die" || pass "noninteractive invalid -> die" + +# interactive (forced) empty input keeps current default +out="$(printf '\n' | PC_FORCE_INTERACTIVE=1 bash -c '. "'"$LIB"'"; CUR=keepme; pc_prompt CUR "Cur"; echo "VAL=$CUR"' 2>/dev/null)" +echo "$out" | grep -q "VAL=keepme" && pass "interactive empty keeps default" || bad "interactive empty keeps default ($out)" +# interactive new value overrides +out2="$(printf 'newval\n' | PC_FORCE_INTERACTIVE=1 bash -c '. "'"$LIB"'"; CUR=old; pc_prompt CUR "Cur"; echo "VAL=$CUR"' 2>/dev/null)" +echo "$out2" | grep -q "VAL=newval" && pass "interactive new value used" || bad "interactive new value used ($out2)" + +# write-if-changed: changed -> unchanged -> changed(+backup) +f="$TMP/u.conf" +[ "$(printf 'A\n' | pc_write_if_changed "$f")" = changed ] && pass "write: first=changed" || bad "write: first=changed" +[ "$(printf 'A\n' | pc_write_if_changed "$f")" = unchanged ] && pass "write: same=unchanged" || bad "write: same=unchanged" +r3="$(printf 'B\n' | pc_write_if_changed "$f")" +{ [ "$r3" = changed ] && ls "$f".bak.* >/dev/null 2>&1; } && pass "write: diff=changed+backup" || bad "write: diff=changed+backup ($r3)" + +[ "$fail" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; } diff --git a/update-scripts/lib/phase-common.sh b/update-scripts/lib/phase-common.sh new file mode 100755 index 00000000..c6350399 --- /dev/null +++ b/update-scripts/lib/phase-common.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Shared helpers for fula phase install/update scripts — idempotent + re-runnable. +# +# Source it from a phase script: +# SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; . "$SCRIPT_DIR/lib/phase-common.sh" +# +# Behaviour it gives every phase script: +# - pc_load_env "$ENV_FILE" : prior saved params become defaults (a CLI/env value +# always wins over a saved one). +# - pc_prompt VAR "Label" [regex] [secret] : asks INTERACTIVELY (a TTY, or +# PC_FORCE_INTERACTIVE=1) showing the current/saved value +# as the default; pressing Enter keeps it. NON-interactive +# (no TTY, e.g. CI/cron): uses the env/.env value, or dies +# if a required value is missing (never guesses). +# - pc_save_env "$ENV_FILE" VAR... : persist chosen params for the next run. +# - pc_write_if_changed PATH : write stdin to PATH only if different (backs up first), +# so re-runs don't needlessly restart services. +# - pc_have / detection helpers : skip work that's already done; never panic. + +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo "[${PC_TAG:-phase}] $*"; } +pc_have() { command -v "$1" >/dev/null 2>&1; } +pc_is_interactive() { [ -t 0 ] || [ "${PC_FORCE_INTERACTIVE:-0}" = "1" ]; } + +pc_load_env() { + local f="${1:-}"; [ -n "$f" ] && [ -f "$f" ] || return 0 + local k v + while IFS='=' read -r k v; do + case "$k" in ''|\#*) continue ;; esac + v="${v%\"}"; v="${v#\"}" + # only fill if not already set in the environment — a CLI/env value wins + if [ -z "${!k:-}" ]; then printf -v "$k" '%s' "$v"; export "$k"; fi + done < "$f" + info "loaded saved params from $f" +} + +pc_save_env() { + local f="${1:-}"; shift || true + [ -n "$f" ] || return 0 + mkdir -p "$(dirname "$f")" + local tmp="${f}.tmp.$$" v + { + echo "# fula phase params — auto-saved; safe to edit. Re-running reuses these as defaults." + for v in "$@"; do printf '%s=%s\n' "$v" "${!v:-}"; done + } > "$tmp" + chmod 600 "$tmp" 2>/dev/null || true + mv "$tmp" "$f" + info "saved params to $f" +} + +# pc_prompt VAR "Label" [validation-regex] [secret] +pc_prompt() { + local var="$1" label="$2" regex="${3:-}" secret="${4:-}" + local cur input val + cur="${!var:-}"; val="$cur"; input="" + if pc_is_interactive; then + while :; do + input="" + if [ -n "$secret" ]; then + printf '%s%s: ' "$label" "${cur:+ [keep current]}" >&2; read -r -s input || true; echo >&2 + else + printf '%s%s: ' "$label" "${cur:+ [$cur]}" >&2; read -r input || true + fi + [ -z "$input" ] && input="$cur" + if [ -z "$input" ]; then echo " required — please enter a value" >&2; continue; fi + if [ -n "$regex" ] && ! [[ "$input" =~ $regex ]]; then echo " invalid (expected: $regex)" >&2; continue; fi + val="$input"; break + done + else + [ -n "$val" ] || die "$var is required — set it as an env var or run interactively (refusing to guess)." + if [ -n "$regex" ] && ! [[ "$val" =~ $regex ]]; then die "$var='$val' is invalid (expected: $regex)."; fi + fi + printf -v "$var" '%s' "$val"; export "$var" +} + +# pc_write_if_changed PATH (new content on stdin) -> echoes "changed" | "unchanged" +pc_write_if_changed() { + local path="$1" tmp; tmp="$(mktemp)" + cat > "$tmp" + if [ -f "$path" ] && cmp -s "$tmp" "$path"; then rm -f "$tmp"; echo "unchanged"; return 0; fi + [ -f "$path" ] && cp -a "$path" "${path}.bak.$(date +%s)" + mkdir -p "$(dirname "$path")" + mv "$tmp" "$path" + echo "changed" +} + +pc_backup() { [ -f "$1" ] && cp -a "$1" "$1.bak.$(date +%s)" && info "backed up $1"; return 0; } +pc_container_exists() { grep -qx "$1" <<<"$(docker ps -a --format '{{.Names}}' 2>/dev/null)"; } +pc_service_active() { systemctl is-active --quiet "$1" 2>/dev/null; } diff --git a/update-scripts/phase-1-master-trust.sh b/update-scripts/phase-1-master-trust.sh new file mode 100755 index 00000000..8b06a2b0 --- /dev/null +++ b/update-scripts/phase-1-master-trust.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Phase 1 — trust a 2nd cluster WRITER on the master. Idempotent + re-runnable. +# +# Appends NEW_WRITER_PEERID to CLUSTER_CRDT_TRUSTEDPEERS in the master's systemd unit +# (both the Environment= line AND the ExecStart `-e` flag), backs up, reloads, restarts, +# verifies. SAFE: additive only — the cluster datastore/identity/pinset are never touched. +# Run interactively and it asks for the peer id (saved for next time); non-interactive +# uses NEW_WRITER_PEERID from env/.env or halts. +# +# Env: UNIT_PATH (default /etc/systemd/system/ipfscluster.service), SERVICE_NAME (ipfscluster), +# ENV_FILE (default /etc/fula/phase-1-master-trust.env), DRY_RUN=1, NO_RESTART=1 (tests). +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib/phase-common.sh +. "$SCRIPT_DIR/lib/phase-common.sh" +PC_TAG="phase-1-master-trust" + +UNIT_PATH="${UNIT_PATH:-/etc/systemd/system/ipfscluster.service}" +SERVICE_NAME="${SERVICE_NAME:-ipfscluster}" +ENV_FILE="${ENV_FILE:-/etc/fula/phase-1-master-trust.env}" +DRY_RUN="${DRY_RUN:-0}"; NO_RESTART="${NO_RESTART:-0}" +VAR="CLUSTER_CRDT_TRUSTEDPEERS" + +pc_load_env "$ENV_FILE" +pc_prompt NEW_WRITER_PEERID "New writer cluster peer id (12D3KooW...)" '^(12D3KooW|Qm)' + +[ -f "$UNIT_PATH" ] || die "unit file not found: $UNIT_PATH (set UNIT_PATH=... if elsewhere)." +if [ "$NO_RESTART" != 1 ] && [ "$DRY_RUN" != 1 ]; then [ "$(id -u)" = 0 ] || die "must run as root to edit $UNIT_PATH and restart."; fi + +CURRENT="$(grep -oE "${VAR}=[^\" ]+" "$UNIT_PATH" | head -1 | cut -d= -f2- || true)" +[ -n "$CURRENT" ] || die "could not find ${VAR}= in $UNIT_PATH." +info "current ${VAR} = $CURRENT" + +case ",${CURRENT}," in + *",${NEW_WRITER_PEERID},"*) info "already trusted: ${NEW_WRITER_PEERID} — no change."; pc_save_env "$ENV_FILE" NEW_WRITER_PEERID; exit 0 ;; +esac +NEWVAL="${CURRENT},${NEW_WRITER_PEERID}" +info "new ${VAR} = $NEWVAL" +pc_save_env "$ENV_FILE" NEW_WRITER_PEERID + +if [ "$DRY_RUN" = 1 ]; then info "DRY_RUN=1 — would set ${VAR}=${NEWVAL} (Environment= + ExecStart -e). No changes."; exit 0; fi + +BACKUP="${UNIT_PATH}.bak.$(date +%s)"; cp -a "$UNIT_PATH" "$BACKUP"; info "backed up -> $BACKUP" +sed -i "s|${VAR}=${CURRENT}|${VAR}=${NEWVAL}|g" "$UNIT_PATH" +grep -q -- "-e ${VAR}=${NEWVAL}" "$UNIT_PATH" || { cp -a "$BACKUP" "$UNIT_PATH"; die "ExecStart '-e ${VAR}' not updated; restored from $BACKUP."; } +info "updated occurrences: $(grep -c "${VAR}=${NEWVAL}" "$UNIT_PATH" || true) (expect 2)" + +if [ "$NO_RESTART" = 1 ]; then info "NO_RESTART=1 — edited + backed up; skipping restart."; exit 0; fi + +info "daemon-reload + restart $SERVICE_NAME (brief cluster-API blip; datastore/pinset untouched)" +systemctl daemon-reload; systemctl restart "$SERVICE_NAME"; sleep 5 +if systemctl is-active --quiet "$SERVICE_NAME"; then info "OK: $SERVICE_NAME active." +else echo "ROLL BACK: cp -a '$BACKUP' '$UNIT_PATH' && systemctl daemon-reload && systemctl restart $SERVICE_NAME" >&2; die "$SERVICE_NAME not active after restart."; fi +command -v docker >/dev/null 2>&1 && { sleep 3; docker exec ipfs_cluster ipfs-cluster-ctl id >/dev/null 2>&1 && info "cluster API responds" || info "NOTE: cluster API not up yet."; } +cat </dev/null 2>&1 || true + apt-get install -y "$1" >/dev/null 2>&1 || die "failed to install $1" +} + +# ---- gather params (interactive prompts with saved defaults; else env/.env or halt) ---- +pc_prompt PUBLIC_HOST "Public IP or DNS of THIS writer box" +pc_prompt CLUSTERNAME "Cluster/pool name" '^[0-9A-Za-z._-]+$' +[ -n "$POOL_API" ] || POOL_API="https://pools.fx.land/pools/${CLUSTERNAME}" + +if [ "$DRY_RUN" != 1 ]; then [ "$(id -u)" = 0 ] || die "run as root (installs packages + writes systemd units)."; fi + +ensure_pkg curl +ensure_pkg jq +if pc_have docker; then info "docker present — skip" +elif [ "$DRY_RUN" = 1 ]; then info "(dry-run) would install Docker" +else info "installing Docker ..."; curl -fsSL https://get.docker.com | sh || die "Docker install failed"; systemctl enable --now docker || die "could not start docker"; fi + +SECRET="$(printf '%s' "$CLUSTERNAME" | sha256sum | cut -d' ' -f1)" + +# resolve master identity from the pool endpoint unless already provided/saved +if [ -z "$MASTER_CLUSTER_PEERID" ] || [ -z "$MASTER_CLUSTER_BOOTSTRAP" ]; then + if [ "$DRY_RUN" = 1 ] && ! pc_have curl; then info "(dry-run) would read $POOL_API" + else + info "reading master identity from $POOL_API ..." + resp="$(curl -s --max-time 20 "$POOL_API" 2>/dev/null || true)" + if printf '%s' "$resp" | jq -e . >/dev/null 2>&1; then + [ -n "$MASTER_CLUSTER_PEERID" ] || MASTER_CLUSTER_PEERID="$(printf '%s' "$resp" | jq -r '."ipfs-cluster-peerid" // empty')" + [ -n "$MASTER_KUBO_PEERID" ] || MASTER_KUBO_PEERID="$(printf '%s' "$resp" | jq -r '."kubo-peerid" // empty')" + [ -n "$MASTER_CLUSTER_BOOTSTRAP" ] || MASTER_CLUSTER_BOOTSTRAP="$(printf '%s' "$resp" | jq -r '(.ipfs_cluster.addresses // [])[] | select(test("/tcp/"))' | head -1)" + [ -n "$MASTER_CLUSTER_BOOTSTRAP" ] || MASTER_CLUSTER_BOOTSTRAP="$(printf '%s' "$resp" | jq -r '(.ipfs_cluster.addresses // [])[0] // empty')" + fi + fi +fi +pc_prompt MASTER_CLUSTER_PEERID "Master cluster peer id" '^(12D3KooW|Qm)' +pc_prompt MASTER_CLUSTER_BOOTSTRAP "Master cluster bootstrap multiaddr" '^/' + +if [[ "$PUBLIC_HOST" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then PROTO=ip4; else PROTO=dns4; fi + +pc_save_env "$ENV_FILE" PUBLIC_HOST CLUSTERNAME POOL_API BASE_DIR KUBO_IMAGE CLUSTER_IMAGE REPL_MIN REPL_MAX MASTER_CLUSTER_PEERID MASTER_CLUSTER_BOOTSTRAP MASTER_KUBO_PEERID + +cat </dev/null; fi +kubo_cfg config --json Addresses.Announce "[\"/$PROTO/$PUBLIC_HOST/tcp/4001\",\"/$PROTO/$PUBLIC_HOST/udp/4001/quic-v1\"]" >/dev/null +kubo_cfg config Routing.Type dhtserver >/dev/null +kubo_cfg config --json Routing.AcceleratedDHTClient true >/dev/null +# read identity from the repo file directly — lock-free, daemon-state-independent +NEW_KUBO_PEERID="$(jq -r '.Identity.PeerID // empty' "$KUBO_DIR/config")"; [ -n "$NEW_KUBO_PEERID" ] || die "could not read new kubo peer id." + +kubo_ch="$(cat </dev/null 2>&1 && break; [ "$i" = 30 ] && die "kubo not healthy on :5001"; sleep 3; done +info "kubo healthy ($NEW_KUBO_PEERID)" + +# ---- ipfs-cluster (idempotent init + join) ---- +cl_oneshot() { docker run --rm -e IPFS_CLUSTER_PATH=/data/ipfs-cluster -e CLUSTER_SECRET="$SECRET" -v "$CLUSTER_DIR":/data/ipfs-cluster --entrypoint ipfs-cluster-service "$CLUSTER_IMAGE" "$@"; } +if [ -f "$CLUSTER_DIR/identity.json" ]; then info "cluster identity exists — skip init"; else info "init ipfs-cluster"; cl_oneshot init >/dev/null 2>&1 || cl_oneshot init >/dev/null; fi +NEW_CLUSTER_PEERID="$(jq -r '.id' "$CLUSTER_DIR/identity.json")"; { [ -n "$NEW_CLUSTER_PEERID" ] && [ "$NEW_CLUSTER_PEERID" != null ]; } || die "could not read new cluster peer id." +printf '%s\n' "$MASTER_CLUSTER_BOOTSTRAP" > "$CLUSTER_DIR/peerstore" +TRUSTED="$MASTER_CLUSTER_PEERID,$NEW_CLUSTER_PEERID" + +cl_ch="$(cat </dev/null 2>&1 && info "cluster API up" || info "NOTE: cluster API not up yet — check: docker logs ipfs_cluster" + +pc_save_env "$ENV_FILE" PUBLIC_HOST CLUSTERNAME POOL_API BASE_DIR KUBO_IMAGE CLUSTER_IMAGE REPL_MIN REPL_MAX MASTER_CLUSTER_PEERID MASTER_CLUSTER_BOOTSTRAP MASTER_KUBO_PEERID NEW_CLUSTER_PEERID NEW_KUBO_PEERID + +cat <