feat(rp1): la cadena corre en la Pi 5 de la casa — binario aarch64, despliegue sin root y medida continua

David, 24-oct: «lo que podamos hacer en la rp1 hagamoslo no esperemos la VPS porfavor». Y ese
mismo día, por Ergon: la rp1 aloja el servidor de arranque de la red.

scripts/rp1/:
- construir-aarch64.sh + Dockerfile.aarch64: origin-chain-node release, con el WASM real, para
  linux/aarch64 en el Mac.
  - Imagen bookworm (glibc 2.36; la rp1 tiene 2.41).
  - Target en un volumen de Docker: la VM no ve el disco externo de compilación si se montó
    después de arrancar Docker, y reiniciarla tiraría los contenedores de otras hermanas.
  - Copia el binario con docker cp y avisa de cómo borrar el volumen.
- desplegar.sh: copia el binario con su sha256 (y deja el anterior para volver atrás), copia
  arrancar.sh y medir.sh, e instala tres líneas en el crontab del usuario: @reboot, vigilante
  cada 5 min y medida cada 5 min. Sin root y sin systemd.
- arrancar.sh: idempotente.
  - taskset -c 0-2: el nodo nunca toca el núcleo 3, el santuario de origin-banco de Laurha.
  - nice 10 + ionice 7: la CI de la casa va primero.
  - RPC sólo en 127.0.0.1; P2P en el 30333.
  - Cadena dev con Alice: es la testnet de la casa, con llaves públicas de desarrollo; ningún
    valor real.
- medir.sh: una fila CSV cada 5 min con best, finalizado, pares, RSS, CPU, MB de datos, load,
  temperatura y estado de la CI.

scripts/bench/max-de-n.py: funde N weights.rs del mismo pallet cobrando el MÁXIMO de cada
coeficiente.
- Por qué: Laurha midió el 24-oct en su santuario que 8 procesos idénticos de frame-omni-bencher
  separan el base weight hasta un 66 %.
- Se niega si los esqueletos difieren (otra lista de extrinsics o de storage) o si los conteos
  de reads/writes no coinciden. Avisa si N < 5.
- Controles: tres copias idénticas salen idénticas byte a byte; el máximo cae sólo donde se
  subió; un conteo de reads distinto se rechaza; un extrinsic renombrado se rechaza.

firmar_como_wallet: modo cuenta "<frase>" [//derivación], que da la llave sr25519 y la SS58 que
verá un wallet Substrate. Son los vectores para la migración de la wallet Origin! (Irhis).

fix(node): build-spec emite el chain spec REAL cuando el binario lleva el WASM.
- Antes, aunque el binario llevara el WASM real, emitía siempre el de mayo, con :code = «0x»
  simulado, y --raw no sacaba el génesis en crudo.
- Ahora usa el mismo spec con el que arranca run (node_chain_spec), y --raw lleva el génesis
  entero.
- Sin WASM sigue el simulado, pero avisa de que no sirve para unirse a una red. Mainnet sigue
  cerrada por la compuerta L-3.
- Por qué importa, medido hoy: el WASM del runtime NO es reproducible entre máquinas. El mismo
  commit compilado en el Mac y en Linux da génesis distintos (0xe80c… frente a 0x0926…), y el
  nodo del Mac baneó al de la rp1 con «Genesis mismatch». El génesis tiene que fijarlo un chain
  spec que se reparte, no quien compila.
- arrancar.sh usa ~/origin-chain/casa.json cuando existe (exportado en la propia rp1). Así un
  binario nuevo nunca crea otro génesis sobre los mismos datos.

Medido en vivo el 24-oct, todo por RPC firmado como un wallet:
- Nodo en la rp1 (Pi 5, Debian 13): bloques cada 6 s y finalidad a 2-3 bloques. RSS ~200 MB y
  afinidad 0-2 con nice 10.
- El vigilante de cron lo resucita con el mismo peer id
  (12D3KooWDWm5p3Z8Y5DddRki6M6nM1A2xUVQzPqNU1DW53uB6BVi) y sin regenerar la llave.
- E2E en la rp1: remark → ExtrinsicSuccess; finalize_override → ProposalNotFound; uphold0 →
  ZeroWeightWindowVote.
- casa.json exportado en la rp1 = estado del bloque 0 de la cadena viva: 46 de 46 claves
  idénticas. El nodo reinició sobre él con el mismo génesis y siguió desde el #155.
- Red de dos máquinas por la LAN: el nodo del Mac, con casa.json y la rp1 como bootnode,
  sincronizó (mismo best, mismo finalizado). Una transacción enviada al nodo del Mac la incluyó
  la rp1 en el bloque #167, con el mismo hash blake2-256 que devolvió author_submitExtrinsic.

Defectos encontrados en mis propios scripts antes de dejarlos publicados:
- desplegar.sh instalaba un crontab VACÍO: con set -e, el grep -v sobre un crontab vacío
  abortaba la subshell antes de los echo.
- medir.sh dejaba best/finalizado en NA: set -u con $2 sin definir, y stderr se iba con cron.
- max-de-n.py reescribía 16416 como 16_416 (la prueba de identidad lo cazó).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-10-24 13:19:52 -05:00
1 parent 3cd1068893
commit 22bab19932
8 files changed
+284 -1

No files matched your search

+23
View File
@@ -243,6 +243,29 @@ fn print_preset(id: &str) {
/// JSON canonical via [`chain_spec::serialize_chain_spec`]. Exit code canonical 1 /// JSON canonical via [`chain_spec::serialize_chain_spec`]. Exit code canonical 1
/// si chain id canonical unknown o JSON serialization canonical fails. /// si chain id canonical unknown o JSON serialization canonical fails.
fn build_spec(chain: &str, raw: bool) { fn build_spec(chain: &str, raw: bool) {
// Con el WASM real, el chain spec que se emite es el MISMO con el que arranca `run`
// (`node_chain_spec`), y `--raw` lleva el génesis entero, `:code` incluido. Es lo que se
// reparte para que otro nodo se una a la red: el WASM no es reproducible entre máquinas
// (Mac y Linux dan génesis distintos con el mismo commit, medido el 2026-10-24), así que
// el génesis lo fija el chain spec, no quien compila.
if origin_chain_runtime::WASM_BINARY.is_some()
&& !matches!(chain, "mainnet" | "mainnet_genesis")
{
match chain_spec::node_chain_spec(chain).and_then(|spec| spec.as_json(raw)) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("❌ Chain spec no resuelto: {e}");
std::process::exit(1);
}
}
return;
}
if origin_chain_runtime::WASM_BINARY.is_none() {
eprintln!(
"⚠ binario sin el WASM del runtime: este chain spec lleva un WASM SIMULADO (\"0x\") \
y no sirve para arrancar ni para unirse a una red (--features wasm-build)."
);
}
match chain_spec::load_spec(chain) { match chain_spec::load_spec(chain) {
Ok(spec) => match chain_spec::serialize_chain_spec(&spec, raw) { Ok(spec) => match chain_spec::serialize_chain_spec(&spec, raw) {
Ok(json) => println!("{}", json), Ok(json) => println!("{}", json),
@@ -17,6 +17,8 @@
//! compilado. Tras un `set_code` hay que pasarlos: el wallet firma con la versión //! compilado. Tras un `set_code` hay que pasarlos: el wallet firma con la versión
//! nueva que anuncia el nodo, no con la que se compiló aquí. //! nueva que anuncia el nodo, no con la que se compiló aquí.
//! eventos <hex de System::Events> decodifica los eventos de un bloque //! eventos <hex de System::Events> decodifica los eventos de un bloque
//! cuenta "<frase BIP39>" [//derivación] llave sr25519 y dirección SS58 (42) que verá un wallet
//! Substrate (substrate-bip39 → mini-secret → sr25519): vectores para la wallet Origin!
//! verificar <hex_extrinsic> <genesis_hex> pasa la firma por el check del runtime nativo //! verificar <hex_extrinsic> <genesis_hex> pasa la firma por el check del runtime nativo
//! sobre el preset development (sin nodo) //! sobre el preset development (sin nodo)
//! ``` //! ```
@@ -124,6 +126,14 @@ fn main() {
println!("{:?}", e.event); println!("{:?}", e.event);
} }
} }
"cuenta" => {
use sp_core::crypto::Ss58Codec;
let uri = format!("{}{}", args[2], args.get(3).map(String::as_str).unwrap_or(""));
let par = sr25519::Pair::from_string(&uri, None).expect("frase o derivación inválida");
let publica = par.public();
println!("sr25519 publica 0x{}", sp_core::hexdisplay::HexDisplay::from(&publica.0));
println!("ss58 (42) {}", publica.to_ss58check());
}
"verificar" => { "verificar" => {
use sp_runtime::traits::Checkable; use sp_runtime::traits::Checkable;
let bytes = hex(&args[2]); let bytes = hex(&args[2]);
@@ -145,6 +155,6 @@ fn main() {
println!("check del runtime: {:?}", r.map(|_| "firma aceptada")); println!("check del runtime: {:?}", r.map(|_| "firma aceptada"));
}); });
} }
_ => panic!("modo: firmar · eventos · verificar (ver la cabecera del fichero)"), _ => panic!("modo: firmar · eventos · verificar · cuenta (ver la cabecera del fichero)"),
} }
} }
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Funde N weights.rs del MISMO pallet en uno que cobra el MÁXIMO de los N procesos.
python3 scripts/bench/max-de-n.py salida.rs proceso1.rs proceso2.rs ... (N >= 2)
Por qué: Laurha midió el 2026-10-24 en la Pi 5, dentro del santuario (núcleo aislado,
governor performance, CI parada), que 8 procesos idénticos de frame-omni-bencher dan
base weights que se separan hasta un 66 % (revoke_validator: 18,8 a 31,3 µs), mientras el
mínimo apenas se mueve. Un weight cobra el caso malo, y el caso malo existe: se cobra el
máximo de N >= 5 procesos, no la media ni la mediana.
Qué hace:
- Exige que los N ficheros tengan el MISMO esqueleto: todo el texto salvo los números del
cuerpo, desde `pub trait WeightInfo`. Si un fichero trae otra lista de extrinsics, otro
acceso a storage u otra plantilla, se niega. Mezclar pallets o plantillas no es un máximo.
- `Weight::from_parts(a, b)`: toma el máximo de cada coeficiente.
- `reads(n)` y `writes(n)`: deben ser IGUALES en los N (cuentan accesos, no tiempo). Si no lo
son, se niega.
- Los comentarios numéricos del cuerpo: «Medido» y «Estimado» toman el máximo; «Tiempo
mínimo» toma el mínimo.
- Cabecera: la del primer fichero, más una línea que declara N y el sha256 de cada entrada.
"""
import hashlib, re, sys
NUM = re.compile(r"\d(?:[\d_]*\d)?") # «7_u64» → 7; nunca se come el guion bajo final
INICIO_CUERPO = "pub trait WeightInfo"
def partir(texto):
i = texto.index(INICIO_CUERPO)
return texto[:i], texto[i:]
def esqueleto(cuerpo):
return NUM.sub("#", cuerpo)
def numeros(cuerpo):
return [(m.start(), m.end(), int(m.group().replace("_", ""))) for m in NUM.finditer(cuerpo)]
def contexto(cuerpo, inicio):
"""La línea donde cae un número, para decidir la regla."""
a = cuerpo.rfind("\n", 0, inicio) + 1
b = cuerpo.find("\n", inicio)
return cuerpo[a:b if b != -1 else len(cuerpo)]
def con_guiones(n):
s = str(n)
partes = []
while s:
partes.insert(0, s[-3:])
s = s[:-3]
return "_".join(partes)
def main():
if len(sys.argv) < 4:
sys.exit(__doc__)
salida, entradas = sys.argv[1], sys.argv[2:]
if len(entradas) < 5:
print(f"AVISO: N = {len(entradas)} < 5. La regla de la casa (Laurha, 24-oct) cobra el máximo de N >= 5 procesos.", file=sys.stderr)
textos = [open(p).read() for p in entradas]
cabecera, cuerpo0 = partir(textos[0])
cuerpos = [partir(t)[1] for t in textos]
sk = esqueleto(cuerpo0)
for p, c in zip(entradas, cuerpos):
if esqueleto(c) != sk:
sys.exit(f"RECHAZO: {p} no tiene el mismo esqueleto que {entradas[0]} (otra lista de extrinsics, storage o plantilla)")
listas = [numeros(c) for c in cuerpos]
out, cursor = [], 0
for k, (ini, fin, _) in enumerate(listas[0]):
valores = [lst[k][2] for lst in listas]
linea = contexto(cuerpo0, ini)
if "reads(" in linea or "writes(" in linea:
if len(set(valores)) != 1:
sys.exit(f"RECHAZO: los conteos de storage difieren entre procesos en «{linea.strip()}»: {valores}")
v = valores[0]
elif "minimo" in linea or "mínimo" in linea:
v = min(valores)
else:
v = max(valores)
crudo = cuerpo0[ini:fin]
nuevo = con_guiones(v) if "_" in crudo else str(v) # conserva el formato del literal
out.append(cuerpo0[cursor:ini])
out.append(nuevo)
cursor = fin
out.append(cuerpo0[cursor:])
shas = " ".join(hashlib.sha256(t.encode()).hexdigest()[:12] for t in textos)
nota = (f"// MAXIMO DE N = {len(entradas)} PROCESOS (scripts/bench/max-de-n.py): cada coeficiente\n"
f"// de tiempo y de proof es el maximo de los N; reads/writes iguales en los N.\n"
f"// Entradas (sha256[:12]): {shas}\n\n")
with open(salida, "w") as f:
f.write(cabecera + nota + "".join(out))
print(f"{salida}: máximo de {len(entradas)} procesos, {len(listas[0])} números revisados")
main()
+7
View File
@@ -0,0 +1,7 @@
# Compila origin-chain-node para la Raspberry Pi 5 (linux/aarch64) en un Mac Apple Silicon.
# Bookworm (glibc 2.36): el binario corre en cualquier Debian con glibc >= 2.36 (la rp1 tiene 2.41).
FROM rust:1.88-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang libclang-dev llvm protobuf-compiler cmake \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add wasm32v1-none wasm32-unknown-unknown && rustup component add rust-src
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Arranca el nodo de Origin Chain en la rp1 si no está corriendo. Idempotente: lo llaman
# cron @reboot y el vigilante cada 5 min. Sin root.
#
# Convivencia con la casa (la rp1 es también el runner de CI y el banco de Laurha):
# - taskset -c 0-2: el nodo NUNCA toca el núcleo 3, que es el santuario de origin-banco.
# - nice 10 + ionice best-effort 7: la CI tiene prioridad sobre el nodo.
# - RPC sólo en 127.0.0.1: se entra por túnel SSH. P2P en 30333 para la LAN.
#
# Cadena: `dev` (Alice, llaves de desarrollo PÚBLICAS). Es la testnet de la casa, no la
# testnet pública: ningún valor real vive aquí.
set -euo pipefail
BASE="$HOME/origin-chain"
BIN="$BASE/bin/origin-chain-node"
PIDF="$BASE/nodo.pid"
mkdir -p "$BASE/data" "$BASE/logs"
if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF")" 2>/dev/null; then
exit 0
fi
LOG="$BASE/logs/nodo-$(date +%Y%m%d-%H%M%S).log"
# Un validador fuera de --dev exige una llave de red estable, y este CLI no trae `key`. Se
# genera UNA vez (la vía 4 del propio nodo) y queda en el base-path; en los arranques
# siguientes la bandera no se pasa, así que el peer id del bootnode no cambia nunca.
LLAVE_RED="$BASE/data/chains/origin_chain_dev/network/secret_ed25519"
EXTRA=()
[ -f "$LLAVE_RED" ] || EXTRA=(--unsafe-force-node-key-generation)
# El génesis lo fija casa.json (chain spec raw con el :code dentro), no el binario: el WASM no
# es reproducible entre máquinas, y con `--chain dev` un binario nuevo crearía OTRO génesis
# sobre los mismos datos. Sin casa.json (el primer arranque) se usa el `dev` embebido; después
# se exporta con `bin/origin-chain-node build-spec --chain dev --raw > casa.json`.
CADENA=dev
[ -f "$BASE/casa.json" ] && CADENA="$BASE/casa.json"
nohup taskset -c 0-2 nice -n 10 ionice -c2 -n7 "$BIN" run \
--chain "$CADENA" \
--validator --alice \
--name rp1-origin-casa \
--base-path "$BASE/data" \
--port 30333 \
--rpc-port 9944 --rpc-methods safe \
--no-telemetry --no-prometheus "${EXTRA[@]}" \
>"$LOG" 2>&1 &
echo $! >"$PIDF"
ln -sfn "$LOG" "$BASE/logs/actual.log"
echo "$(date -Is) arrancado pid $(cat "$PIDF") log $LOG" >>"$BASE/logs/arranques.log"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Compila origin-chain-node (release, con el WASM real) para la rp1, Raspberry Pi 5 linux/aarch64,
# en un Mac Apple Silicon con Docker (linux/arm64 corre nativo, sin emulación).
#
# scripts/rp1/construir-aarch64.sh <carpeta-destino-del-binario>
#
# Por qué así (medido el 2026-10-24):
# - La rp1 no compila Substrate: es el runner de CI de la casa y tiene 8 GB.
# - Imagen bookworm (glibc 2.36): el binario corre en la rp1 (Debian 13, glibc 2.41).
# - El target vive en un VOLUMEN de Docker y no en el disco externo de compilación: la VM de
# Docker Desktop no ve /Volumes/MacOSBukup si el disco se montó después de arrancar Docker
# («not a directory»), y reiniciar Docker tiraría los contenedores de otras hermanas.
# Al acabar se copia el binario fuera y se borra el volumen.
# - 4 jobs: la VM tiene 8 GB compartidos con las bases de datos de otras hermanas.
# - WASM_BUILD_WORKSPACE_HINT=/src: substrate-wasm-builder busca Cargo.lock subiendo desde el
# target; con el target fuera del workspace no lo encuentra, resuelve dependencias nuevas
# (que piden rustc 1.90) y el build del runtime falla.
set -euo pipefail
DESTINO="${1:?carpeta destino}"
RAIZ="$(cd "$(dirname "$0")/../.." && pwd)"
IMG=origin-chain-builder:1.88-bookworm
docker image inspect "$IMG" >/dev/null 2>&1 || docker build -q -t "$IMG" -f "$RAIZ/scripts/rp1/Dockerfile.aarch64" "$RAIZ/scripts/rp1"
docker volume create charon-arm64-target >/dev/null
docker volume create charon-arm64-registry >/dev/null
docker run --rm --name charon-aarch64 \
-v "$RAIZ":/src:ro -v charon-arm64-target:/target -v charon-arm64-registry:/usr/local/cargo/registry \
-e CARGO_TARGET_DIR=/target -e WASM_BUILD_WORKSPACE_HINT=/src -e WASM_BUILD_STD=0 -e CARGO_BUILD_JOBS=4 \
-e CC_wasm32v1_none=clang -e AR_wasm32v1_none=llvm-ar \
-e CC_wasm32_unknown_unknown=clang -e AR_wasm32_unknown_unknown=llvm-ar \
-w /src "$IMG" cargo build --release -p origin-chain-node --features wasm-build
mkdir -p "$DESTINO"
# docker cp escribe desde el host: funciona aunque la VM no vea el destino (p. ej. /Volumes/…).
C=$(docker create -v charon-arm64-target:/target "$IMG" true)
docker cp "$C:/target/release/origin-chain-node" "$DESTINO/origin-chain-node"
docker rm "$C" >/dev/null
file "$DESTINO/origin-chain-node"
shasum -a 256 "$DESTINO/origin-chain-node"
echo "Para liberar el disco de Docker: docker volume rm charon-arm64-target charon-arm64-registry"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Despliega el nodo de Origin Chain en la rp1 desde el Mac (Charon, 2026-10-24).
#
# scripts/rp1/desplegar.sh <ruta/al/origin-chain-node aarch64>
#
# - Copia el binario a ~/origin-chain/bin y deja el anterior como .anterior (volver atrás:
# mv .anterior encima y matar el pid; el vigilante lo rearranca).
# - Copia arrancar.sh y medir.sh, e instala en el crontab del usuario (sin root):
# @reboot y cada 5 min arrancar.sh (vigilante); cada 5 min medir.sh.
# - No toca nada fuera de ~/origin-chain ni el crontab de otras entradas.
set -euo pipefail
BIN="${1:?ruta al binario aarch64}"
HOST="${ORIGIN_RP1:-rp1}"
DIR="$(cd "$(dirname "$0")" && pwd)"
file "$BIN" | grep -q "ARM aarch64" || { echo "no es un binario aarch64: $BIN"; exit 1; }
SHA=$(shasum -a 256 "$BIN" | cut -d' ' -f1)
ssh "$HOST" 'mkdir -p ~/origin-chain/bin ~/origin-chain/logs && { [ -f ~/origin-chain/bin/origin-chain-node ] && cp -p ~/origin-chain/bin/origin-chain-node ~/origin-chain/bin/origin-chain-node.anterior || true; }'
scp -q "$BIN" "$HOST:origin-chain/bin/origin-chain-node.nuevo"
scp -q "$DIR/arrancar.sh" "$DIR/medir.sh" "$HOST:origin-chain/"
ssh "$HOST" "set -e; cd ~/origin-chain
echo '$SHA bin/origin-chain-node.nuevo' | sha256sum -c --quiet
mv bin/origin-chain-node.nuevo bin/origin-chain-node; chmod +x bin/origin-chain-node arrancar.sh medir.sh
echo '$SHA' > bin/origin-chain-node.sha256
# || true: con set -e, el grep -v sobre un crontab vacío sale 1 y abortaba la subshell
# ANTES de los echo, así que se instalaba un crontab VACÍO sin ningún error (medido el 24-oct).
( { crontab -l 2>/dev/null | grep -v 'origin-chain/' || true; } ; \
echo '@reboot \$HOME/origin-chain/arrancar.sh'; \
echo '*/5 * * * * \$HOME/origin-chain/arrancar.sh'; \
echo '*/5 * * * * \$HOME/origin-chain/medir.sh' ) | crontab -
bin/origin-chain-node version | head -3"
echo "desplegado en $HOST: sha256 $SHA"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Una fila de medida del nodo de la rp1 cada vez que se llama (cron cada 5 min).
# CSV: fecha, best, finalizado, pares, rss_mb, cpu_pct, datos_mb, load1, temp_c, ci_activo
set -uo pipefail
BASE="$HOME/origin-chain"
CSV="$BASE/logs/medidas.csv"
[ -f "$CSV" ] || echo "fecha,best,finalizado,pares,rss_mb,cpu_pct,datos_mb,load1,temp_c,ci_activo" >"$CSV"
rpc() {
curl -s -m 5 -H 'Content-Type: application/json' \
-d "{\"id\":1,\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":[${2:-}]}" http://127.0.0.1:9944
}
num() { python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print(int(r['number'],16) if isinstance(r,dict) else r)" 2>/dev/null || echo NA; }
best=$(rpc chain_getHeader | num)
fh=$(rpc chain_getFinalizedHead | python3 -c "import json,sys;print(json.load(sys.stdin)['result'])" 2>/dev/null || echo "")
fin=NA; [ -n "$fh" ] && fin=$(rpc chain_getHeader "\"$fh\"" | num)
pares=$(rpc system_health | python3 -c "import json,sys;print(json.load(sys.stdin)['result']['peers'])" 2>/dev/null || echo NA)
pid=$(cat "$BASE/nodo.pid" 2>/dev/null || echo 0)
rss=NA; cpu=NA
if kill -0 "$pid" 2>/dev/null; then
read -r rss cpu < <(ps -o rss=,pcpu= -p "$pid" | awk '{printf "%.0f %s\n", $1/1024, $2}')
fi
datos=$(du -sm "$BASE/data" 2>/dev/null | cut -f1)
load1=$(cut -d' ' -f1 /proc/loadavg)
temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp)
ci=$(systemctl is-active gitea-runner 2>/dev/null || echo NA)
echo "$(date -Is),$best,$fin,$pares,$rss,$cpu,$datos,$load1,$temp,$ci" >>"$CSV"