diff --git a/controls/roles/manage-service/templates/prometheus.yml.j2 b/controls/roles/manage-service/templates/prometheus.yml.j2 index 63245f5474..8ca4b18313 100644 --- a/controls/roles/manage-service/templates/prometheus.yml.j2 +++ b/controls/roles/manage-service/templates/prometheus.yml.j2 @@ -336,13 +336,13 @@ scrape_configs: {% endif %} ] -# Charon +# Charon / Pluto - same metric names on the same port, so one job - job_name: charon static_configs: - targets: [ {% set targets = [] %} {%- for service_config in service_configs.results %} - {%- if (service_config.content | b64decode | from_yaml).service == "CharonService" %} + {%- if (service_config.content | b64decode | from_yaml).service in ["CharonService", "PlutoService"] %} {%- set _ = targets.append("stereum-" ~ (service_config.content | b64decode | from_yaml).id ~ ":3620") %} {%- endif %} {% endfor %} diff --git a/launcher/public/img/icon/service-icons/execution/Ethrex-s.png b/launcher/public/img/icon/service-icons/execution/Ethrex-s.png index 585c70ffdc..dec645fd77 100644 Binary files a/launcher/public/img/icon/service-icons/execution/Ethrex-s.png and b/launcher/public/img/icon/service-icons/execution/Ethrex-s.png differ diff --git a/launcher/public/img/icon/service-icons/execution/Ethrex.png b/launcher/public/img/icon/service-icons/execution/Ethrex.png index 4f258784db..ff91d9381f 100644 Binary files a/launcher/public/img/icon/service-icons/execution/Ethrex.png and b/launcher/public/img/icon/service-icons/execution/Ethrex.png differ diff --git a/launcher/public/img/icon/service-icons/validator/Pluto-s.png b/launcher/public/img/icon/service-icons/validator/Pluto-s.png new file mode 100644 index 0000000000..16ed23b6f3 Binary files /dev/null and b/launcher/public/img/icon/service-icons/validator/Pluto-s.png differ diff --git a/launcher/public/img/icon/service-icons/validator/Pluto.png b/launcher/public/img/icon/service-icons/validator/Pluto.png new file mode 100644 index 0000000000..356915eced Binary files /dev/null and b/launcher/public/img/icon/service-icons/validator/Pluto.png differ diff --git a/launcher/src/backend/Monitoring.js b/launcher/src/backend/Monitoring.js index a1ce7d0714..8bc0349efb 100755 --- a/launcher/src/backend/Monitoring.js +++ b/launcher/src/backend/Monitoring.js @@ -1,6 +1,7 @@ import { ServiceManager } from "./ServiceManager"; import { ValidatorAccountManager } from "./ValidatorAccountManager"; import { StringUtils } from "./StringUtils.js"; +import { isObolDVTService, OBOL_DVT_SERVICES } from "@/share/ObolDVTServices"; import { SSHService } from "./SSHService.js"; import * as log from "electron-log"; import * as crypto from "crypto"; @@ -3268,9 +3269,9 @@ export class Monitoring { } try { - //Check if connected to CharonService + //Check if connected to an Obol DV middleware service const vc = await this.nodeConnection.readServiceConfiguration(serviceID); - const connectedCharon = vc?.dependencies.consensusClients.find((client) => client.service === "CharonService"); + const connectedCharon = vc?.dependencies.consensusClients.find((client) => isObolDVTService(client.service)); const beaconAPIPort = connectedCharon ? 3600 : beaconStatus.data[0].beacon.servicePort; const serviceId = connectedCharon ? connectedCharon.id : beaconStatus.data[0].sid; @@ -3396,7 +3397,7 @@ export class Monitoring { */ async fetchObolCharonAlerts() { try { - const serviceInfos = await this.getServiceInfos("CharonService"); + const serviceInfos = await this.getServiceInfos(...OBOL_DVT_SERVICES); if (serviceInfos.length < 1) { return []; } @@ -3422,11 +3423,18 @@ export class Monitoring { if (metric.result.status != "success") { return; } + // a labelled counter has no series until first incremented, so a + // healthy cluster legitimately returns nothing for some of these + const series = metric.result.data?.result ?? []; + if (!series.length) { + log.debug(`No data for Obol alert metric "${metric.key}", skipping it`); + return; + } if (metric.key === "peer_ping_latency") { - let value = Math.max(...metric.result.data.result.map((r) => r.value[1])); + let value = Math.max(...series.map((r) => r.value[1])); return this.parseObolCharonAlerts(metric.key, value); } - let value = metric.result.data.result[0].value[1]; + let value = series[0].value[1]; return this.parseObolCharonAlerts(metric.key, value); }) .filter((alert) => alert); @@ -3827,15 +3835,20 @@ export class Monitoring { } async getObolClusterInformation(serviceID) { - const serviceInfos = await this.getServiceInfos("CharonService"); + const serviceInfos = await this.getServiceInfos(...OBOL_DVT_SERVICES); const charon = serviceInfos.find((service) => service.config?.serviceID === serviceID); if (!charon) { log.info("No such Charon found!"); return {}; } + // charon deleted this check in v1.10.0 (ObolNetwork/charon#4359) as a false + // positive; pluto still ports it and reports 6 while doing its duties fine + const readyzCodes = charon.service === "PlutoService" ? ["1", "6"] : ["1"]; + const queries = { app_monitoring_readyz: `app_monitoring_readyz{instance=~".*${serviceID}.*"}`, // for Cluster Peer + app_peer_name: `app_peer_name{instance=~".*${serviceID}.*"}`, cluster_attestation_performance: `(sum(increase(core_tracker_success_duties_total{instance=~".*${serviceID}.*",duty="attester"}[1h])) / sum(increase(core_tracker_expect_duties_total{instance=~".*${serviceID}.*",duty="attester"}[1h])) > 0) * 100`, cluster_attestation_participation: `core_tracker_participation{instance=~".*${serviceID}.*",duty="attester"}`, cluster_operators: `cluster_operators{instance=~".*${serviceID}.*"}`, @@ -3855,25 +3868,34 @@ export class Monitoring { if (metric.result.status != "success") { return; } + const series = metric.result.data?.result ?? []; + if (!series.length) { + log.debug(`No data for Obol cluster metric "${metric.key}", skipping it`); + return; + } switch (metric.key) { case "app_monitoring_readyz": - stats.nodeStatus = metric.result.data.result[0].value[1] === "1" ? "ACTIVE" : "INACTIVE"; - stats.peerName = metric.result.data.result[0].metric.cluster_peer; + stats.nodeStatus = readyzCodes.includes(series[0].value[1]) ? "ACTIVE" : "INACTIVE"; + stats.peerName = series[0].metric.cluster_peer; + break; + case "app_peer_name": + // charon carries the name on readyz, pluto only here + if (!stats.peerName) stats.peerName = series[0].metric.peer_name; break; case "cluster_attestation_performance": - stats.attestationPerformance = metric.result.data.result[0].value[1]; + stats.attestationPerformance = series[0].value[1]; break; case "cluster_attestation_participation": - stats.attestationParticipation = metric.result.data.result.reduce((acc, curr) => acc + parseInt(curr.value[1]), 0); + stats.attestationParticipation = series.reduce((acc, curr) => acc + parseInt(curr.value[1]), 0); break; case "cluster_operators": - stats.operators = parseInt(metric.result.data.result[0].value[1]); + stats.operators = parseInt(series[0].value[1]); break; case "cluster_threshold": - stats.threshold = parseInt(metric.result.data.result[0].value[1]); + stats.threshold = parseInt(series[0].value[1]); break; case "cluster_validators": - stats.validators = parseInt(metric.result.data.result[0].value[1]); + stats.validators = parseInt(series[0].value[1]); break; } }); diff --git a/launcher/src/backend/OneClickInstall.js b/launcher/src/backend/OneClickInstall.js index d3f41f3dd1..655cf44e1e 100755 --- a/launcher/src/backend/OneClickInstall.js +++ b/launcher/src/backend/OneClickInstall.js @@ -2,6 +2,7 @@ import { ServiceManager } from "./ServiceManager"; import YAML from "yaml"; import { StringUtils } from "./StringUtils"; import { ConfigManager } from "./ConfigManager"; +import { isObolDVTService } from "@/share/ObolDVTServices"; const log = require("electron-log"); @@ -227,9 +228,9 @@ export class OneClickInstall { } let charon = undefined; - if (constellation.includes("CharonService")) { - //CharonService - charon = this.serviceManager.getService("CharonService", { + const obolDVTService = constellation.find((service) => isObolDVTService(service)); + if (obolDVTService) { + charon = this.serviceManager.getService(obolDVTService, { ...args, consensusClients: this.beaconService.filter((service) => service.service !== "OpNodeBeaconService"), }); @@ -351,7 +352,7 @@ export class OneClickInstall { ...args, consensusClients: this.beaconService .filter((service) => service.service !== "OpNodeBeaconService") - .concat(this.extraServices.filter((s) => s.service === "CharonService")), + .concat(this.extraServices.filter((s) => isObolDVTService(s.service))), otherServices: this.extraServices.filter((s) => s.service === "ValidatorEjectorService"), }) ); diff --git a/launcher/src/backend/SSHService.js b/launcher/src/backend/SSHService.js index 7455a4b4ac..4d23b5f03d 100755 --- a/launcher/src/backend/SSHService.js +++ b/launcher/src/backend/SSHService.js @@ -243,9 +243,10 @@ export class SSHService { async execCommand(command) { if (this.loggingOut) return { rc: -1, stdout: "", stderr: "Logging Out!" }; + const conn = this.getConnectionFromPool(); + // an empty pool would otherwise surface as a TypeError on conn.exec + if (!conn) return { rc: -1, stdout: "", stderr: "Not connected!" }; return new Promise((resolve, reject) => { - let conn = this.getConnectionFromPool(); - const data = { rc: -1, stdout: "", diff --git a/launcher/src/backend/ServiceManager.js b/launcher/src/backend/ServiceManager.js index 834b041e38..dca0e23027 100755 --- a/launcher/src/backend/ServiceManager.js +++ b/launcher/src/backend/ServiceManager.js @@ -9,6 +9,7 @@ import { BesuService } from "./ethereum-services/BesuService"; import { SSVNetworkService } from "./ethereum-services/SSVNetworkService"; import { SSVDKGService } from "./ethereum-services/SSVDKGService"; import { CharonService } from "./ethereum-services/CharonService"; +import { PlutoService } from "./ethereum-services/PlutoService"; import { NimbusBeaconService } from "./ethereum-services/NimbusBeaconService"; import { NimbusValidatorService } from "./ethereum-services/NimbusValidatorService"; import { PrometheusService } from "./ethereum-services/PrometheusService"; @@ -42,6 +43,7 @@ import { OpNodeBeaconService } from "./ethereum-services/OpNodeBeaconService"; import { L2GethService } from "./ethereum-services/L2GethService"; import { SSVNOMService } from "./ethereum-services/SSVNOMService"; import { EthrexService } from "./ethereum-services/EthrexService"; +import { isObolDVTService } from "@/share/ObolDVTServices"; import YAML from "yaml"; // import { file } from "jszip"; @@ -172,6 +174,8 @@ export class ServiceManager { services.push(KeysAPIService.buildByConfiguration(config)); } else if (config.service == "CharonService") { services.push(CharonService.buildByConfiguration(config)); + } else if (config.service == "PlutoService") { + services.push(PlutoService.buildByConfiguration(config)); } else if (config.service == "ExternalConsensusService") { services.push(ExternalConsensusService.buildByConfiguration(config)); } else if (config.service == "ExternalExecutionService") { @@ -477,7 +481,7 @@ export class ServiceManager { filter = (e) => e.buildConsensusClientHttpEndpointUrl(); command = "--beaconNodes="; } - if (dependencies.some((d) => d.service === "CharonService")) { + if (dependencies.some((d) => isObolDVTService(d.service))) { service.command.push("--distributed"); } break; @@ -572,6 +576,7 @@ export class ServiceManager { } break; case "Charon": + case "Pluto": filter = (e) => e.buildConsensusClientHttpEndpointUrl(); command = "--beacon-node-endpoints="; break; @@ -657,7 +662,7 @@ export class ServiceManager { if (service.service === "OpNodeBeaconService") { service.volumes = service.volumes.filter((vol) => vol.servicePath !== "/engine.jwt"); } - } else if (service.service.includes("Validator") || service.service.includes("Charon")) { + } else if (service.service.includes("Validator") || isObolDVTService(service.service)) { service.dependencies.consensusClients = dependencies; } else if (service.service.includes("OpErigon") || service.service.includes("OpGeth")) { service.dependencies.executionClients = dependencies.filter((d) => d.service === "L2GethService"); @@ -748,14 +753,14 @@ export class ServiceManager { addLidoObolExitConnection(service, dependencies) { // handle beacon node command dependency const consensusClient = dependencies.filter( - (d) => typeof d.buildConsensusClientHttpEndpointUrl === "function" && d.service != "CharonService" + (d) => typeof d.buildConsensusClientHttpEndpointUrl === "function" && !isObolDVTService(d.service) )[0]; let filter = (e) => e.buildConsensusClientHttpEndpointUrl(); let command = "--beacon-node-url="; service.command = this.addCommandConnection(service, command, consensusClient ? [consensusClient] : [], filter); // handle charon volume dependency - const charon = dependencies.find((d) => d.service === "CharonService"); + const charon = dependencies.find((d) => isObolDVTService(d.service)); service.volumes = service.volumes.filter((v) => !v.servicePath.includes("charon")); if (charon) { service.volumes.push(new ServiceVolume(`${charon.getDataDir()}/.charon`, "/charon")); @@ -864,6 +869,22 @@ export class ServiceManager { return command + newValue; } + /** + * buildConfiguration() always reports autoupdate as enabled, so carry over + * whatever the service was configured with. Falls back to enabled, which is + * what writing the configuration directly would have done anyway. + */ + async writeServiceConfigurationKeepingAutoupdate(service) { + const configuration = service.buildConfiguration(); + try { + const stored = await this.nodeConnection.readServiceConfiguration(service.id); + configuration.autoupdate = stored?.autoupdate ?? true; + } catch (err) { + log.warn(`Couldn't read the stored autoupdate flag of ${service.id}, keeping it enabled:`, err?.message ?? err); + } + await this.nodeConnection.writeServiceConfiguration(configuration); + } + async deleteService(task, _tasks, services, ssvConfigs) { let serviceToDelete = services.find((service) => service.id === task.service.config.serviceID); let dependents = []; @@ -879,7 +900,7 @@ export class ServiceManager { if (service.service === "SSVNetworkService") { this.removeSSVNetworkConnection(service, serviceToDelete, ssvConfigs); } - this.nodeConnection.writeServiceConfiguration(service.buildConfiguration()); + await this.writeServiceConfigurationKeepingAutoupdate(service); } if (serviceToDelete.service === "Web3SignerService") { await this.nodeConnection.sshService.exec( @@ -895,7 +916,19 @@ export class ServiceManager { }); } + /** + * @param {Object} switchTask - a "SWITCH CLIENT" task + * @returns {boolean} true if swapped in place, so the previous service must not be deleted + */ async switchServices(switchTask) { + // Charon and Pluto share one ".charon" directory (ENR key, cluster lock, + // key shares); installing a replacement and deleting the old service would + // take it with them, so swap in place. + if (isObolDVTService(switchTask.service.service) && isObolDVTService(switchTask.data.itemToInstall?.service)) { + await this.switchObolDVTClient(switchTask); + return true; + } + await this.configManager.deleteServiceFromSetup(switchTask.id, switchTask.setupId); let services = await this.readServiceConfigurations(); @@ -969,6 +1002,83 @@ export class ServiceManager { } } + /** + * Swap Charon <-> Pluto in place: only image and entrypoint change, so the + * ".charon" data, id, ports, volumes, dependencies and command all survive. + * + * @param {Object} switchTask - a "SWITCH CLIENT" task between two DV clients + */ + async switchObolDVTClient(switchTask) { + const serviceID = switchTask.service.config.serviceID; + const target = switchTask.data.itemToInstall.service; + const services = await this.readServiceConfigurations(); + const current = services.find((service) => service.id === serviceID); + + if (!current) throw new Error(`Couldn't find service ${serviceID} to switch`); + if (!isObolDVTService(current.service)) throw new Error(`${current.service} is not an Obol DV middleware client`); + if (current.service === target) return; + + // both clients take the same flags, so the command carries over verbatim + const config = current.buildConfiguration(); + const swapped = target === "PlutoService" ? PlutoService.buildByConfiguration(config) : CharonService.buildByConfiguration(config); + const canonical = this.getService(target, { network: current.network, installDir: "/opt/stereum", consensusClients: [] }); + + // buildConfiguration() minimises dependencies and initByConfig() keeps them + // as given, so reuse the resolved ones rather than re-minimising plain objects + swapped.dependencies = current.dependencies; + + swapped.service = target; + swapped.image = canonical.image; + swapped.imageVersion = await this.latestImageVersion(target, current.network, canonical.imageVersion); + swapped.entrypoint = canonical.entrypoint; + + log.info(`Switching ${current.service} ${serviceID} to ${target} (${swapped.image}:${swapped.imageVersion})`); + + // Dependents persist the client's name alongside its id. They hold a + // reference to `current`, so renaming it is what their minimal + // configurations will serialise. + const dependents = services.filter((service) => + Object.values(service.dependencies).some((deps) => deps.some((d) => d?.id === serviceID)) + ); + current.service = target; + + // "restarted" skips the role's firewall task, so stop then start + await this.manageServiceState(serviceID, "stopped"); + await this.writeServiceConfigurationKeepingAutoupdate(swapped); + for (const dependent of dependents) { + await this.writeServiceConfigurationKeepingAutoupdate(dependent); + } + await this.manageServiceState(serviceID, "started"); + } + + async latestImageVersion(serviceName, network, fallback) { + try { + const versions = await this.nodeConnection.nodeUpdates.checkUpdates(); + const tags = versions?.[network]?.[serviceName] ?? versions?.mainnet?.[serviceName]; + if (Array.isArray(tags) && tags.length) return tags.slice(-1).pop(); + } catch (err) { + log.error(`Couldn't fetch versions for ${serviceName}, using ${fallback}:`, err); + } + return fallback; + } + + /** + * @returns {string|null} the node's publicly reachable IPv4 address + */ + async resolveExternalIp() { + const services = ["https://api.ipify.org", "https://ifconfig.me/ip", "https://icanhazip.com"]; + for (const url of services) { + try { + const result = await this.nodeConnection.sshService.exec(`curl -4 -fsS --max-time 5 ${url}`); + const ip = result.stdout?.trim(); + if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return ip; + } catch (err) { + log.warn(`Couldn't resolve the external IP via ${url}:`, err); + } + } + return null; + } + //args: network, installDir, port, executionClients, checkpointURL, consensusClients, mevboost, relays // for external -> source, jwtToken // chainId -> for devnet getService(name, args) { let ports; @@ -1213,6 +1323,10 @@ export class ServiceManager { ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; return CharonService.buildByUserInput(args.network, ports, args.installDir + "/charon", args.consensusClients); + case "PlutoService": + ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + return PlutoService.buildByUserInput(args.network, ports, args.installDir + "/pluto", args.consensusClients); + case "ExternalExecutionService": return ExternalExecutionService.buildByUserInput(args.network, args.installDir + "/externalExecution", args.source, args.jwtToken); case "ExternalConsensusService": @@ -1494,7 +1608,7 @@ export class ServiceManager { setupAndServiceIds[service.id] = t.data.setupId; newServices.push(service); }); - let DVTInstalls = tasks.filter((t) => /SSVNetwork|Charon/.test(t.service.service)); + let DVTInstalls = tasks.filter((t) => /SSVNetwork/.test(t.service.service) || isObolDVTService(t.service.service)); DVTInstalls.forEach((t) => { if (t.service.service == "SSVNetworkService" && services.filter((s) => s.service === "SSVNetworkService").length) { // TODO: Make SSVNetworkService multiservice (which depends also on SSVDKGService) @@ -1507,7 +1621,9 @@ export class ServiceManager { setupAndServiceIds[service.id] = t.data.setupId; newServices.push(service); }); - let VLInstalls = tasks.filter((t) => t.service.category === "validator" && !/SSVNetwork|Charon/.test(t.service.service)); + let VLInstalls = tasks.filter( + (t) => t.service.category === "validator" && !/SSVNetwork/.test(t.service.service) && !isObolDVTService(t.service.service) + ); VLInstalls.forEach((t) => { this.updateInfoForDependencies(t, services, newServices, ELInstalls, CLInstalls, undefined, DVTInstalls); let service = this.getService(t.service.service, t.data); @@ -1994,22 +2110,30 @@ export class ServiceManager { } if (jobs.includes("SWITCH CLIENT")) { let before = this.nodeConnection.nodeUpdates.getTimeStamp(); + let switchTasks = tasks.filter((t) => t.content === "SWITCH CLIENT"); + let swappedInPlace = []; try { - let switchTasks = tasks.filter((t) => t.content === "SWITCH CLIENT"); for (const switchTask of switchTasks) { - await this.switchServices(switchTask); + if (await this.switchServices(switchTask)) swappedInPlace.push(switchTask.service.config.serviceID); } let services = await this.readServiceConfigurations(); await Promise.all( - tasks.filter(ServiceManager.uniqueByID("SWITCH CLIENT")).map((task, _index, tasks) => { - return this.deleteService(task, tasks, services); - }) + tasks + .filter(ServiceManager.uniqueByID("SWITCH CLIENT")) + .filter((task) => !swappedInPlace.includes(task.service.config.serviceID)) + .map((task, _index, tasks) => { + return this.deleteService(task, tasks, services); + }) ); } catch (err) { log.error("Switching Services Failed:", err); } finally { - let after = this.nodeConnection.nodeUpdates.getTimeStamp(); - await this.nodeConnection.nodeUpdates.restartServices(after - before); + // an in place swap restarted the service already; "restart-services" + // would cycle it again and prune the previous client's image + if (swappedInPlace.length < switchTasks.length) { + let after = this.nodeConnection.nodeUpdates.getTimeStamp(); + await this.nodeConnection.nodeUpdates.restartServices(after - before); + } } } let services = await this.readServiceConfigurations(); @@ -2554,7 +2678,8 @@ export class ServiceManager { })); return translators; } catch (error) { - console.error("Failed to fetch translators:", error); + log.warn("Couldn't fetch translators, the credits list stays empty:", error?.message ?? error); + return []; } } @@ -2568,7 +2693,8 @@ export class ServiceManager { })); return testers; } catch (error) { - console.error("Failed to fetch GitHub testers:", error); + log.warn("Couldn't fetch GitHub testers, the credits list stays empty:", error?.message ?? error); + return []; } } diff --git a/launcher/src/backend/ValidatorAccountManager.js b/launcher/src/backend/ValidatorAccountManager.js index 7f93582e0f..f0c8e586bb 100755 --- a/launcher/src/backend/ValidatorAccountManager.js +++ b/launcher/src/backend/ValidatorAccountManager.js @@ -5,6 +5,7 @@ import { SSHService } from "./SSHService.js"; import { validatorPorts } from "./ethereum-services/ServicePort.js"; import { ServiceVolume } from "./ethereum-services/ServiceVolume.js"; import { networks } from "./ethereum-services/NodeService.js"; +import { isObolDVTService } from "@/share/ObolDVTServices"; import * as path from "path"; import axios from "axios"; @@ -230,11 +231,11 @@ export class ValidatorAccountManager { try { let client = await this.nodeConnection.readServiceConfiguration(serviceID); let data = {}; - if (client.service === "CharonService" || client.service === "SSVNetworkService") { + if (isObolDVTService(client.service) || client.service === "SSVNetworkService") { const keys = await this.getDVTKeys(serviceID); data.data = keys.map((dv) => { return { - validating_pubkey: client.service === "CharonService" ? dv.distributed_public_key : "0x" + dv.public_key, + validating_pubkey: isObolDVTService(client.service) ? dv.distributed_public_key : "0x" + dv.public_key, derivation_path: "", readonly: false, dvt: true, @@ -911,8 +912,8 @@ export class ValidatorAccountManager { async createObolENR(privateKey = "") { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; if (privateKey) { let result = await this.nodeConnection.sshService.exec(charonClient.getCreateCharonFolderCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -938,8 +939,8 @@ export class ValidatorAccountManager { async checkObolContent() { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let result = await this.nodeConnection.sshService.exec(charonClient.getListCharonFolderContentsCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -967,8 +968,8 @@ export class ValidatorAccountManager { async getObolENRPrivateKey() { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let result = await this.nodeConnection.sshService.exec(charonClient.getReadENRPrivateKeyCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -982,8 +983,8 @@ export class ValidatorAccountManager { async getObolENRPublicKey() { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let result = await this.nodeConnection.sshService.exec(charonClient.getReadEnrCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -998,8 +999,8 @@ export class ValidatorAccountManager { async removeObolENR() { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let result = await this.nodeConnection.sshService.exec(charonClient.getRemoveEnrCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -1013,8 +1014,8 @@ export class ValidatorAccountManager { async removeObolCluster() { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let result = await this.nodeConnection.sshService.exec(charonClient.getNukeObolCommand()); if (SSHService.checkExecError(result) && result.stderr) throw SSHService.extractExecError(result); @@ -1032,8 +1033,8 @@ export class ValidatorAccountManager { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; let contentResult = await this.nodeConnection.sshService.exec(charonClient.getListCharonFolderContentsCommand()); if (SSHService.checkExecError(contentResult) && contentResult.stderr) throw SSHService.extractExecError(contentResult); @@ -1077,8 +1078,8 @@ export class ValidatorAccountManager { async downloadObolBackup(localPath) { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; const dataDir = path.posix.join(charonClient.getDataDir(), ".charon"); const result = await this.nodeConnection.sshService.downloadDirectorySSH(dataDir, localPath); if (result) { @@ -1092,8 +1093,8 @@ export class ValidatorAccountManager { async importObolBackup(localPath) { try { let services = await this.serviceManager.readServiceConfigurations(); - let charonClient = services.find((service) => service.service === "CharonService"); - if (!charonClient) throw "Couldn't find CharonService"; + let charonClient = services.find((service) => isObolDVTService(service.service)); + if (!charonClient) throw "Couldn't find an Obol DV middleware service"; const dataDir = path.posix.join(charonClient.getDataDir(), ".charon"); await this.nodeConnection.sshService.exec(`rm -rf ${charonClient.getDataDir()}`); const result = await this.nodeConnection.sshService.uploadDirectorySSH(path.normalize(localPath), dataDir); @@ -1109,7 +1110,8 @@ export class ValidatorAccountManager { const service = (await this.serviceManager.readServiceConfigurations()).find((s) => s.id === serviceID); if (!service) throw new Error(`Service with id ${serviceID} not found`); switch (service.service) { - case "CharonService": { + case "CharonService": + case "PlutoService": { const result = await this.nodeConnection.sshService.exec(service.getReadClusterLockCommand()); const clusterLock = JSON.parse(result.stdout); return clusterLock.distributed_validators; diff --git a/launcher/src/backend/ethereum-services/BesuService.js b/launcher/src/backend/ethereum-services/BesuService.js index 6431565ccd..58604cb09c 100755 --- a/launcher/src/backend/ethereum-services/BesuService.js +++ b/launcher/src/backend/ethereum-services/BesuService.js @@ -16,7 +16,7 @@ export class BesuService extends NodeService { service.id, // id 1, // configVersion "hyperledger/besu", // image - "22.7.6", // imageVersion + "26.8.1", // imageVersion [ `--network=${network}`, `--data-path=${dataDir}`, diff --git a/launcher/src/backend/ethereum-services/CharonService.js b/launcher/src/backend/ethereum-services/CharonService.js index fb3ee2977a..cc04045b34 100755 --- a/launcher/src/backend/ethereum-services/CharonService.js +++ b/launcher/src/backend/ethereum-services/CharonService.js @@ -21,7 +21,7 @@ export class CharonService extends NodeService { service.id, // id 1, // configVersion "obolnetwork/charon", // image - "v0.19.0", // imageVersion + "v1.11.0", // imageVersion [ "run", `--beacon-node-endpoints=${beaconNodes}`, diff --git a/launcher/src/backend/ethereum-services/ErigonService.js b/launcher/src/backend/ethereum-services/ErigonService.js index 9f1bf8ccd8..0bc5060be3 100755 --- a/launcher/src/backend/ethereum-services/ErigonService.js +++ b/launcher/src/backend/ethereum-services/ErigonService.js @@ -17,7 +17,7 @@ export class ErigonService extends NodeService { service.id, // id 1, // configVersion "erigontech/erigon", // image - "v2.60.8", // imageVersion + "v3.6.0", // imageVersion [ `erigon`, `--chain=${network}`, diff --git a/launcher/src/backend/ethereum-services/EthrexService.js b/launcher/src/backend/ethereum-services/EthrexService.js index 20119f2dd9..f5c415538f 100644 --- a/launcher/src/backend/ethereum-services/EthrexService.js +++ b/launcher/src/backend/ethereum-services/EthrexService.js @@ -16,7 +16,7 @@ export class EthrexService extends NodeService { service.id, // id 1, // configVersion "ghcr.io/lambdaclass/ethrex", // image - "9.0.0", // imageVersion + "25.0.0", // imageVersion [ `--network=${network}`, `--datadir=${dataDir}`, diff --git a/launcher/src/backend/ethereum-services/FlashbotsMevBoostService.js b/launcher/src/backend/ethereum-services/FlashbotsMevBoostService.js index af838dd9ee..3be985085d 100755 --- a/launcher/src/backend/ethereum-services/FlashbotsMevBoostService.js +++ b/launcher/src/backend/ethereum-services/FlashbotsMevBoostService.js @@ -10,7 +10,7 @@ export class FlashbotsMevBoostService extends NodeService { null, // id 1, // configVersion image, // image - "v0.8.2", // imageVersion + "1.12", // imageVersion null, // command ["/app/mev-boost", "-addr", "0.0.0.0:18550", `-${network}`, "-relay-check", "-relays", `${relayLinks}`], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/GethService.js b/launcher/src/backend/ethereum-services/GethService.js index 5c5754ee48..5754d2b41b 100755 --- a/launcher/src/backend/ethereum-services/GethService.js +++ b/launcher/src/backend/ethereum-services/GethService.js @@ -23,7 +23,7 @@ export class GethService extends NodeService { service.id, // id 1, // configVersion "ethereum/client-go", // image - "v1.14.10-amd64", // imageVersion + "v1.17.5", // imageVersion cmd, // command ["geth"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/GrafanaService.js b/launcher/src/backend/ethereum-services/GrafanaService.js index 509e16b88d..05981d0bd0 100755 --- a/launcher/src/backend/ethereum-services/GrafanaService.js +++ b/launcher/src/backend/ethereum-services/GrafanaService.js @@ -25,7 +25,7 @@ export class GrafanaService extends NodeService { service.id, // id 1, // configVersion image, // image - "9.1.6", // imageVersion + "13.2.0", // imageVersion "/run.sh", // command null, // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/GrandineBeaconService.js b/launcher/src/backend/ethereum-services/GrandineBeaconService.js index bfa0f5b9fd..8461e9f1ea 100644 --- a/launcher/src/backend/ethereum-services/GrandineBeaconService.js +++ b/launcher/src/backend/ethereum-services/GrandineBeaconService.js @@ -39,7 +39,7 @@ export class GrandineBeaconService extends NodeService { service.id, //id 1, // configVersion image, //image - "1.0.0", //imageVersion + "2.0.6", //imageVersion [ "grandine", `--network=${network}`, diff --git a/launcher/src/backend/ethereum-services/KeysAPIService.js b/launcher/src/backend/ethereum-services/KeysAPIService.js index 8d569b9974..b2b234c997 100755 --- a/launcher/src/backend/ethereum-services/KeysAPIService.js +++ b/launcher/src/backend/ethereum-services/KeysAPIService.js @@ -12,7 +12,7 @@ export class KeysAPIService extends NodeService { service.id, // id 1, // configVersion image, // image - "dev", // imageVersion + "4.0.4", // imageVersion [], // command [], // entrypoint { diff --git a/launcher/src/backend/ethereum-services/L2GethService.js b/launcher/src/backend/ethereum-services/L2GethService.js index be40faaa98..a66c981458 100644 --- a/launcher/src/backend/ethereum-services/L2GethService.js +++ b/launcher/src/backend/ethereum-services/L2GethService.js @@ -15,7 +15,7 @@ export class L2GethService extends NodeService { service.id, // id 1, // configVersion "ethereumoptimism/l2geth", // image - "0.5.31", // imageVersion + "latest", // imageVersion ["--vmodule=eth/*=5,miner=4,rpc=5,rollup=4,consensus/clique=1", `--datadir=${dataDir}`, "--allow-insecure-unlock", "--gcmode=full"], // command ["geth"], // entrypoint { diff --git a/launcher/src/backend/ethereum-services/LidoObolExitService.js b/launcher/src/backend/ethereum-services/LidoObolExitService.js index 53019f7437..4f63c274eb 100644 --- a/launcher/src/backend/ethereum-services/LidoObolExitService.js +++ b/launcher/src/backend/ethereum-services/LidoObolExitService.js @@ -1,12 +1,13 @@ import { NodeService } from "./NodeService"; +import { isObolDVTService } from "@/share/ObolDVTServices"; import { ServiceVolume } from "./ServiceVolume"; export class LidoObolExitService extends NodeService { static buildByUserInput(network, ports, dir, consensusClients = [], otherServices = []) { let ejector = otherServices.find((service) => service.service === "ValidatorEjectorService"); - let charon = consensusClients.find((service) => service.service === "CharonService"); + let charon = consensusClients.find((service) => isObolDVTService(service.service)); if (charon) otherServices.push(charon); - consensusClients = consensusClients.filter((service) => !(service.service === "CharonService")); + consensusClients = consensusClients.filter((service) => !isObolDVTService(service.service)); const service = new LidoObolExitService(); service.setId(); @@ -29,7 +30,7 @@ export class LidoObolExitService extends NodeService { service.id, // id 1, // configVersion image, // image - "latest", // imageVersion + "v0.5.2", // imageVersion [ "run", `--beacon-node-url=${consensusClients[0] ? consensusClients[0].buildConsensusClientHttpEndpointUrl() : ""}`, diff --git a/launcher/src/backend/ethereum-services/LighthouseBeaconService.js b/launcher/src/backend/ethereum-services/LighthouseBeaconService.js index 0eaec7e975..8aa88f275e 100755 --- a/launcher/src/backend/ethereum-services/LighthouseBeaconService.js +++ b/launcher/src/backend/ethereum-services/LighthouseBeaconService.js @@ -40,7 +40,7 @@ export class LighthouseBeaconService extends NodeService { service.id, //id 1, // configVersion image, //image - "v3.1.2", //imageVersion + "v8.2.2", //imageVersion [ "lighthouse", "bn", diff --git a/launcher/src/backend/ethereum-services/LighthouseValidatorService.js b/launcher/src/backend/ethereum-services/LighthouseValidatorService.js index 591c1cff44..5e1af08b46 100755 --- a/launcher/src/backend/ethereum-services/LighthouseValidatorService.js +++ b/launcher/src/backend/ethereum-services/LighthouseValidatorService.js @@ -1,6 +1,7 @@ import { NodeService } from "./NodeService.js"; import { ServicePortDefinition } from "./SerivcePortDefinition.js"; import { ServiceVolume } from "./ServiceVolume.js"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export class LighthouseValidatorService extends NodeService { static buildByUserInput(network, ports, dir, consensusClients = []) { @@ -28,7 +29,7 @@ export class LighthouseValidatorService extends NodeService { service.id, //id 1, //configVersion image, //image - "v3.1.2", //imageVersion + "v8.2.2", //imageVersion [ "lighthouse", "vc", @@ -58,7 +59,7 @@ export class LighthouseValidatorService extends NodeService { consensusClients //consensusClients ); - if (consensusClients.some((c) => c.service === "CharonService")) { + if (consensusClients.some((c) => isObolDVTService(c.service))) { service.command = service.command.filter((c) => c !== "--enable-doppelganger-protection"); } diff --git a/launcher/src/backend/ethereum-services/LodestarBeaconService.js b/launcher/src/backend/ethereum-services/LodestarBeaconService.js index e45ae895f9..0c93f4ff5f 100755 --- a/launcher/src/backend/ethereum-services/LodestarBeaconService.js +++ b/launcher/src/backend/ethereum-services/LodestarBeaconService.js @@ -40,7 +40,7 @@ export class LodestarBeaconService extends NodeService { service.id, //id 1, // configVersion image, //image - "v1.3.0", //imageVersion + "v1.46.0", //imageVersion [ `beacon`, `--network=${network}`, diff --git a/launcher/src/backend/ethereum-services/LodestarValidatorService.js b/launcher/src/backend/ethereum-services/LodestarValidatorService.js index 854f12f680..5a588ff9b9 100755 --- a/launcher/src/backend/ethereum-services/LodestarValidatorService.js +++ b/launcher/src/backend/ethereum-services/LodestarValidatorService.js @@ -1,6 +1,7 @@ import { NodeService } from "./NodeService.js"; import { ServicePortDefinition } from "./SerivcePortDefinition.js"; import { ServiceVolume } from "./ServiceVolume.js"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export class LodestarValidatorService extends NodeService { static buildByUserInput(network, ports, dir, consensusClients = []) { @@ -27,7 +28,7 @@ export class LodestarValidatorService extends NodeService { service.id, //id 1, //configVersion image, //image - "v1.3.0", //imageVersion + "v1.46.0", //imageVersion [ `validator`, `--network=${network}`, @@ -53,7 +54,7 @@ export class LodestarValidatorService extends NodeService { consensusClients //consensusClients ); - if (consensusClients.some((c) => c.service === "CharonService")) { + if (consensusClients.some((c) => isObolDVTService(c.service))) { service.command.push("--distributed"); service.command[service.command.findIndex((c) => c === "--doppelgangerProtection=true")] = "--doppelgangerProtection=false"; service.command.push("--builder.selection=builderalways"); diff --git a/launcher/src/backend/ethereum-services/NethermindService.js b/launcher/src/backend/ethereum-services/NethermindService.js index 78067d4b5f..b48b93fcb8 100755 --- a/launcher/src/backend/ethereum-services/NethermindService.js +++ b/launcher/src/backend/ethereum-services/NethermindService.js @@ -17,7 +17,7 @@ export class NethermindService extends NodeService { service.id, // id 1, // configVersion "nethermind/nethermind", // image - "1.16.1", // imageVersion + "1.39.3", // imageVersion [ `--config=${network}`, "--log=info", diff --git a/launcher/src/backend/ethereum-services/NimbusBeaconService.js b/launcher/src/backend/ethereum-services/NimbusBeaconService.js index 7c8506fe08..023d1f05c8 100755 --- a/launcher/src/backend/ethereum-services/NimbusBeaconService.js +++ b/launcher/src/backend/ethereum-services/NimbusBeaconService.js @@ -38,7 +38,7 @@ export class NimbusBeaconService extends NodeService { service.id, // id, 2, // configVersion image, // image, - "multiarch-v22.10.0", // imageVersion, + "multiarch-v26.8.0", // imageVersion, [ `--network=${network}`, `--data-dir=${dataDir}`, diff --git a/launcher/src/backend/ethereum-services/NimbusValidatorService.js b/launcher/src/backend/ethereum-services/NimbusValidatorService.js index 18cd393ea2..79774d64f8 100755 --- a/launcher/src/backend/ethereum-services/NimbusValidatorService.js +++ b/launcher/src/backend/ethereum-services/NimbusValidatorService.js @@ -1,6 +1,7 @@ import { NodeService } from "./NodeService.js"; import { ServicePortDefinition } from "./SerivcePortDefinition.js"; import { ServiceVolume } from "./ServiceVolume.js"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export class NimbusValidatorService extends NodeService { static buildByUserInput(network, ports, dir, consensusClients = []) { @@ -31,7 +32,7 @@ export class NimbusValidatorService extends NodeService { service.id, // id, 1, // configVersion image, // image, - "multiarch-v23.4.0", // imageVersion, + "multiarch-v26.8.0", // imageVersion, [ "--log-level=INFO", `--data-dir=${dataDir}`, @@ -60,7 +61,7 @@ export class NimbusValidatorService extends NodeService { consensusClients //consensusClients ); - if (consensusClients.some((c) => c.service === "CharonService")) { + if (consensusClients.some((c) => isObolDVTService(c.service))) { service.command[service.command.findIndex((c) => c === "--doppelganger-detection=true")] = "--doppelganger-detection=false"; } diff --git a/launcher/src/backend/ethereum-services/OpErigonService.js b/launcher/src/backend/ethereum-services/OpErigonService.js index 623af04955..0ba8f4e8b6 100644 --- a/launcher/src/backend/ethereum-services/OpErigonService.js +++ b/launcher/src/backend/ethereum-services/OpErigonService.js @@ -30,7 +30,7 @@ export class OpErigonService extends NodeService { service.id, // id 1, // configVersion "testinprod/op-erigon", // image - "v2.60.10-0.8.2", // imageVersion + "latest", // imageVersion cmd, // command ["/usr/local/bin/erigon"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/OpGethService.js b/launcher/src/backend/ethereum-services/OpGethService.js index 7385027467..32a41c091c 100644 --- a/launcher/src/backend/ethereum-services/OpGethService.js +++ b/launcher/src/backend/ethereum-services/OpGethService.js @@ -30,7 +30,7 @@ export class OpGethService extends NodeService { service.id, // id 1, // configVersion "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-geth", // image - "v1.101411.4", // imageVersion + "latest", // imageVersion cmd, // command ["geth"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/OpNodeBeaconService.js b/launcher/src/backend/ethereum-services/OpNodeBeaconService.js index a0791ee992..ccaea4a6ae 100644 --- a/launcher/src/backend/ethereum-services/OpNodeBeaconService.js +++ b/launcher/src/backend/ethereum-services/OpNodeBeaconService.js @@ -64,7 +64,7 @@ export class OpNodeBeaconService extends NodeService { service.id, //id 1, // configVersion image, //image - "v1.10.3", //imageVersion + "v1.19.5", //imageVersion [ "op-node", `--l2=${l2Execution}`, diff --git a/launcher/src/backend/ethereum-services/OpRethService.js b/launcher/src/backend/ethereum-services/OpRethService.js index 67e7eb8b45..425e30ccd7 100644 --- a/launcher/src/backend/ethereum-services/OpRethService.js +++ b/launcher/src/backend/ethereum-services/OpRethService.js @@ -21,7 +21,7 @@ export class OpRethService extends NodeService { service.id, // id 1, // configVersion "ghcr.io/paradigmxyz/op-reth", // image - "v1.2.1", // imageVersion + "latest", // imageVersion cmd, // command ["/usr/local/bin/op-reth"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/PlutoService.js b/launcher/src/backend/ethereum-services/PlutoService.js new file mode 100644 index 0000000000..3f205a1f1d --- /dev/null +++ b/launcher/src/backend/ethereum-services/PlutoService.js @@ -0,0 +1,79 @@ +import { CharonService } from "./CharonService"; +import { ServiceVolume } from "./ServiceVolume"; + +/** + * Nethermind's Pluto, a Rust reimplementation of Charon. Implements charon's + * "run" interface, so it inherits every CharonService helper (ENR, DKG, cluster + * lock) and differs only in image and entrypoint. + * + * Behind Docker it stays relay-only until given "--p2p-external-ip": it never + * advertises the address its autonat confirmed. That address cannot be derived + * from the configuration, so it is an expert option rather than a default. + */ +export class PlutoService extends CharonService { + static buildByUserInput(network, ports, dir, consensusClients = []) { + const service = new PlutoService(); + service.setId(); + const workingDir = service.buildWorkingDir(dir); + + // pluto's defaults are relative to its "/opt/charon" workdir, so the mount + // point has to match charon's + const dataDir = "/opt/charon"; + const volumes = [new ServiceVolume(workingDir, dataDir)]; + + const beaconNodes = consensusClients + .map((client) => { + return client.buildConsensusClientHttpEndpointUrl(); + }) + .join(); + + service.init( + "PlutoService", // service + service.id, // id + 1, // configVersion + "nethermindeth/pluto", // image + "v0.1.4", // imageVersion + [ + "run", + `--beacon-node-endpoints=${beaconNodes}`, + "--log-level=info", + "--log-format=console", + "--p2p-relays=https://0.relay.obol.tech", + "--p2p-tcp-address=0.0.0.0:3610", + "--validator-api-address=0.0.0.0:3600", + "--monitoring-address=0.0.0.0:3620", + "--builder-api", + ], // command + ["/app/bin/pluto"], // entrypoint + null, // env + ports, // ports + volumes, // volumes + null, // user + network, // network + null, // executionClients + consensusClients // consensusClients + ); + + if (consensusClients.map((s) => s.service).includes("NimbusBeaconService")) { + service.command.push("--feature-set-enable=json_requests"); + } + + if (consensusClients.map((s) => s.service).includes("TekuBeaconService")) { + consensusClients + .filter((s) => s.service === "TekuBeaconService") + .forEach((s) => { + s.command.push(`--validators-graffiti-client-append-format=DISABLED`); + }); + } + + return service; + } + + static buildByConfiguration(config) { + const service = new PlutoService(); + + service.initByConfig(config); + + return service; + } +} diff --git a/launcher/src/backend/ethereum-services/PrometheusNodeExporterService.js b/launcher/src/backend/ethereum-services/PrometheusNodeExporterService.js index 457d5f1880..857f9e7fc2 100755 --- a/launcher/src/backend/ethereum-services/PrometheusNodeExporterService.js +++ b/launcher/src/backend/ethereum-services/PrometheusNodeExporterService.js @@ -11,7 +11,7 @@ export class PrometheusNodeExporterService extends NodeService { null, // id 1, // configVersion image, // image - "v1.3.1", // imageVersion + "v1.12.1", // imageVersion ["--path.rootfs=/host"], // command ["/bin/node_exporter"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/PrometheusService.js b/launcher/src/backend/ethereum-services/PrometheusService.js index 94e4d868fb..680e9478ae 100755 --- a/launcher/src/backend/ethereum-services/PrometheusService.js +++ b/launcher/src/backend/ethereum-services/PrometheusService.js @@ -20,7 +20,7 @@ export class PrometheusService extends NodeService { service.id, // id 1, // configVersion image, // image - "v2.38.0", // imageVersion + "v3.14.0", // imageVersion 'sh -c "/bin/prometheus --config.file=/etc/prometheus/prometheus.yml --web.enable-lifecycle --storage.tsdb.retention.time=15d"', // command null, // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/PrysmBeaconService.js b/launcher/src/backend/ethereum-services/PrysmBeaconService.js index c0cecfe6cf..3e1c9ab3a2 100755 --- a/launcher/src/backend/ethereum-services/PrysmBeaconService.js +++ b/launcher/src/backend/ethereum-services/PrysmBeaconService.js @@ -63,7 +63,7 @@ export class PrysmBeaconService extends NodeService { service.id, //id 1, // configVersion image, //image - "v5.0.4", //imageVersion + "v7.1.8", //imageVersion cmd, // command ["/app/cmd/beacon-chain/beacon-chain"], //entrypoint null, //env diff --git a/launcher/src/backend/ethereum-services/PrysmValidatorService.js b/launcher/src/backend/ethereum-services/PrysmValidatorService.js index 5dd4b1cd4a..b7c01f12a0 100755 --- a/launcher/src/backend/ethereum-services/PrysmValidatorService.js +++ b/launcher/src/backend/ethereum-services/PrysmValidatorService.js @@ -46,7 +46,7 @@ export class PrysmValidatorService extends NodeService { service.id, //id 1, // configVersion image, //image - "v5.0.4", //imageVersion + "v7.1.8", //imageVersion cmd, //command ["/app/cmd/validator/validator"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/RethService.js b/launcher/src/backend/ethereum-services/RethService.js index 920d1e2d81..def24315ba 100644 --- a/launcher/src/backend/ethereum-services/RethService.js +++ b/launcher/src/backend/ethereum-services/RethService.js @@ -16,7 +16,7 @@ export class RethService extends NodeService { service.id, // id 1, // configVersion "ghcr.io/paradigmxyz/reth", // image - "v0.1.0-alpha.4", // imageVersion + "v2.5.1", // imageVersion [ `node`, `--chain=${network}`, diff --git a/launcher/src/backend/ethereum-services/SSVDKGService.js b/launcher/src/backend/ethereum-services/SSVDKGService.js index 0359521a53..ff84a70cf2 100644 --- a/launcher/src/backend/ethereum-services/SSVDKGService.js +++ b/launcher/src/backend/ethereum-services/SSVDKGService.js @@ -41,7 +41,7 @@ ethEndpointURL: ${ethEndpointURL} #HTTP Address of Execution Node`; service.id, //id 1, //configVersion image, //image - "v2.1.0", //imageVersion + "v3.1.1", //imageVersion ["start-operator", "--configPath=./data/config.yaml"], //command ["/entry-point.sh"], // entrypoint null, // env diff --git a/launcher/src/backend/ethereum-services/SSVNetworkService.js b/launcher/src/backend/ethereum-services/SSVNetworkService.js index 8fe2bfc6a5..9551cdd19a 100755 --- a/launcher/src/backend/ethereum-services/SSVNetworkService.js +++ b/launcher/src/backend/ethereum-services/SSVNetworkService.js @@ -62,7 +62,7 @@ SSVAPIPort: 16000`; service.id, // id 1, // configVersion image, //image - "v0.3.4", //imageVersion + "v2.4.3", //imageVersion "make BUILD_PATH=/go/bin/ssvnode start-node && docker logs ssv_node", // command null, // entrypoint { diff --git a/launcher/src/backend/ethereum-services/TekuBeaconService.js b/launcher/src/backend/ethereum-services/TekuBeaconService.js index a01bb22ec7..03d250030c 100755 --- a/launcher/src/backend/ethereum-services/TekuBeaconService.js +++ b/launcher/src/backend/ethereum-services/TekuBeaconService.js @@ -38,7 +38,7 @@ export class TekuBeaconService extends NodeService { service.id, // id 2, // configVersion image, // image - "22.10.1", // imageVersion + "26.8.0", // imageVersion [ `--network=${network}`, "--p2p-enabled=true", diff --git a/launcher/src/backend/ethereum-services/TekuValidatorService.js b/launcher/src/backend/ethereum-services/TekuValidatorService.js index 343d71f772..5f88ef892c 100755 --- a/launcher/src/backend/ethereum-services/TekuValidatorService.js +++ b/launcher/src/backend/ethereum-services/TekuValidatorService.js @@ -1,6 +1,7 @@ import { NodeService } from "./NodeService.js"; import { ServicePortDefinition } from "./SerivcePortDefinition.js"; import { ServiceVolume } from "./ServiceVolume.js"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export class TekuValidatorService extends NodeService { static buildByUserInput(network, ports, dir, consensusClients = []) { @@ -27,7 +28,7 @@ export class TekuValidatorService extends NodeService { service.id, // id 1, // configVersion image, // image - "23.4.0", // imageVersion + "26.8.0", // imageVersion [ "vc", `--beacon-node-api-endpoint=${beaconNodes}`, @@ -62,7 +63,7 @@ export class TekuValidatorService extends NodeService { consensusClients //consensusClients ); - if (consensusClients.some((c) => c.service === "CharonService")) { + if (consensusClients.some((c) => isObolDVTService(c.service))) { service.command[service.command.findIndex((c) => c === "--doppelganger-detection-enabled=true")] = "--doppelganger-detection-enabled=false"; } diff --git a/launcher/src/backend/ethereum-services/ValidatorEjectorService.js b/launcher/src/backend/ethereum-services/ValidatorEjectorService.js index 361dbd502f..7d6f5f06ea 100755 --- a/launcher/src/backend/ethereum-services/ValidatorEjectorService.js +++ b/launcher/src/backend/ethereum-services/ValidatorEjectorService.js @@ -41,7 +41,7 @@ export class ValidatorEjectorService extends NodeService { service.id, // id 1, // configVersion image, // image - "dev", // imageVersion + "2.1.0", // imageVersion [], // command [], // entrypoint { diff --git a/launcher/src/backend/ethereum-services/Web3SignerService.js b/launcher/src/backend/ethereum-services/Web3SignerService.js index bdaa8602fe..7e1b91b206 100755 --- a/launcher/src/backend/ethereum-services/Web3SignerService.js +++ b/launcher/src/backend/ethereum-services/Web3SignerService.js @@ -20,7 +20,7 @@ export class Web3SignerService extends NodeService { service.id, // id 1, // configVersion image, // image - "23.2.1", // imageVersion + "26.7.0", // imageVersion [ `--key-store-path=${keysDir}`, `--data-path=${dataDir}`, diff --git a/launcher/src/backend/tests/unit/Credits.test.js b/launcher/src/backend/tests/unit/Credits.test.js new file mode 100644 index 0000000000..a9fa68ee7e --- /dev/null +++ b/launcher/src/backend/tests/unit/Credits.test.js @@ -0,0 +1,44 @@ +import { ServiceManager } from "../../ServiceManager"; + +jest.mock("axios"); +const axios = require("axios"); + +// SettingScreen checks Array.isArray on these, so returning undefined made it +// log "fetchTesters did not return an array" for what is really just an +// unreachable endpoint. Both credits URLs currently answer 404. +const CREDITS = [ + { name: "fetchTranslators", key: "translators" }, + { name: "fetchGitHubTesters", key: "testers" }, +]; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe.each(CREDITS)("$name", ({ name, key }) => { + const call = () => new ServiceManager()[name](); + + test("maps the payload when the endpoint answers", async () => { + axios.get.mockResolvedValue({ + data: { data: { [key]: [{ username: "someone", avatarUrl: "http://avatar", testsCount: 3 }] } }, + }); + + const result = await call(); + + expect(Array.isArray(result)).toBe(true); + expect(result[0].name).toMatch("someone"); + expect(result[0].avatar).toMatch("http://avatar"); + }); + + test("returns an empty array rather than undefined when it 404s", async () => { + axios.get.mockRejectedValue(new Error("Request failed with status code 404")); + + await expect(call()).resolves.toEqual([]); + }); + + test("returns an empty array when the payload is not the shape expected", async () => { + axios.get.mockResolvedValue({ data: {} }); + + await expect(call()).resolves.toEqual([]); + }); +}); diff --git a/launcher/src/backend/tests/unit/ExternalIpOption.test.js b/launcher/src/backend/tests/unit/ExternalIpOption.test.js new file mode 100644 index 0000000000..4a6dcab970 --- /dev/null +++ b/launcher/src/backend/tests/unit/ExternalIpOption.test.js @@ -0,0 +1,123 @@ +import { createPinia, setActivePinia } from "pinia"; +import { useServices } from "@/store/services"; + +// Flag each client uses to advertise its address to peers. Seven clients already +// shipped an option for this before the detect button existed, and their command +// strings are kept as they were - Nimbus in particular carries the "extip:" +// scheme inside the command rather than as a valuePrefix. +const EXPECTED = { + // pre-existing options, now with autoDetect added + LighthouseBeaconService: ["--enr-address", ""], + GrandineBeaconService: ["--enr-address", ""], + PrysmBeaconService: ["--p2p-host-ip", ""], + TekuBeaconService: ["--p2p-advertised-ip", ""], + LodestarBeaconService: ["--enr.ip", ""], + BesuService: ["--p2p-host", ""], + NimbusBeaconService: ["--nat:extip:", ""], + // added, geth derived: "--nat any|none|upnp|pmp|extip:" + GethService: ["--nat", "extip:"], + ErigonService: ["--nat", "extip:"], + RethService: ["--nat", "extip:"], + OpGethService: ["--nat", "extip:"], + OpErigonService: ["--nat", "extip:"], + OpRethService: ["--nat", "extip:"], + // added, plain address + NethermindService: ["--Network.ExternalIp", ""], + EthrexService: ["--nat.extip", ""], + OpNodeBeaconService: ["--p2p.advertise.ip", ""], + PlutoService: ["--p2p-external-ip", ""], +}; + +// External EC/CC only point at a node someone else runs, and L2Geth replicates +// from the sequencer instead of speaking devp2p, so none of them advertise an +// address of their own. +const WITHOUT_P2P = ["ExternalExecutionService", "ExternalConsensusService", "L2GethService"]; + +let allServices; + +beforeEach(() => { + setActivePinia(createPinia()); + allServices = useServices().allServices; +}); + +const optionsFor = (service) => allServices.find((s) => s.service === service)?.expertOptions ?? []; + +const externalIpOptionsOf = (service, command) => optionsFor(service).filter((o) => o.commands?.[0] === command); + +test("every client that speaks p2p offers an external ip option", () => { + const p2pServices = allServices + .filter((s) => ["execution", "consensus"].includes(s.category)) + .map((s) => s.service) + .filter((service) => !WITHOUT_P2P.includes(service)); + + expect(p2pServices.length).toBeGreaterThan(0); + + // a client added to the catalog without its flag fails here rather than + // silently shipping without the option + for (const service of p2pServices) { + expect(Object.keys(EXPECTED)).toContain(service); + } +}); + +test("each client gets its own flag and value prefix", () => { + for (const [service, [command, valuePrefix]] of Object.entries(EXPECTED)) { + const [option] = externalIpOptionsOf(service, command); + + expect(option).toBeDefined(); + expect(option.type).toMatch("text"); + expect(option.valuePrefix ?? "").toBe(valuePrefix); + // drives the detect button in ExpertWindow + expect(option.autoDetect).toMatch("externalIp"); + } +}); + +test("exactly one option per client edits the external ip flag", () => { + // seven clients already had an option for this; adding a second one gave them + // two identical rows in expert mode + for (const [service, [command]] of Object.entries(EXPECTED)) { + expect(externalIpOptionsOf(service, command)).toHaveLength(1); + } +}); + +test("no client has two options writing the same command", () => { + for (const client of allServices) { + const commands = (client.expertOptions ?? []).flatMap((o) => o.commands ?? []); + expect(new Set(commands).size).toBe(commands.length); + } +}); + +test("options are independent objects", () => { + const geth = externalIpOptionsOf("GethService", "--nat")[0]; + const reth = externalIpOptionsOf("RethService", "--nat")[0]; + + // both are built from the same factory with the same arguments, so a shared + // reference would leak one client's entered address into the other + expect(geth).not.toBe(reth); + + geth.changeValue = "80.249.121.1"; + expect(reth.changeValue).toBeNull(); +}); + +test("flags containing dots are escaped in the pattern", () => { + expect(externalIpOptionsOf("NethermindService", "--Network.ExternalIp")[0].pattern[0]).toMatch("(- --Network\\.ExternalIp=)(.*)(\\n)"); + + // the escaped pattern still matches the line it describes + const pattern = new RegExp(externalIpOptionsOf("EthrexService", "--nat.extip")[0].pattern[0]); + expect(" - --nat.extip=80.249.121.1\n").toMatch(pattern); + expect(" - --natXextip=80.249.121.1\n").not.toMatch(pattern); +}); + +test("lodestar can be given node runtime flags", () => { + for (const service of ["LodestarBeaconService", "LodestarValidatorService"]) { + const option = optionsFor(service).find((o) => o.commands?.[0] === "NODE_OPTIONS: "); + + expect(option).toBeDefined(); + // an environment variable, not a command flag: ExpertWindow needs isENV to + // put it in the env block instead of appending it to the command list + expect(option.isENV).toBe(true); + expect(option.type).toMatch("text"); + // the command carries its own ": " separator + expect(option.commands[0].endsWith(": ")).toBe(true); + expect(option.placeholder).toMatch("--max-old-space-size=8192"); + } +}); diff --git a/launcher/src/backend/tests/unit/PlutoService.test.js b/launcher/src/backend/tests/unit/PlutoService.test.js new file mode 100644 index 0000000000..138439041f --- /dev/null +++ b/launcher/src/backend/tests/unit/PlutoService.test.js @@ -0,0 +1,122 @@ +import { PlutoService } from "../../ethereum-services/PlutoService.js"; +import { CharonService } from "../../ethereum-services/CharonService.js"; +import { ServicePort, servicePortProtocol } from "../../ethereum-services/ServicePort.js"; + +const buildBeaconMock = (service, endpoint) => { + return { + service: service, + buildConsensusClientHttpEndpointUrl: jest.fn(() => endpoint), + buildMinimalConfiguration: jest.fn(() => { + return { id: service + "-id", service: service }; + }), + command: [], + }; +}; + +test("buildConfiguration", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const beacon = buildBeaconMock("LighthouseBeaconService", "http://stereum-lh:5052"); + + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", [beacon]).buildConfiguration(); + + expect(pluto.service).toMatch("PlutoService"); + expect(pluto.image).toMatch("nethermindeth/pluto:v0.1.4"); + // pluto's binary is not where charon's is + expect(pluto.entrypoint).toEqual(["/app/bin/pluto"]); + expect(pluto.command).toContain("run"); + expect(pluto.command).toContain("--beacon-node-endpoints=http://stereum-lh:5052"); + expect(pluto.command).toContain("--validator-api-address=0.0.0.0:3600"); + expect(pluto.command).toContain("--monitoring-address=0.0.0.0:3620"); + expect(pluto.command).toContain("--builder-api"); + expect(pluto.network).toMatch("hoodi"); + expect(pluto.id).toHaveLength(36); + expect(pluto.configVersion).toBe(1); + // the image's working directory is /opt/charon, so the mount point has to be too + expect(pluto.volumes).toContain("/opt/stereum/pluto-" + pluto.id + ":/opt/charon"); +}); + +test("charon compatible command surface", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const beacon = buildBeaconMock("LighthouseBeaconService", "http://stereum-lh:5052"); + + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", [beacon]); + const charon = CharonService.buildByUserInput("hoodi", ports, "/opt/stereum/charon", [ + buildBeaconMock("LighthouseBeaconService", "http://stereum-lh:5052"), + ]); + + // an in place swap carries the command over verbatim, so both clients have to + // agree on every flag stereum sets + expect(pluto.command).toEqual(charon.command); +}); + +test("nimbus needs json requests", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const beacon = buildBeaconMock("NimbusBeaconService", "http://stereum-nimbus:5052"); + + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", [beacon]); + + expect(pluto.command).toContain("--feature-set-enable=json_requests"); +}); + +test("teku graffiti append is disabled", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const beacon = buildBeaconMock("TekuBeaconService", "http://stereum-teku:5051"); + + PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", [beacon]); + + expect(beacon.command).toContain("--validators-graffiti-client-append-format=DISABLED"); +}); + +test("external ip is left to the user", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const beacon = buildBeaconMock("LighthouseBeaconService", "http://stereum-lh:5052"); + + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", [beacon]); + + // the address cannot be derived from the configuration, so it is offered as + // an expert option instead of being guessed at install time + expect(pluto.command.some((c) => c.startsWith("--p2p-external-ip"))).toBe(false); +}); + +test("buildByConfiguration", () => { + const pluto = PlutoService.buildByConfiguration({ + id: "pluto-id", + service: "PlutoService", + configVersion: 1, + command: ["run", "--p2p-external-ip=80.249.121.1"], + entrypoint: ["/app/bin/pluto"], + image: "nethermindeth/pluto:v0.1.4", + ports: ["0.0.0.0:3610:3610/tcp"], + volumes: ["/opt/stereum/pluto-pluto-id:/opt/charon"], + network: "hoodi", + }); + + expect(pluto).toBeInstanceOf(PlutoService); + expect(pluto.id).toMatch("pluto-id"); + expect(pluto.imageVersion).toMatch("v0.1.4"); + expect(pluto.command).toContain("--p2p-external-ip=80.249.121.1"); +}); + +test("inherits charon's data directory helpers", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", []); + const dataDir = "/opt/stereum/pluto-" + pluto.id; + + expect(pluto.getDataDir()).toMatch(dataDir); + // "create enr" / "enr" / "dkg" all exist in pluto and read the same files + expect(pluto.getCreateEnrCommand()).toMatch(`-v "${dataDir}:/opt/charon" nethermindeth/pluto:v0.1.4 create enr`); + expect(pluto.getReadEnrCommand()).toMatch(`-v "${dataDir}:/opt/charon" nethermindeth/pluto:v0.1.4 enr`); + expect(pluto.getDKGCommand("https://api.obol.tech/dv/abc")).toMatch( + `nethermindeth/pluto:v0.1.4 dkg --definition-file=https://api.obol.tech/dv/abc --publish` + ); + expect(pluto.getReadENRPrivateKeyCommand()).toMatch(`${dataDir}/.charon/charon-enr-private-key`); + expect(pluto.getReadClusterLockCommand()).toMatch(`${dataDir}/.charon/cluster-lock.json`); +}); + +test("is reachable through the same endpoints as charon", () => { + const ports = [new ServicePort(null, 3610, 3610, servicePortProtocol.tcp)]; + const pluto = PlutoService.buildByUserInput("hoodi", ports, "/opt/stereum/pluto", []); + + expect(pluto.buildConsensusClientHttpEndpointUrl()).toMatch("http://stereum-" + pluto.id + ":3600"); + expect(pluto.buildConsensusClientEndpoint()).toMatch("stereum-" + pluto.id + ":3600"); +}); diff --git a/launcher/src/backend/tests/unit/SSHService.test.js b/launcher/src/backend/tests/unit/SSHService.test.js new file mode 100644 index 0000000000..f5159d1fa7 --- /dev/null +++ b/launcher/src/backend/tests/unit/SSHService.test.js @@ -0,0 +1,34 @@ +import { SSHService } from "../../SSHService.js"; + +// Anything that queries the node before a connection exists used to die on +// "Cannot read properties of undefined (reading 'exec')", because execCommand +// called conn.exec on an empty pool. That message named the symptom rather than +// the cause, which made it hard to place (it reached the log as +// "Can't read services configurations: TypeError: ..."). +test("execCommand reports a missing connection instead of throwing", async () => { + const ssh = new SSHService(); + expect(ssh.connectionPool).toHaveLength(0); + + await expect(ssh.execCommand("ls -1 /etc/stereum/services")).resolves.toEqual({ + rc: -1, + stdout: "", + stderr: "Not connected!", + }); +}); + +test("the missing connection result is treated as an exec error", async () => { + const ssh = new SSHService(); + + const result = await ssh.execCommand("ls"); + + // callers branch on checkExecError, so the guard has to look like a failure + expect(SSHService.checkExecError(result)).toBe(true); + expect(SSHService.extractExecError(result)).toMatch("Not connected!"); +}); + +test("logging out still short circuits before the pool is consulted", async () => { + const ssh = new SSHService(); + ssh.loggingOut = true; + + await expect(ssh.execCommand("ls")).resolves.toEqual({ rc: -1, stdout: "", stderr: "Logging Out!" }); +}); diff --git a/launcher/src/backend/tests/unit/ServiceManager.test.js b/launcher/src/backend/tests/unit/ServiceManager.test.js index b83a008604..7385966818 100755 --- a/launcher/src/backend/tests/unit/ServiceManager.test.js +++ b/launcher/src/backend/tests/unit/ServiceManager.test.js @@ -351,3 +351,220 @@ test("change network", () => { ); expect(services.find((s) => s.service === "LighthouseBeaconService").command).toContain("--network=mainnet"); }); + +const beaconConfig = (id) => { + return { + service: "LighthouseBeaconService", + id: id, + configVersion: 1, + command: ["lighthouse", "bn", "--network=hoodi"], + entrypoint: [], + env: {}, + image: "sigp/lighthouse:v8.2.2", + ports: ["0.0.0.0:9000:9000/tcp"], + volumes: ["/opt/stereum/lighthouse-" + id + ":/opt/app/beacon"], + user: "2000", + network: "hoodi", + autoupdate: true, + dependencies: { executionClients: [], consensusClients: [], mevboost: [], otherServices: [] }, + }; +}; + +const validatorConfig = (id, dvId) => { + return { + service: "LodestarValidatorService", + id: id, + configVersion: 1, + command: ["validator", `--beaconNodes=http://stereum-${dvId}:3600`, "--distributed"], + entrypoint: [], + env: {}, + image: "chainsafe/lodestar:v1.46.0", + ports: [], + volumes: ["/opt/stereum/lodestar-" + id + ":/opt/app/validator"], + user: "2000", + network: "hoodi", + autoupdate: false, + dependencies: { + executionClients: [], + consensusClients: [{ id: dvId, service: "CharonService" }], + mevboost: [], + otherServices: [], + }, + }; +}; + +const charonConfig = (id) => { + return { + service: "CharonService", + id: id, + configVersion: 1, + command: [ + "run", + "--beacon-node-endpoints=http://stereum-lh-id:5052", + "--log-level=info", + "--p2p-tcp-address=0.0.0.0:3610", + "--validator-api-address=0.0.0.0:3600", + "--monitoring-address=0.0.0.0:3620", + "--builder-api", + "--graffiti=custom", + ], + entrypoint: ["/usr/local/bin/charon"], + env: {}, + image: "obolnetwork/charon:v1.11.0", + ports: ["0.0.0.0:3610:3610/tcp"], + volumes: ["/opt/stereum/charon-" + id + ":/opt/charon"], + user: "2000", + network: "hoodi", + autoupdate: false, + dependencies: { + executionClients: [], + consensusClients: [{ id: "lh-id", service: "LighthouseBeaconService" }], + mevboost: [], + otherServices: [], + }, + }; +}; + +const switchMocks = (configs) => { + jest.mock("../../NodeConnection"); + const NodeConnection = require("../../NodeConnection"); + const writeServiceConfiguration = jest.fn(() => Promise.resolve()); + const runPlaybook = jest.fn(() => Promise.resolve({ playbookRunRef: "ref" })); + NodeConnection.NodeConnection.mockImplementation(() => { + return { + listServicesConfigurations: jest.fn(() => Promise.resolve(configs.map((c) => c.id))), + readServiceConfiguration: jest.fn((id) => Promise.resolve(configs.find((c) => c.id === id))), + writeServiceConfiguration: writeServiceConfiguration, + runPlaybook: runPlaybook, + nodeUpdates: { checkUpdates: jest.fn(() => Promise.resolve({ hoodi: { PlutoService: ["v0.1.3", "v0.1.4"] } })) }, + sshService: { exec: jest.fn(() => Promise.resolve({ stdout: "80.249.121.1\n", stderr: "", rc: 0 })) }, + }; + }); + return { NodeConnection, writeServiceConfiguration, runPlaybook }; +}; + +test("switchServices swaps charon for pluto in place", async () => { + const config = charonConfig("charon-id"); + const vc = validatorConfig("vc-id", "charon-id"); + const { NodeConnection, writeServiceConfiguration, runPlaybook } = switchMocks([config, beaconConfig("lh-id"), vc]); + const sm = new ServiceManager(NodeConnection.NodeConnection()); + + const handledInPlace = await sm.switchServices({ + id: "charon-id", + setupId: "setup-id", + service: { service: "CharonService", category: "validator", config: { serviceID: "charon-id", network: "hoodi" } }, + data: { itemToInstall: { service: "PlutoService" }, data: {} }, + }); + + // the previous service must survive: it holds the ENR key and key shares + expect(handledInPlace).toBe(true); + + const written = writeServiceConfiguration.mock.calls[0][0]; + expect(written.service).toMatch("PlutoService"); + expect(written.image).toMatch("nethermindeth/pluto:v0.1.4"); + expect(written.entrypoint).toEqual(["/app/bin/pluto"]); + + // identity, ports and data directory are untouched + expect(written.id).toMatch("charon-id"); + expect(written.ports).toEqual(config.ports); + expect(written.volumes).toEqual(config.volumes); + expect(written.user).toMatch("2000"); + expect(written.autoupdate).toBe(false); + + // dependencies come back out as minimal configs + expect(written.dependencies.consensusClients).toEqual([{ id: "lh-id", service: "LighthouseBeaconService" }]); + + // carried over verbatim; "--p2p-external-ip" is the user's to set + expect(written.command).toEqual(config.command); + + // stopped then started, so "manage-service" reconciles the firewall rules + expect(runPlaybook.mock.calls.map((c) => c[1].stereum_args.manage_service.state)).toEqual(["stopped", "started"]); +}); + +test("switchServices refreshes what dependents recorded about the client", async () => { + const config = charonConfig("charon-id"); + const vc = validatorConfig("vc-id", "charon-id"); + const { NodeConnection, writeServiceConfiguration } = switchMocks([config, beaconConfig("lh-id"), vc]); + const sm = new ServiceManager(NodeConnection.NodeConnection()); + + await sm.switchServices({ + id: "charon-id", + setupId: "setup-id", + service: { service: "CharonService", category: "validator", config: { serviceID: "charon-id", network: "hoodi" } }, + data: { itemToInstall: { service: "PlutoService" }, data: {} }, + }); + + const written = writeServiceConfiguration.mock.calls.map((c) => c[0]); + const dependent = written.find((c) => c.id === "vc-id"); + + // dependents persist the client's name next to its id, so a stale name would + // outlive the swap + expect(dependent).toBeDefined(); + expect(dependent.dependencies.consensusClients).toEqual([{ id: "charon-id", service: "PlutoService" }]); + + // rewriting a dependent must not quietly re-enable its auto updates + expect(dependent.autoupdate).toBe(false); + + // and nothing else about it changes + expect(dependent.command).toEqual(vc.command); + expect(dependent.image).toMatch("chainsafe/lodestar:v1.46.0"); +}); + +test("switchServices swaps pluto back to charon in place", async () => { + const config = charonConfig("pluto-id"); + config.service = "PlutoService"; + config.image = "nethermindeth/pluto:v0.1.4"; + config.entrypoint = ["/app/bin/pluto"]; + config.command.push("--p2p-external-ip=80.249.121.1"); + + const { NodeConnection, writeServiceConfiguration } = switchMocks([config, beaconConfig("lh-id")]); + const sm = new ServiceManager(NodeConnection.NodeConnection()); + + const handledInPlace = await sm.switchServices({ + id: "pluto-id", + setupId: "setup-id", + service: { service: "PlutoService", category: "validator", config: { serviceID: "pluto-id", network: "hoodi" } }, + data: { itemToInstall: { service: "CharonService" }, data: {} }, + }); + + expect(handledInPlace).toBe(true); + + const written = writeServiceConfiguration.mock.calls[0][0]; + expect(written.service).toMatch("CharonService"); + expect(written.image).toMatch(/^obolnetwork\/charon:/); + expect(written.entrypoint).toEqual(["/usr/local/bin/charon"]); + expect(written.id).toMatch("pluto-id"); + // charon accepts the same flag, so a hand set external ip survives the swap + expect(written.command).toEqual(config.command); + expect(written.command).toContain("--graffiti=custom"); + expect(written.command).toContain("--p2p-external-ip=80.249.121.1"); +}); + +test("resolveExternalIp falls through to the next service", async () => { + jest.mock("../../NodeConnection"); + const NodeConnection = require("../../NodeConnection"); + const exec = jest + .fn() + .mockResolvedValueOnce({ stdout: "", stderr: "curl: (6) Could not resolve host", rc: 6 }) + .mockResolvedValueOnce({ stdout: "80.249.121.1\n", stderr: "", rc: 0 }); + NodeConnection.NodeConnection.mockImplementation(() => { + return { sshService: { exec: exec } }; + }); + + const sm = new ServiceManager(NodeConnection.NodeConnection()); + + await expect(sm.resolveExternalIp()).resolves.toMatch("80.249.121.1"); + expect(exec).toHaveBeenCalledTimes(2); +}); + +test("resolveExternalIp reports failure instead of a bogus address", async () => { + jest.mock("../../NodeConnection"); + const NodeConnection = require("../../NodeConnection"); + NodeConnection.NodeConnection.mockImplementation(() => { + return { sshService: { exec: jest.fn(() => Promise.resolve({ stdout: "nope", stderr: "", rc: 0 })) } }; + }); + + const sm = new ServiceManager(NodeConnection.NodeConnection()); + + await expect(sm.resolveExternalIp()).resolves.toBeNull(); +}); diff --git a/launcher/src/backend/tests/unit/StorageValue.test.js b/launcher/src/backend/tests/unit/StorageValue.test.js new file mode 100644 index 0000000000..d630e7a99b --- /dev/null +++ b/launcher/src/backend/tests/unit/StorageValue.test.js @@ -0,0 +1,42 @@ +import { storageValueToGiB } from "@/composables/utils"; + +// Sizes come from "du -csh" via Monitoring.getStorageStatus, which turns e.g. +// "1.2T" into "1.2 TB". They are divided by totalDisk, which "df --total -BG" +// reports in GiB, so every suffix has to land on the same scale. +test("converts every unit du can emit to GiB", () => { + expect(storageValueToGiB("1 GB")).toBe(1); + expect(storageValueToGiB("450 GB")).toBe(450); + + // the regression: TB used to fall through and be read as GB + expect(storageValueToGiB("1 TB")).toBe(1024); + expect(storageValueToGiB("1.2 TB")).toBeCloseTo(1228.8, 5); + expect(storageValueToGiB("2 PB")).toBe(2 * 1024 * 1024); + + expect(storageValueToGiB("512 MB")).toBeCloseTo(0.5, 10); + expect(storageValueToGiB("4.0 KB")).toBeCloseTo(4 / 1024 ** 2, 12); + expect(storageValueToGiB("0 B")).toBe(0); +}); + +test("a terabyte is a thousandfold of a gigabyte, not equal to it", () => { + expect(storageValueToGiB("1 TB")).toBeGreaterThan(storageValueToGiB("1 GB")); + expect(storageValueToGiB("1 TB") / storageValueToGiB("1 GB")).toBe(1024); +}); + +test("accepts the shapes the backend actually produces", () => { + // getStorageStatus inserts a single space before the unit, and emits "0 B" + // for empty volumes + expect(storageValueToGiB("0 B")).toBe(0); + expect(storageValueToGiB("36 GB")).toBe(36); + + // some locales make du print a comma decimal mark + expect(storageValueToGiB("1,5 TB")).toBe(1536); + + // case insensitive, tolerant of surrounding whitespace + expect(storageValueToGiB(" 1 tb ")).toBe(1024); +}); + +test("returns 0 rather than NaN for anything unparseable", () => { + for (const value of [undefined, null, "", "n/a", "GB", "1 ZB", "abc GB"]) { + expect(storageValueToGiB(value)).toBe(0); + } +}); diff --git a/launcher/src/background.js b/launcher/src/background.js index e847de5acf..8f5b05ff70 100755 --- a/launcher/src/background.js +++ b/launcher/src/background.js @@ -810,6 +810,10 @@ ipcMain.handle("handleOTPChange", async (event, args) => { ); }); +ipcMain.handle("getExternalIp", async () => { + return await serviceManager.resolveExternalIp(); +}); + ipcMain.handle("fetchObolCharonAlerts", async () => { return await monitoring.fetchObolCharonAlerts(); }); @@ -886,7 +890,8 @@ function registerAppProtocol() { // Content-Security-Policy: no 'unsafe-eval' (all runtime eval was removed; i18n uses JIT // compilation). connect-src stays permissive because the renderer talks to arbitrary // nodes/RPC endpoints/APIs; style 'unsafe-inline' covers Vite's dev style injection and -// scoped styles. Applied in dev + prod (also silences Electron's insecure-CSP warning). +// scoped styles; media-src allows data: for the inlined wav sound effects. Applied +// in dev + prod (also silences Electron's insecure-CSP warning). function registerContentSecurityPolicy() { const csp = [ "default-src 'self'", @@ -894,6 +899,7 @@ function registerContentSecurityPolicy() { "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob: https:", + "media-src 'self' data:", "connect-src 'self' http: https: ws: wss: data:", "worker-src 'self' blob:", ].join("; "); diff --git a/launcher/src/components/UI/base-header/components/modals/NotifModal.vue b/launcher/src/components/UI/base-header/components/modals/NotifModal.vue index 1053fb72e1..1ffd4260aa 100755 --- a/launcher/src/components/UI/base-header/components/modals/NotifModal.vue +++ b/launcher/src/components/UI/base-header/components/modals/NotifModal.vue @@ -130,7 +130,9 @@ export default { }); } - return test.filter((obj) => obj.category === "validator" && obj.name !== "ssv.network" && obj.name !== "Obol Charon"); + return test.filter( + (obj) => obj.category === "validator" && obj.name !== "ssv.network" && obj.name !== "Obol Charon" && obj.name !== "Pluto" + ); }, installedMetricsExporter() { const copyOfInstalledServices = [...this.installedServices]; diff --git a/launcher/src/components/UI/base-header/components/services/ServiceBody.vue b/launcher/src/components/UI/base-header/components/services/ServiceBody.vue index ec9a211b4f..04f40dc422 100644 --- a/launcher/src/components/UI/base-header/components/services/ServiceBody.vue +++ b/launcher/src/components/UI/base-header/components/services/ServiceBody.vue @@ -5,6 +5,7 @@ diff --git a/launcher/src/composables/services.js b/launcher/src/composables/services.js index 78728a0b19..7e989a37c7 100644 --- a/launcher/src/composables/services.js +++ b/launcher/src/composables/services.js @@ -64,55 +64,64 @@ export async function useFrontendServices() { if (services && services.length != 0 && nodeHeaderStore.refresh) { let otherServices = []; let needForTunnel = []; - const newServices = services.map((service) => { - let oldService; - if ( - serviceStore.installedServices && - serviceStore.installedServices.map((s) => s?.config?.serviceID).includes(service?.config?.serviceID) - ) { - oldService = serviceStore.installedServices.find( - (s) => s.service === service.service && s?.config?.serviceID && s?.config?.serviceID === service?.config?.serviceID - ); - } else { - oldService = allServices.find((s) => s.service === service.service); - if (oldService?.tunnelLink) needForTunnel.push(oldService); - } - if (oldService?.config?.keys) { - oldService.config = { - ...service?.config, - keys: oldService?.config?.keys, - }; - } else { - oldService.config = service?.config; - } - oldService.state = service.state; - if ( - (oldService.service === "TekuBeaconService" || oldService.service === "NimbusBeaconService") && - oldService?.config?.configVersion && - oldService?.config?.configVersion < 2 - ) { - let existing = serviceStore.installedServices.find( - (s) => s?.config?.serviceID === oldService?.config?.serviceID && s?.service === oldService.name + "ValidatorService" - ); - let vs; - if (existing) { - vs = existing; + const newServices = services + .map((service) => { + let oldService; + if ( + serviceStore.installedServices && + serviceStore.installedServices.map((s) => s?.config?.serviceID).includes(service?.config?.serviceID) + ) { + oldService = serviceStore.installedServices.find( + (s) => s.service === service.service && s?.config?.serviceID && s?.config?.serviceID === service?.config?.serviceID + ); + // a client swapped in place keeps its id but changes its name, so + // the id matches while the name does not + if (!oldService) oldService = allServices.find((s) => s.service === service.service); } else { - vs = allServices.find((element) => element.service === oldService.name + "ValidatorService"); + oldService = allServices.find((s) => s.service === service.service); + if (oldService?.tunnelLink) needForTunnel.push(oldService); } - if (vs.service === "TekuValidatorService") { - vs.icon = "/img/icon/service-icons/validator/Teku-Validator-Linked-Circle.png"; - vs.sIcon = "/img/icon/service-icons/validator/Teku-Validator-Linked-s.png"; - } else if (vs.service === "NimbusValidatorService") { - vs.icon = "/img/icon/service-icons/validator/Nimbus-Validator-Linked-Circle.png"; - vs.sIcon = "/img/icon/service-icons/validator/Nimbus-Validator-Linked-s.png"; + if (!oldService) { + console.error(`No catalog entry for ${service.service}, skipping it`); + return null; } - vs.config = oldService?.config; - vs.state = oldService.state; - otherServices.push(vs); - } - return oldService; - }); + if (oldService?.config?.keys) { + oldService.config = { + ...service?.config, + keys: oldService?.config?.keys, + }; + } else { + oldService.config = service?.config; + } + oldService.state = service.state; + if ( + (oldService.service === "TekuBeaconService" || oldService.service === "NimbusBeaconService") && + oldService?.config?.configVersion && + oldService?.config?.configVersion < 2 + ) { + let existing = serviceStore.installedServices.find( + (s) => s?.config?.serviceID === oldService?.config?.serviceID && s?.service === oldService.name + "ValidatorService" + ); + let vs; + if (existing) { + vs = existing; + } else { + vs = allServices.find((element) => element.service === oldService.name + "ValidatorService"); + } + if (vs.service === "TekuValidatorService") { + vs.icon = "/img/icon/service-icons/validator/Teku-Validator-Linked-Circle.png"; + vs.sIcon = "/img/icon/service-icons/validator/Teku-Validator-Linked-s.png"; + } else if (vs.service === "NimbusValidatorService") { + vs.icon = "/img/icon/service-icons/validator/Nimbus-Validator-Linked-Circle.png"; + vs.sIcon = "/img/icon/service-icons/validator/Nimbus-Validator-Linked-s.png"; + } + vs.config = oldService?.config; + vs.state = oldService.state; + otherServices.push(vs); + } + return oldService; + }) + .filter(Boolean); serviceStore.installedServices = newServices.concat(otherServices).map((service, i) => { service.id = i; if (!service.setupId) { diff --git a/launcher/src/composables/useConnectionLines.js b/launcher/src/composables/useConnectionLines.js index 254b34f4fc..07bd221dc8 100644 --- a/launcher/src/composables/useConnectionLines.js +++ b/launcher/src/composables/useConnectionLines.js @@ -1,5 +1,6 @@ import { useServices } from "@/store/services"; import { ref } from "vue"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export function useConnectionLines() { const serviceStore = useServices(); @@ -68,7 +69,7 @@ export function useConnectionLines() { validator: (item, dependencies) => { return dependencies.flatMap((d) => { if (d.category === "validator") { - if (item.service === "CharonService") { + if (isObolDVTService(item.service)) { return [createConnection(item.ref, d.ref, "left", "left")]; } return [createConnection(d.ref, item.ref, "left", "left")]; diff --git a/launcher/src/composables/utils.js b/launcher/src/composables/utils.js index a10f43a7e3..e52df0313a 100644 --- a/launcher/src/composables/utils.js +++ b/launcher/src/composables/utils.js @@ -22,3 +22,28 @@ export function saveToFile(fileName, fileContent, fileType = "text/plain") { link.click(); document.body.removeChild(link); } + +// "du -h" reports binary units, which Monitoring.getStorageStatus labels +// B/KB/MB/GB/TB/PB. Sizes are compared against totalDisk, which comes from +// "df --total -BG" and is therefore in GiB, so everything is converted to GiB +// here. The ladder has to cover every suffix: a missing one silently reads +// e.g. "1.2 TB" as 1.2 GB, which is a thousandfold understatement. +const GIB_PER_UNIT = { + B: 1 / 1024 ** 3, + KB: 1 / 1024 ** 2, + MB: 1 / 1024, + GB: 1, + TB: 1024, + PB: 1024 ** 2, +}; + +/** + * @param {string} storageValue - a size such as "1.2 TB" or "450 GB" + * @returns {number} the size in GiB, or 0 if it cannot be parsed + */ +export function storageValueToGiB(storageValue) { + // some locales make "du -h" print "1,2T", so accept a comma decimal mark + const match = /^\s*(\d+(?:[.,]\d+)?)\s*([KMGTP]?B)\s*$/i.exec(storageValue ?? ""); + if (!match) return 0; + return parseFloat(match[1].replace(",", ".")) * GIB_PER_UNIT[match[2].toUpperCase()]; +} diff --git a/launcher/src/composables/validators.js b/launcher/src/composables/validators.js index e67e66e6a5..55a3f5bb42 100644 --- a/launcher/src/composables/validators.js +++ b/launcher/src/composables/validators.js @@ -3,6 +3,7 @@ import { useServices } from "@/store/services"; import { useNodeManage } from "@/store/nodeManage"; import { useStakingStore } from "@/store/theStaking"; import axios from "axios"; +import { isObolDVTService } from "@/share/ObolDVTServices"; export async function useListKeys(forceRefresh) { const serviceStore = useServices(); @@ -18,7 +19,7 @@ export async function useListKeys(forceRefresh) { if ((client.config.keys === undefined || client.config.keys.length === 0 || forceRefresh) && client.state === "running") { //refresh validator list let result = await ControlService.listValidators(client.config.serviceID); - if (!/Web3Signer|CharonService|SSVNetwork/.test(client.service)) { + if (!/Web3Signer|SSVNetwork/.test(client.service) && !isObolDVTService(client.service)) { let resultRemote = await ControlService.listRemoteKeys(client.config.serviceID); let remoteKeys = resultRemote.data ? resultRemote.data.map((e) => { @@ -222,7 +223,7 @@ export async function useUpdateValidatorStats() { export async function useObolStats() { const stakingStore = useStakingStore(); - if (stakingStore.selectedServiceToFilter?.service === "CharonService") { + if (isObolDVTService(stakingStore.selectedServiceToFilter?.service)) { ControlService.getObolClusterInformation(stakingStore.selectedServiceToFilter?.config?.serviceID).then((data) => { stakingStore.obolStats = data; }); diff --git a/launcher/src/composables/version.js b/launcher/src/composables/version.js index c115c8f93b..dbeec49df2 100644 --- a/launcher/src/composables/version.js +++ b/launcher/src/composables/version.js @@ -34,7 +34,15 @@ export async function useUpdateCheck() { if (!nodeHeaderStore.updating) { nodeHeaderStore.searchingForUpdates = true; - let services = await useBackendServices(); + let services; + try { + services = await useBackendServices(); + } catch (error) { + // reachable with no node connected, e.g. settings opened from the login screen + console.log("Couldn't read services, skipping the update check:", error.message); + nodeHeaderStore.searchingForUpdates = false; + return; + } let versions = {}; try { diff --git a/launcher/src/share/ObolDVTServices.js b/launcher/src/share/ObolDVTServices.js new file mode 100644 index 0000000000..cb48e08dad --- /dev/null +++ b/launcher/src/share/ObolDVTServices.js @@ -0,0 +1,8 @@ +// Obol DV middleware. Pluto is Nethermind's Rust reimplementation of Charon: +// same CLI, CHARON_* env vars, ".charon" layout and ports, so consumers treat +// them alike. +export const OBOL_DVT_SERVICES = ["CharonService", "PlutoService"]; + +export function isObolDVTService(serviceName) { + return OBOL_DVT_SERVICES.includes(serviceName); +} diff --git a/launcher/src/store/ControlService.js b/launcher/src/store/ControlService.js index 2c34a338f7..7b6e94aad2 100755 --- a/launcher/src/store/ControlService.js +++ b/launcher/src/store/ControlService.js @@ -709,6 +709,10 @@ class ControlService extends EventEmitter { return this.promiseIpc.send("handleOTPChange", args); } + async getExternalIp() { + return this.promiseIpc.send("getExternalIp"); + } + async fetchObolCharonAlerts() { return this.promiseIpc.send("fetchObolCharonAlerts"); } diff --git a/launcher/src/store/nodeHeader.js b/launcher/src/store/nodeHeader.js index 7fbea89d87..ce01298bd0 100755 --- a/launcher/src/store/nodeHeader.js +++ b/launcher/src/store/nodeHeader.js @@ -96,6 +96,7 @@ export const useNodeHeader = defineStore("nodeHeader", { this.showMevboostWindow = true; break; case "CharonService": + case "PlutoService": this.showObolCharonWindow = true; break; case "SSVDKGService": diff --git a/launcher/src/store/services.js b/launcher/src/store/services.js index cce27a35ed..7c54edc2ea 100755 --- a/launcher/src/store/services.js +++ b/launcher/src/store/services.js @@ -1,4 +1,39 @@ import { defineStore } from "pinia"; + +/** + * The address a client advertises to its peers. Every client spells the flag + * differently and geth derived ones want the value as "extip:". Rendered as + * a text field with a detect button, see "autoDetect" in ExpertWindow. + */ +const externalIpOption = (command, valuePrefix = "") => ({ + title: "P2P External IP", + type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", + changeValue: null, + valuePrefix: valuePrefix, + icon: "/img/icon/service-setting-icons/ip-address.png", + pattern: [`(- ${command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=)(.*)(\\n)`], + commands: [command], +}); + +/** + * Lodestar's Node.js runtime flags. The beacon ships + * "--max-old-space-size=8192" already; the validator ships with an empty env. + * An env var rather than a flag, hence "isENV" and the ": " in the command. + */ +const nodeOptionsOption = () => ({ + title: "Node Options", + type: "text", + changeValue: null, + placeholder: "--max-old-space-size=8192", + icon: "/img/icon/service-setting-icons/ram.png", + pattern: ["(NODE_OPTIONS: )(.*)(\\n)"], + commands: ["NODE_OPTIONS: "], + isENV: true, +}); + export const useServices = defineStore("services", { state: () => { return { @@ -47,6 +82,9 @@ export const useServices = defineStore("services", { { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --enr-address=)(.*)(\\n)"], @@ -216,6 +254,9 @@ export const useServices = defineStore("services", { { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["( --p2p-host-ip=)(.*)(\\n)"], @@ -414,6 +455,9 @@ export const useServices = defineStore("services", { { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --nat:extip:)(.*)(\\n)"], @@ -617,6 +661,9 @@ export const useServices = defineStore("services", { { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --p2p-advertised-ip=)(.*)(\\n)"], @@ -906,6 +953,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "Gas Limit", type: "text", @@ -1022,6 +1070,9 @@ export const useServices = defineStore("services", { { title: "P2P Host Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --p2p-host=)(.*)(\\n)"], @@ -1124,6 +1175,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--Network.ExternalIp"), { title: "Pruning Mode", type: "select", @@ -1407,9 +1459,13 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + nodeOptionsOption(), { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --enr.ip=)(.*)(\\n)"], @@ -1503,6 +1559,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + nodeOptionsOption(), { title: "Default Fee Recipient", type: "text", @@ -1560,6 +1617,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "Gas Limit", type: "text", @@ -1764,6 +1822,39 @@ export const useServices = defineStore("services", { network: "", }, }, + { + id: 41, + name: "Pluto", + service: "PlutoService", + category: "validator", + displayCategory: "vlc", + displayTooltip: false, + displayPluginMenu: false, + serviceIsPending: false, + modifierPanel: false, + replacePanel: false, + addPanel: false, + path: "/pluto", + icon: "/img/icon/service-icons/validator/Pluto.png", + sIcon: "/img/icon/service-icons/validator/Pluto-s.png", + hIcon: "/img/icon/service-icons/validator/Pluto-s.png", + linkUrl: "", + docsUrl: "https://github.com/NethermindEth/pluto", + headerOption: true, + expertOptionsModal: false, + expertOptions: [externalIpOption("--p2p-external-ip")], + drag: true, + state: "exited", + config: { + serviceID: "", + configVersion: "", + image: "", + imageVersion: "", + ports: [], + volumes: [], + network: "", + }, + }, { id: 25, name: "Reth", @@ -1784,6 +1875,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "Gas Limit", type: "text", @@ -2137,6 +2229,9 @@ export const useServices = defineStore("services", { { title: "External IP Address", type: "text", + autoDetect: "externalIp", + detecting: false, + placeholder: "", changeValue: null, icon: "/img/icon/service-setting-icons/ip-address.png", pattern: ["(- --enr-address=)(.*)(\\n)"], @@ -2236,6 +2331,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "Sync Mode", type: "text", @@ -2351,6 +2447,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--p2p.advertise.ip"), { title: "RPC Address", type: "text", @@ -2481,6 +2578,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "RPC Authentication Host Address", type: "text", @@ -2572,6 +2670,7 @@ export const useServices = defineStore("services", { headerOption: false, expertOptionsModal: false, expertOptions: [ + externalIpOption("--nat", "extip:"), { title: "RPC Authentication Host Address", type: "text", @@ -2654,7 +2753,7 @@ export const useServices = defineStore("services", { sIcon: "/img/icon/service-icons/execution/Ethrex-s.png", headerOption: false, expertOptionsModal: false, - expertOptions: [], + expertOptions: [externalIpOption("--nat.extip")], drag: true, state: "exited", config: {