From fbfaa9134298793b57601b946948e16a5a305da6 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:53:50 -0300 Subject: [PATCH 1/8] Add images.env file pinning image digests --- .gitignore | 1 + addons/constructors.go | 4 +- addons/graffiti_wall_writer/addon.go | 13 +- addons/graffiti_wall_writer/config.go | 9 +- rocketpool-cli/service/service.go | 4 + shared/services/config/alertmanager-config.go | 6 +- .../config/{besu-params.go => besu-config.go} | 6 +- shared/services/config/commit-boost-config.go | 9 +- .../{erigon-params.go => erigon-config.go} | 6 +- shared/services/config/exporter-config.go | 7 +- shared/services/config/external-configs.go | 20 +- .../config/{geth-params.go => geth-config.go} | 6 +- shared/services/config/grafana-config.go | 7 +- shared/services/config/images.go | 546 ++++++++++++++++++ shared/services/config/images_test.go | 205 +++++++ shared/services/config/lighthouse-config.go | 8 +- shared/services/config/lodestar-config.go | 6 +- shared/services/config/mev-boost-config.go | 6 +- ...hermind-params.go => nethermind-config.go} | 6 +- .../services/config/networks-config_test.go | 2 +- shared/services/config/nimbus-config.go | 16 +- shared/services/config/prometheus-config.go | 7 +- shared/services/config/prysm-config.go | 12 +- .../config/{reth-params.go => reth-config.go} | 6 +- shared/services/config/rocket-pool-config.go | 43 +- shared/services/config/smartnode-config.go | 5 + shared/services/config/teku-config.go | 6 +- shared/services/rocketpool/assets/assets.go | 9 + .../rocketpool/assets/install/images.env | 49 ++ .../templates/addons/gww/addon_gww.tmpl | 2 +- .../install/templates/alertmanager.tmpl | 2 +- .../install/templates/commit-boost.tmpl | 2 +- .../assets/install/templates/eth1.tmpl | 2 +- .../assets/install/templates/eth2.tmpl | 4 +- .../assets/install/templates/exporter.tmpl | 2 +- .../assets/install/templates/grafana.tmpl | 2 +- .../assets/install/templates/mev-boost.tmpl | 2 +- .../assets/install/templates/node.tmpl | 2 +- .../assets/install/templates/prometheus.tmpl | 2 +- .../assets/install/templates/validator.tmpl | 2 +- .../assets/install/templates/watchtower.tmpl | 2 +- .../rocketpool/assets/scripts/install.sh | 1 + shared/services/rocketpool/client.go | 35 +- 43 files changed, 961 insertions(+), 131 deletions(-) rename shared/services/config/{besu-params.go => besu-config.go} (94%) rename shared/services/config/{erigon-params.go => erigon-config.go} (93%) rename shared/services/config/{geth-params.go => geth-config.go} (93%) create mode 100644 shared/services/config/images.go create mode 100644 shared/services/config/images_test.go rename shared/services/config/{nethermind-params.go => nethermind-config.go} (97%) rename shared/services/config/{reth-params.go => reth-config.go} (95%) create mode 100644 shared/services/rocketpool/assets/install/images.env diff --git a/.gitignore b/.gitignore index f44b0b37e..ba7615e21 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ vendor node_modules /scripts/config.js +/scripts/pin-images.sh /rocketpool/rocketpool /rocketpool-cli/rocketpool-cli-darwin-amd64 /rocketpool-cli/rocketpool-cli-linux-amd64 diff --git a/addons/constructors.go b/addons/constructors.go index d7c8f639f..a3fd9aced 100644 --- a/addons/constructors.go +++ b/addons/constructors.go @@ -6,8 +6,8 @@ import ( "github.com/rocket-pool/smartnode/shared/types/addons" ) -func NewGraffitiWallWriter() addons.SmartnodeAddon { - return graffiti_wall_writer.NewGraffitiWallWriter() +func NewGraffitiWallWriter(containerTag string) addons.SmartnodeAddon { + return graffiti_wall_writer.NewGraffitiWallWriter(containerTag) } func NewRescueNode() addons.SmartnodeAddon { diff --git a/addons/graffiti_wall_writer/addon.go b/addons/graffiti_wall_writer/addon.go index dffe2476a..d9a19361b 100644 --- a/addons/graffiti_wall_writer/addon.go +++ b/addons/graffiti_wall_writer/addon.go @@ -16,9 +16,9 @@ type GraffitiWallWriter struct { cfg *GraffitiWallWriterConfig `yaml:"config,omitempty"` } -func NewGraffitiWallWriter() addons.SmartnodeAddon { +func NewGraffitiWallWriter(containerTag string) addons.SmartnodeAddon { return &GraffitiWallWriter{ - cfg: NewConfig(), + cfg: NewConfig(containerTag), } } @@ -43,5 +43,12 @@ func (gww *GraffitiWallWriter) GetEnabledParameter() *cfgtypes.Parameter { } func (gww *GraffitiWallWriter) GetContainerTag() string { - return containerTag + if gww.cfg.ContainerTag.Value != nil { + return gww.cfg.ContainerTag.Value.(string) + } + def, err := gww.cfg.ContainerTag.GetDefault(cfgtypes.Network_All) + if err != nil { + return "" + } + return def.(string) } diff --git a/addons/graffiti_wall_writer/config.go b/addons/graffiti_wall_writer/config.go index 87fce876a..e3cf2a9b0 100644 --- a/addons/graffiti_wall_writer/config.go +++ b/addons/graffiti_wall_writer/config.go @@ -4,11 +4,6 @@ import ( "github.com/rocket-pool/smartnode/shared/types/config" ) -// Constants -const ( - containerTag string = "rocketpool/graffiti-wall-addon:v1.0.1" -) - // Configuration for the Graffiti Wall Writer type GraffitiWallWriterConfig struct { Title string `yaml:"-"` @@ -31,7 +26,7 @@ type GraffitiWallWriterConfig struct { } // Creates a new configuration instance -func NewConfig() *GraffitiWallWriterConfig { +func NewConfig(containerTag string) *GraffitiWallWriterConfig { return &GraffitiWallWriterConfig{ Title: "Graffiti Wall Writer Settings", @@ -93,7 +88,7 @@ func NewConfig() *GraffitiWallWriterConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the container you want to use on Docker Hub.", + Description: "The tag name of the container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, Default: map[config.Network]any{config.Network_All: containerTag}, AffectsContainers: []config.ContainerID{ContainerID_GraffitiWallWriter}, diff --git a/rocketpool-cli/service/service.go b/rocketpool-cli/service/service.go index 45f87f3ee..f307dd0d0 100644 --- a/rocketpool-cli/service/service.go +++ b/rocketpool-cli/service/service.go @@ -81,6 +81,10 @@ func installService(yes, verbose, noDeps bool, path string) error { } } + if path == "" { + path = rp.ConfigPath() + } + // Install service err = rp.InstallService(verbose, noDeps, path, dataPath) if err != nil { diff --git a/shared/services/config/alertmanager-config.go b/shared/services/config/alertmanager-config.go index 45efb4602..9a8025601 100644 --- a/shared/services/config/alertmanager-config.go +++ b/shared/services/config/alertmanager-config.go @@ -14,7 +14,7 @@ import ( ) // Constants -const alertmanagerTag string = "prom/alertmanager:v0.26.0" + const AlertmanagerConfigTemplate string = "alerting/alertmanager.tmpl" const AlertmanagerConfigFile string = "alerting/alertmanager.yml" @@ -173,9 +173,9 @@ func NewAlertmanagerConfig(cfg *RocketPoolConfig) *AlertmanagerConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Alertmanager Container Tag", - Description: "The tag name of the Alertmanager container you want to use on Docker Hub.", + Description: "The tag name of the Alertmanager container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: alertmanagerTag}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageAlertmanager)}, AffectsContainers: []config.ContainerID{config.ContainerID_Alertmanager}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/besu-params.go b/shared/services/config/besu-config.go similarity index 94% rename from shared/services/config/besu-params.go rename to shared/services/config/besu-config.go index 2328e7a2a..9021ebd9f 100644 --- a/shared/services/config/besu-params.go +++ b/shared/services/config/besu-config.go @@ -6,8 +6,6 @@ import ( // Constants const ( - besuTagTest string = "hyperledger/besu:26.8.1" - besuTagProd string = "hyperledger/besu:26.8.1" besuEventLogInterval int = 1000 besuMaxPeers uint16 = 25 besuStopSignal string = "SIGTERM" @@ -95,9 +93,9 @@ func NewBesuConfig(cfg *RocketPoolConfig) *BesuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Besu container you want to use on Docker Hub.", + Description: "The tag name of the Besu container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, besuTagProd, besuTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageBesuProd), cfg.imageDefault(ImageBesuTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/commit-boost-config.go b/shared/services/config/commit-boost-config.go index 56b2b97b7..54b7c8340 100644 --- a/shared/services/config/commit-boost-config.go +++ b/shared/services/config/commit-boost-config.go @@ -11,8 +11,6 @@ import ( const ( CommitBoostConfigFile string = "cb_config.toml" CommitBoostConfigTemplate string = "commit-boost-config" - commitBoostProdTag string = "ghcr.io/commit-boost/commit-boost:v0.10.1" - commitBoostTestTag string = "ghcr.io/commit-boost/commit-boost:v0.10.1" ) // Relay selection mode for Commit-Boost PBS @@ -175,12 +173,12 @@ func NewCommitBoostConfig(cfg *RocketPoolConfig) *CommitBoostConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Commit-Boost container you want to use.", + Description: "The tag name of the Commit-Boost container you want to use. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", AffectsContainers: []config.ContainerID{config.ContainerID_CommitBoost}, CanBeBlank: false, OverwriteOnUpgrade: true, Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, commitBoostProdTag, commitBoostTestTag), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageCommitBoostProd), cfg.imageDefault(ImageCommitBoostTest)), }, ExternalUrl: config.Parameter{ @@ -343,6 +341,9 @@ func (cfg *CommitBoostConfig) GetRelayString() string { // Get the container tag value as a string (for use in templates) func (cfg *CommitBoostConfig) GetContainerTag() string { + if cfg.parentConfig != nil { + return cfg.parentConfig.resolveParamImage(&cfg.ContainerTag, ImageCommitBoostProd, ImageCommitBoostTest) + } return fmt.Sprint(cfg.ContainerTag.Value) } diff --git a/shared/services/config/erigon-params.go b/shared/services/config/erigon-config.go similarity index 93% rename from shared/services/config/erigon-params.go rename to shared/services/config/erigon-config.go index c5111885e..b5ac29443 100644 --- a/shared/services/config/erigon-params.go +++ b/shared/services/config/erigon-config.go @@ -8,8 +8,6 @@ import ( // Constants const ( - erigonTagProd string = "erigontech/erigon:v3.6.1" - erigonTagTest string = "erigontech/erigon:v3.6.1" erigonEventLogInterval int = 1000 erigonStopSignal string = "SIGINT" defaultErigonTorrentPort uint16 = 42069 @@ -83,9 +81,9 @@ func NewErigonConfig(cfg *RocketPoolConfig) *ErigonConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Erigon container you want to use on Docker Hub.", + Description: "The tag name of the Erigon container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, erigonTagProd, erigonTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageErigonProd), cfg.imageDefault(ImageErigonTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/exporter-config.go b/shared/services/config/exporter-config.go index 707f52b20..56f0eee30 100644 --- a/shared/services/config/exporter-config.go +++ b/shared/services/config/exporter-config.go @@ -4,9 +4,6 @@ import ( "github.com/rocket-pool/smartnode/shared/types/config" ) -// Constants -const exporterTag string = "prom/node-exporter:v1.12.1" - // Defaults const defaultExporterRootFs bool = false @@ -43,9 +40,9 @@ func NewExporterConfig(cfg *RocketPoolConfig) *ExporterConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Exporter Container Tag", - Description: "The tag name of the Prometheus Node Exporter container you want to use on Docker Hub.", + Description: "The tag name of the Prometheus Node Exporter container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: exporterTag}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageExporter)}, AffectsContainers: []config.ContainerID{config.ContainerID_Exporter}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/external-configs.go b/shared/services/config/external-configs.go index 2e7fb3fc2..fbbb202b2 100644 --- a/shared/services/config/external-configs.go +++ b/shared/services/config/external-configs.go @@ -237,9 +237,9 @@ func NewExternalLighthouseConfig(cfg *RocketPoolConfig) *ExternalLighthouseConfi ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lighthouse container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Lighthouse container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, lighthouseTagPortableProd, lighthouseTagPortableTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLighthouseProd), cfg.imageDefault(ImageLighthouseTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -322,9 +322,9 @@ func NewExternalLodestarConfig(cfg *RocketPoolConfig) *ExternalLodestarConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lodestar container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Lodestar container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, lodestarTagProd, lodestarTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLodestarProd), cfg.imageDefault(ImageLodestarTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -408,9 +408,9 @@ func NewExternalNimbusConfig(cfg *RocketPoolConfig) *ExternalNimbusConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Nimbus validator container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Nimbus validator container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, nimbusVcTagProd, nimbusVcTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusVcProd), cfg.imageDefault(ImageNimbusVcTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -504,9 +504,9 @@ func NewExternalPrysmConfig(cfg *RocketPoolConfig) *ExternalPrysmConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Prysm validator container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Prysm validator container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, prysmVcProd, prysmVcTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmVcProd), cfg.imageDefault(ImagePrysmVcTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -589,9 +589,9 @@ func NewExternalTekuConfig(cfg *RocketPoolConfig) *ExternalTekuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Teku container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Teku container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, tekuTagProd, tekuTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageTekuProd), cfg.imageDefault(ImageTekuTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/geth-params.go b/shared/services/config/geth-config.go similarity index 93% rename from shared/services/config/geth-params.go rename to shared/services/config/geth-config.go index d2d09e7d8..f52d7b8b9 100644 --- a/shared/services/config/geth-params.go +++ b/shared/services/config/geth-config.go @@ -8,8 +8,6 @@ import ( // Constants const ( - gethTagProd string = "ethereum/client-go:v1.17.5" - gethTagTest string = "ethereum/client-go:v1.17.5" gethEventLogInterval int = 1000 gethStopSignal string = "SIGTERM" ) @@ -82,9 +80,9 @@ func NewGethConfig(cfg *RocketPoolConfig) *GethConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Geth container you want to use on Docker Hub.", + Description: "The tag name of the Geth container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, gethTagProd, gethTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageGethProd), cfg.imageDefault(ImageGethTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/grafana-config.go b/shared/services/config/grafana-config.go index e7da720d5..6f877775f 100644 --- a/shared/services/config/grafana-config.go +++ b/shared/services/config/grafana-config.go @@ -4,9 +4,6 @@ import ( "github.com/rocket-pool/smartnode/shared/types/config" ) -// Constants -const grafanaTag string = "grafana/grafana:9.5.18" - // Defaults const defaultGrafanaPort uint16 = 3100 const defaultGrafanaOpenPort string = string(config.RPC_OpenExternal) @@ -57,9 +54,9 @@ func NewGrafanaConfig(cfg *RocketPoolConfig) *GrafanaConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Grafana Container Tag", - Description: "The tag name of the Grafana container you want to use on Docker Hub.", + Description: "The tag name of the Grafana container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: grafanaTag}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageGrafana)}, AffectsContainers: []config.ContainerID{config.ContainerID_Grafana}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/images.go b/shared/services/config/images.go new file mode 100644 index 000000000..6b7204de7 --- /dev/null +++ b/shared/services/config/images.go @@ -0,0 +1,546 @@ +package config + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rocket-pool/smartnode/shared/services/rocketpool/assets" + "github.com/rocket-pool/smartnode/shared/types/config" +) + +const ( + ImagesEnvFile = "images.env" + + ImageSmartnode = "RP_IMAGE_SMARTNODE" + + ImageGethProd = "RP_IMAGE_GETH_PROD" + ImageGethTest = "RP_IMAGE_GETH_TEST" + ImageNethermindProd = "RP_IMAGE_NETHERMIND_PROD" + ImageNethermindTest = "RP_IMAGE_NETHERMIND_TEST" + ImageBesuProd = "RP_IMAGE_BESU_PROD" + ImageBesuTest = "RP_IMAGE_BESU_TEST" + ImageRethProd = "RP_IMAGE_RETH_PROD" + ImageRethTest = "RP_IMAGE_RETH_TEST" + ImageErigonProd = "RP_IMAGE_ERIGON_PROD" + ImageErigonTest = "RP_IMAGE_ERIGON_TEST" + + ImageLighthouseProd = "RP_IMAGE_LIGHTHOUSE_PROD" + ImageLighthouseTest = "RP_IMAGE_LIGHTHOUSE_TEST" + ImageLodestarProd = "RP_IMAGE_LODESTAR_PROD" + ImageLodestarTest = "RP_IMAGE_LODESTAR_TEST" + ImageNimbusBnProd = "RP_IMAGE_NIMBUS_BN_PROD" + ImageNimbusBnTest = "RP_IMAGE_NIMBUS_BN_TEST" + ImageNimbusVcProd = "RP_IMAGE_NIMBUS_VC_PROD" + ImageNimbusVcTest = "RP_IMAGE_NIMBUS_VC_TEST" + ImageTekuProd = "RP_IMAGE_TEKU_PROD" + ImageTekuTest = "RP_IMAGE_TEKU_TEST" + ImagePrysmBnProd = "RP_IMAGE_PRYSM_BN_PROD" + ImagePrysmBnTest = "RP_IMAGE_PRYSM_BN_TEST" + ImagePrysmVcProd = "RP_IMAGE_PRYSM_VC_PROD" + ImagePrysmVcTest = "RP_IMAGE_PRYSM_VC_TEST" + + ImageMevBoostProd = "RP_IMAGE_MEV_BOOST_PROD" + ImageMevBoostTest = "RP_IMAGE_MEV_BOOST_TEST" + ImageCommitBoostProd = "RP_IMAGE_COMMIT_BOOST_PROD" + ImageCommitBoostTest = "RP_IMAGE_COMMIT_BOOST_TEST" + + ImagePrometheus = "RP_IMAGE_PROMETHEUS" + ImageExporter = "RP_IMAGE_EXPORTER" + ImageAlertmanager = "RP_IMAGE_ALERTMANAGER" + ImageGrafana = "RP_IMAGE_GRAFANA" + ImageGWW = "RP_IMAGE_GWW" + ImageCurl = "RP_IMAGE_CURL" + ImageAlpine = "RP_IMAGE_ALPINE" +) + +// requiredImageKeys must be present in the official catalog. +var requiredImageKeys = []string{ + ImageSmartnode, + ImageGethProd, ImageGethTest, + ImageNethermindProd, ImageNethermindTest, + ImageBesuProd, ImageBesuTest, + ImageRethProd, ImageRethTest, + ImageErigonProd, ImageErigonTest, + ImageLighthouseProd, ImageLighthouseTest, + ImageLodestarProd, ImageLodestarTest, + ImageNimbusBnProd, ImageNimbusBnTest, + ImageNimbusVcProd, ImageNimbusVcTest, + ImageTekuProd, ImageTekuTest, + ImagePrysmBnProd, ImagePrysmBnTest, + ImagePrysmVcProd, ImagePrysmVcTest, + ImageMevBoostProd, ImageMevBoostTest, + ImageCommitBoostProd, ImageCommitBoostTest, + ImagePrometheus, ImageExporter, ImageAlertmanager, ImageGrafana, + ImageGWW, ImageCurl, ImageAlpine, +} + +// ImageCatalog is the parsed official (or overlay) image env file. +type ImageCatalog struct { + values map[string]string +} + +func (c *ImageCatalog) Get(key string) (string, bool) { + if c == nil { + return "", false + } + v, ok := c.values[key] + return v, ok +} + +func (c *ImageCatalog) Must(key string) string { + v, ok := c.Get(key) + if !ok || v == "" { + panic("missing image catalog key " + key) + } + return v +} + +func (c *ImageCatalog) Set(key, value string) { + if c == nil { + return + } + if c.values == nil { + c.values = map[string]string{} + } + c.values[key] = value +} + +func (c *ImageCatalog) Map() map[string]string { + if c == nil { + return nil + } + out := make(map[string]string, len(c.values)) + for k, v := range c.values { + out[k] = v + } + return out +} + +func EnvRef(key string) string { + return "${" + key + "}" +} + +// ParseEnvFile parses KEY=VALUE lines. Comments and blank lines are ignored. +// Quoted values have a single pair of matching quotes stripped. +func ParseEnvFile(data []byte) (map[string]string, error) { + out := map[string]string{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("line %d: empty key", lineNo) + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + out[key] = value + } + if err := scanner.Err(); err != nil { + return nil, err + } + return out, nil +} + +func LoadEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseEnvFile(data) +} + +// LoadImages loads the official catalog: embed, then on-disk images.env if present. +func LoadImages(rpDir string) (*ImageCatalog, error) { + values, err := ParseEnvFile(assets.ImagesEnv()) + if err != nil { + return nil, fmt.Errorf("could not parse embedded %s: %w", ImagesEnvFile, err) + } + if rpDir != "" { + diskPath := filepath.Join(rpDir, ImagesEnvFile) + if _, err := os.Stat(diskPath); err == nil { + diskValues, err := LoadEnvFile(diskPath) + if err != nil { + return nil, fmt.Errorf("could not load %s: %w", diskPath, err) + } + values = diskValues + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("could not stat %s: %w", diskPath, err) + } + } + if err := validateImageCatalog(values); err != nil { + return nil, err + } + return &ImageCatalog{values: values}, nil +} + +func validateImageCatalog(values map[string]string) error { + var missing []string + for _, key := range requiredImageKeys { + if v, ok := values[key]; !ok || v == "" { + missing = append(missing, key) + } + } + if len(missing) > 0 { + return fmt.Errorf("%s is missing required keys: %s", ImagesEnvFile, strings.Join(missing, ", ")) + } + return nil +} + +// UpdateEnvFile sets keys in an existing env file in place, preserving comments and other lines. +func UpdateEnvFile(path string, updates map[string]string) error { + if len(updates) == 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + remaining := make(map[string]string, len(updates)) + for k, v := range updates { + remaining[k] = v + } + var b strings.Builder + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + k, _, ok := strings.Cut(trimmed, "=") + k = strings.TrimSpace(k) + if ok { + if value, found := remaining[k]; found { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(value) + b.WriteByte('\n') + delete(remaining, k) + continue + } + } + } + b.WriteString(line) + b.WriteByte('\n') + } + if err := scanner.Err(); err != nil { + return err + } + keys := make([]string, 0, len(remaining)) + for k := range remaining { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(remaining[k]) + b.WriteByte('\n') + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// WriteEnvFile writes KEY=VALUE lines (sorted). +func WriteEnvFile(path string, values map[string]string) error { + var b strings.Builder + // Stable-ish: iterate a sorted key list + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(values[k]) + b.WriteByte('\n') + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func (cfg *RocketPoolConfig) imageDefault(key string) string { + return cfg.images.Must(key) +} + +func (cfg *RocketPoolConfig) clientImageKey(prodKey, testKey string) string { + info := cfg.GetNetworkInfo() + if info != nil && info.ClientTagSet == config.ClientTagSetProduction { + return prodKey + } + return testKey +} + +func (cfg *RocketPoolConfig) LoadedImages() *ImageCatalog { + return cfg.images +} + +func (cfg *RocketPoolConfig) ImageEnvPath() string { + return filepath.Join(cfg.RocketPoolDirectory, ImagesEnvFile) +} + +// GetECImageEnvRef returns the compose interpolation for the selected execution client. +func (cfg *RocketPoolConfig) GetECImageEnvRef() (string, error) { + if !cfg.ExecutionClientLocal() { + return "", fmt.Errorf("Execution client is external, there is no container tag") + } + switch cfg.ExecutionClient.Value.(config.ExecutionClient) { + case config.ExecutionClient_Geth: + return EnvRef(cfg.clientImageKey(ImageGethProd, ImageGethTest)), nil + case config.ExecutionClient_Nethermind: + return EnvRef(cfg.clientImageKey(ImageNethermindProd, ImageNethermindTest)), nil + case config.ExecutionClient_Besu: + return EnvRef(cfg.clientImageKey(ImageBesuProd, ImageBesuTest)), nil + case config.ExecutionClient_Reth: + return EnvRef(cfg.clientImageKey(ImageRethProd, ImageRethTest)), nil + case config.ExecutionClient_Erigon: + return EnvRef(cfg.clientImageKey(ImageErigonProd, ImageErigonTest)), nil + } + return "", fmt.Errorf("Unknown Execution Client %s", string(cfg.ExecutionClient.Value.(config.ExecutionClient))) +} + +// GetBeaconImageEnvRef returns the compose interpolation for the selected beacon client. +func (cfg *RocketPoolConfig) GetBeaconImageEnvRef() (string, error) { + cCfg, err := cfg.GetSelectedConsensusClientConfig() + if err != nil { + return "", err + } + switch cCfg.(type) { + case *LighthouseConfig: + return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + case *LodestarConfig: + return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + case *NimbusConfig: + return EnvRef(cfg.clientImageKey(ImageNimbusBnProd, ImageNimbusBnTest)), nil + case *PrysmConfig: + return EnvRef(cfg.clientImageKey(ImagePrysmBnProd, ImagePrysmBnTest)), nil + case *TekuConfig: + return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + default: + return "", fmt.Errorf("unknown consensus client config %T", cCfg) + } +} + +// GetVCImageEnvRef returns the compose interpolation for the selected validator client. +func (cfg *RocketPoolConfig) GetVCImageEnvRef() (string, error) { + mode := cfg.ConsensusClientMode.Value.(config.Mode) + if mode == config.Mode_Local { + cCfg, err := cfg.GetSelectedConsensusClientConfig() + if err != nil { + return "", err + } + switch cCfg.(type) { + case *LighthouseConfig: + return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + case *LodestarConfig: + return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + case *NimbusConfig: + return EnvRef(cfg.clientImageKey(ImageNimbusVcProd, ImageNimbusVcTest)), nil + case *PrysmConfig: + return EnvRef(cfg.clientImageKey(ImagePrysmVcProd, ImagePrysmVcTest)), nil + case *TekuConfig: + return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + default: + return "", fmt.Errorf("unknown consensus client config %T", cCfg) + } + } + client := cfg.ExternalConsensusClient.Value.(config.ConsensusClient) + switch client { + case config.ConsensusClient_Lighthouse: + return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + case config.ConsensusClient_Lodestar: + return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + case config.ConsensusClient_Nimbus: + return EnvRef(cfg.clientImageKey(ImageNimbusVcProd, ImageNimbusVcTest)), nil + case config.ConsensusClient_Prysm: + return EnvRef(cfg.clientImageKey(ImagePrysmVcProd, ImagePrysmVcTest)), nil + case config.ConsensusClient_Teku: + return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + default: + return "", fmt.Errorf("unknown external consensus client [%v]", client) + } +} + +func (cfg *RocketPoolConfig) GetMevBoostImageEnvRef() string { + return EnvRef(cfg.clientImageKey(ImageMevBoostProd, ImageMevBoostTest)) +} + +func (cfg *RocketPoolConfig) GetCommitBoostImageEnvRef() string { + return EnvRef(cfg.clientImageKey(ImageCommitBoostProd, ImageCommitBoostTest)) +} + +type imageOverrideParam struct { + param *config.Parameter + prodKey string + testKey string +} + +func (cfg *RocketPoolConfig) imageOverrideParams() []imageOverrideParam { + gwwTag := cfg.GraffitiWallWriter.GetConfig().GetParameters() + var gwwContainer *config.Parameter + for _, p := range gwwTag { + if p.ID == "containerTag" { + gwwContainer = p + break + } + } + params := []imageOverrideParam{ + {&cfg.Geth.ContainerTag, ImageGethProd, ImageGethTest}, + {&cfg.Nethermind.ContainerTag, ImageNethermindProd, ImageNethermindTest}, + {&cfg.Besu.ContainerTag, ImageBesuProd, ImageBesuTest}, + {&cfg.Reth.ContainerTag, ImageRethProd, ImageRethTest}, + {&cfg.Erigon.ContainerTag, ImageErigonProd, ImageErigonTest}, + {&cfg.Lighthouse.ContainerTag, ImageLighthouseProd, ImageLighthouseTest}, + {&cfg.Lodestar.ContainerTag, ImageLodestarProd, ImageLodestarTest}, + {&cfg.Nimbus.BnContainerTag, ImageNimbusBnProd, ImageNimbusBnTest}, + {&cfg.Nimbus.VcContainerTag, ImageNimbusVcProd, ImageNimbusVcTest}, + {&cfg.Prysm.BnContainerTag, ImagePrysmBnProd, ImagePrysmBnTest}, + {&cfg.Prysm.VcContainerTag, ImagePrysmVcProd, ImagePrysmVcTest}, + {&cfg.Teku.ContainerTag, ImageTekuProd, ImageTekuTest}, + {&cfg.ExternalLighthouse.ContainerTag, ImageLighthouseProd, ImageLighthouseTest}, + {&cfg.ExternalLodestar.ContainerTag, ImageLodestarProd, ImageLodestarTest}, + {&cfg.ExternalNimbus.ContainerTag, ImageNimbusVcProd, ImageNimbusVcTest}, + {&cfg.ExternalPrysm.ContainerTag, ImagePrysmVcProd, ImagePrysmVcTest}, + {&cfg.ExternalTeku.ContainerTag, ImageTekuProd, ImageTekuTest}, + {&cfg.MevBoost.ContainerTag, ImageMevBoostProd, ImageMevBoostTest}, + {&cfg.CommitBoost.ContainerTag, ImageCommitBoostProd, ImageCommitBoostTest}, + {&cfg.Prometheus.ContainerTag, ImagePrometheus, ""}, + {&cfg.Grafana.ContainerTag, ImageGrafana, ""}, + {&cfg.Exporter.ContainerTag, ImageExporter, ""}, + {&cfg.Alertmanager.ContainerTag, ImageAlertmanager, ""}, + } + if gwwContainer != nil { + params = append(params, imageOverrideParam{gwwContainer, ImageGWW, ""}) + } + return params +} + +func (cfg *RocketPoolConfig) skipPersistingImageTags() { + for _, item := range cfg.imageOverrideParams() { + item.param.SkipUserSettings = true + } +} + +// tuiImageValues maps the current TUI container-tag values onto images.env keys +// for the selected network (_PROD vs _TEST). +func (cfg *RocketPoolConfig) tuiImageValues() map[string]string { + out := map[string]string{} + for _, item := range cfg.imageOverrideParams() { + if item.param.Value == nil { + continue + } + value := fmt.Sprint(item.param.Value) + if value == "" { + continue + } + key := item.prodKey + if item.testKey != "" { + key = cfg.clientImageKey(item.prodKey, item.testKey) + } + out[key] = value + } + return out +} + +// SaveImagesEnv writes current TUI container tags into images.env. +// A later `rocketpool service install` replaces that file with the official catalog. +func (cfg *RocketPoolConfig) SaveImagesEnv() error { + if cfg.RocketPoolDirectory == "" { + return nil + } + path := cfg.ImageEnvPath() + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + updates := cfg.tuiImageValues() + if err := UpdateEnvFile(path, updates); err != nil { + return fmt.Errorf("could not update %s: %w", path, err) + } + network := cfg.GetNetwork() + for _, item := range cfg.imageOverrideParams() { + if item.param.Value == nil { + continue + } + value := fmt.Sprint(item.param.Value) + if value == "" { + continue + } + key := item.prodKey + if item.testKey != "" { + key = cfg.clientImageKey(item.prodKey, item.testKey) + item.param.Default[network] = value + } else { + item.param.Default[config.Network_All] = value + } + cfg.images.Set(key, value) + } + return nil +} + +func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, prodKey, testKey string) string { + if param == nil { + return "" + } + value := fmt.Sprint(param.Value) + if cfg.Smartnode != nil && cfg.Smartnode.Network.Value != nil { + network := cfg.Smartnode.Network.Value.(config.Network) + if def, err := param.GetDefault(network); err == nil && value != fmt.Sprint(def) { + return value + } + } + key := prodKey + if testKey != "" { + key = cfg.clientImageKey(prodKey, testKey) + } + if v := cfg.ResolvedImage(key); v != "" { + return v + } + return value +} + +func (cfg *RocketPoolConfig) imageFromEnvRef(ref, tuiValue string) string { + key := strings.TrimSuffix(strings.TrimPrefix(ref, "${"), "}") + if cfg.images != nil { + if catalog, ok := cfg.images.Get(key); ok && tuiValue != "" && tuiValue != catalog { + return tuiValue + } + } + if v := cfg.ResolvedImage(key); v != "" { + return v + } + return tuiValue +} + +func (cfg *RocketPoolConfig) ResolvedImage(key string) string { + if cfg.images != nil { + if v, ok := cfg.images.Get(key); ok && v != "" { + return v + } + } + return "" +} diff --git a/shared/services/config/images_test.go b/shared/services/config/images_test.go new file mode 100644 index 000000000..c65b6f59a --- /dev/null +++ b/shared/services/config/images_test.go @@ -0,0 +1,205 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/config" +) + +func TestParseEnvFile(t *testing.T) { + data := []byte(` +# comment +RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:abc + +RP_IMAGE_CURL="curlimages/curl:8.13.0" +RP_IMAGE_ALPINE='alpine:3.21.3' +`) + got, err := ParseEnvFile(data) + if err != nil { + t.Fatal(err) + } + if got["RP_IMAGE_GETH_PROD"] != "ethereum/client-go:v1.17.5@sha256:abc" { + t.Fatalf("geth: %q", got["RP_IMAGE_GETH_PROD"]) + } + if got["RP_IMAGE_CURL"] != "curlimages/curl:8.13.0" { + t.Fatalf("curl: %q", got["RP_IMAGE_CURL"]) + } + if got["RP_IMAGE_ALPINE"] != "alpine:3.21.3" { + t.Fatalf("alpine: %q", got["RP_IMAGE_ALPINE"]) + } +} + +func TestParseEnvFileRejectsBadLine(t *testing.T) { + if _, err := ParseEnvFile([]byte("not-a-key-value")); err == nil { + t.Fatal("expected error") + } +} + +func TestLoadImagesEmbedded(t *testing.T) { + catalog, err := LoadImages("") + if err != nil { + t.Fatal(err) + } + if catalog.Must(ImageGethProd) == "" { + t.Fatal("empty geth prod") + } + if !strings.Contains(catalog.Must(ImageGethProd), "ethereum/client-go:") { + t.Fatalf("unexpected geth image %s", catalog.Must(ImageGethProd)) + } +} + +func TestLoadImagesPrefersDisk(t *testing.T) { + dir := t.TempDir() + embedded, err := LoadImages("") + if err != nil { + t.Fatal(err) + } + values := embedded.Map() + values[ImageGethProd] = "example.com/geth:custom" + if err := WriteEnvFile(filepath.Join(dir, ImagesEnvFile), values); err != nil { + t.Fatal(err) + } + catalog, err := LoadImages(dir) + if err != nil { + t.Fatal(err) + } + if catalog.Must(ImageGethProd) != "example.com/geth:custom" { + t.Fatalf("got %s", catalog.Must(ImageGethProd)) + } +} + +func TestUpdateEnvFilePreservesComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ImagesEnvFile) + if err := os.WriteFile(path, []byte("# comment\nRP_IMAGE_GETH_PROD=old/geth:1\nRP_IMAGE_CURL=keep/curl:1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := UpdateEnvFile(path, map[string]string{ImageGethProd: "my/geth:custom"}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "# comment") { + t.Fatalf("lost comment: %s", text) + } + parsed, err := ParseEnvFile(data) + if err != nil { + t.Fatal(err) + } + if parsed[ImageGethProd] != "my/geth:custom" { + t.Fatalf("geth %q", parsed[ImageGethProd]) + } + if parsed[ImageCurl] != "keep/curl:1" { + t.Fatalf("curl %q", parsed[ImageCurl]) + } +} + +func TestClientImageEnvRefs(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.ExecutionClientMode.Value = config.Mode_Local + cfg.ExecutionClient.Value = config.ExecutionClient_Geth + cfg.ConsensusClientMode.Value = config.Mode_Local + cfg.ConsensusClient.Value = config.ConsensusClient_Lighthouse + cfg.Smartnode.Network.Value = config.Network("mainnet") + + ref, err := cfg.GetECImageEnvRef() + if err != nil { + t.Fatal(err) + } + if ref != EnvRef(ImageGethProd) { + t.Fatalf("ec ref %s", ref) + } + bn, err := cfg.GetBeaconImageEnvRef() + if err != nil { + t.Fatal(err) + } + if bn != EnvRef(ImageLighthouseProd) { + t.Fatalf("bn ref %s", bn) + } + + cfg.ChangeNetwork(config.Network("testnet")) + ref, err = cfg.GetECImageEnvRef() + if err != nil { + t.Fatal(err) + } + if ref != EnvRef(ImageGethTest) { + t.Fatalf("ec test ref %s", ref) + } +} + +func TestComposeTemplatesUseEnvRefs(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.ExecutionClientMode.Value = config.Mode_Local + cfg.ExecutionClient.Value = config.ExecutionClient_Geth + cfg.ConsensusClientMode.Value = config.Mode_Local + cfg.ConsensusClient.Value = config.ConsensusClient_Lighthouse + cfg.Smartnode.Network.Value = config.Network("mainnet") + + ec, err := cfg.GetECImageEnvRef() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(ec, "${RP_IMAGE_") || !strings.HasSuffix(ec, "}") { + t.Fatalf("ec ref should be compose interpolation, got %s", ec) + } + bn, err := cfg.GetBeaconImageEnvRef() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(bn, "${RP_IMAGE_") { + t.Fatalf("bn ref %s", bn) + } +} + +func TestSaveImagesEnvWritesTuiOverrides(t *testing.T) { + dir := t.TempDir() + embedded, err := LoadImages("") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, ImagesEnvFile) + if err := WriteEnvFile(path, embedded.Map()); err != nil { + t.Fatal(err) + } + cfg := mustNewRocketPoolConfig(t, dir, false) + cfg.Smartnode.Network.Value = config.Network("mainnet") + cfg.Geth.ContainerTag.Value = "my/geth:custom" + if err := cfg.SaveImagesEnv(); err != nil { + t.Fatal(err) + } + parsed, err := LoadEnvFile(path) + if err != nil { + t.Fatal(err) + } + if parsed[ImageGethProd] != "my/geth:custom" { + t.Fatalf("images.env geth %q", parsed[ImageGethProd]) + } + if cfg.ResolvedImage(ImageGethProd) != "my/geth:custom" { + t.Fatalf("catalog geth %q", cfg.ResolvedImage(ImageGethProd)) + } + + // Reloading picks the TUI value from images.env, not user-settings.yml. + reloaded := mustNewRocketPoolConfig(t, dir, false) + reloaded.Smartnode.Network.Value = config.Network("mainnet") + if err := reloaded.Geth.ContainerTag.SetToDefault(config.Network("mainnet")); err != nil { + t.Fatal(err) + } + if reloaded.Geth.ContainerTag.Value != "my/geth:custom" { + t.Fatalf("reloaded geth default %v", reloaded.Geth.ContainerTag.Value) + } +} + +func TestContainerTagsAreNotSerialized(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.Geth.ContainerTag.Value = "my/geth:custom" + serialized := cfg.Serialize() + if tag, ok := serialized["geth"]["containerTag"]; ok { + t.Fatalf("container tags must not be saved to user-settings.yml, got %q", tag) + } +} diff --git a/shared/services/config/lighthouse-config.go b/shared/services/config/lighthouse-config.go index 73ec65a6f..1e3ec244d 100644 --- a/shared/services/config/lighthouse-config.go +++ b/shared/services/config/lighthouse-config.go @@ -5,9 +5,7 @@ import ( ) const ( - lighthouseTagPortableTest string = "sigp/lighthouse:v8.2.2" - lighthouseTagPortableProd string = "sigp/lighthouse:v8.2.2" - defaultLhMaxPeers uint16 = 100 + defaultLhMaxPeers uint16 = 100 ) // Configuration for Lighthouse @@ -65,9 +63,9 @@ func NewLighthouseConfig(cfg *RocketPoolConfig) *LighthouseConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lighthouse container you want to use from Docker Hub.", + Description: "The tag name of the Lighthouse container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, lighthouseTagPortableProd, lighthouseTagPortableTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLighthouseProd), cfg.imageDefault(ImageLighthouseTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/lodestar-config.go b/shared/services/config/lodestar-config.go index cd0856047..0e79f4f2a 100644 --- a/shared/services/config/lodestar-config.go +++ b/shared/services/config/lodestar-config.go @@ -5,8 +5,6 @@ import ( ) const ( - lodestarTagTest string = "chainsafe/lodestar:v1.48.0" - lodestarTagProd string = "chainsafe/lodestar:v1.48.0" defaultLodestarMaxPeers uint16 = 200 ) @@ -66,9 +64,9 @@ func NewLodestarConfig(cfg *RocketPoolConfig) *LodestarConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lodestar container you want to use from Docker Hub.", + Description: "The tag name of the Lodestar container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, lodestarTagProd, lodestarTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLodestarProd), cfg.imageDefault(ImageLodestarTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/mev-boost-config.go b/shared/services/config/mev-boost-config.go index 0993f176c..eb1197543 100644 --- a/shared/services/config/mev-boost-config.go +++ b/shared/services/config/mev-boost-config.go @@ -9,8 +9,6 @@ import ( // Constants const ( - mevBoostTagProd string = "flashbots/mev-boost:1.12.0" - mevBoostTagTest string = "flashbots/mev-boost:1.12.0" mevDocsUrl string = "https://docs.rocketpool.net/node-staking/mev" RegulatedRelayDescription string = "Select this to enable the relays that comply with government regulations (e.g. OFAC sanctions), " UnregulatedRelayDescription string = "Select this to enable the relays that do not follow any sanctions lists (do not censor transactions), " @@ -177,9 +175,9 @@ func NewMevBoostConfig(cfg *RocketPoolConfig) *MevBoostConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the MEV-Boost container you want to use on Docker Hub.", + Description: "The tag name of the MEV-Boost container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, mevBoostTagProd, mevBoostTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageMevBoostProd), cfg.imageDefault(ImageMevBoostTest)), AffectsContainers: []config.ContainerID{config.ContainerID_MevBoost}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/nethermind-params.go b/shared/services/config/nethermind-config.go similarity index 97% rename from shared/services/config/nethermind-params.go rename to shared/services/config/nethermind-config.go index 28c52abc3..c2a7b103c 100644 --- a/shared/services/config/nethermind-params.go +++ b/shared/services/config/nethermind-config.go @@ -10,8 +10,6 @@ import ( // Constants const ( - nethermindTagProd string = "nethermind/nethermind:1.39.3" - nethermindTagTest string = "nethermind/nethermind:1.39.3" nethermindEventLogInterval int = 1000 nethermindStopSignal string = "SIGTERM" ) @@ -168,9 +166,9 @@ func NewNethermindConfig(cfg *RocketPoolConfig) *NethermindConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Nethermind container you want to use on Docker Hub.", + Description: "The tag name of the Nethermind container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, nethermindTagProd, nethermindTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNethermindProd), cfg.imageDefault(ImageNethermindTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/networks-config_test.go b/shared/services/config/networks-config_test.go index c887c493a..42bf95c66 100644 --- a/shared/services/config/networks-config_test.go +++ b/shared/services/config/networks-config_test.go @@ -185,7 +185,7 @@ func TestOfficialGetterLock(t *testing.T) { if cfg.Smartnode.GetChainID() != 1 { t.Fatalf("mainnet chainID %d", cfg.Smartnode.GetChainID()) } - if tag, _ := cfg.Geth.ContainerTag.GetDefault(config.Network("mainnet")); tag != gethTagProd { + if tag, _ := cfg.Geth.ContainerTag.GetDefault(config.Network("mainnet")); tag != cfg.imageDefault(ImageGethProd) { t.Fatalf("mainnet geth tag %v", tag) } diff --git a/shared/services/config/nimbus-config.go b/shared/services/config/nimbus-config.go index a4539795a..fa1d5d85b 100644 --- a/shared/services/config/nimbus-config.go +++ b/shared/services/config/nimbus-config.go @@ -7,14 +7,6 @@ import ( ) const ( - // Testnet - nimbusBnTagTest string = "statusim/nimbus-eth2:multiarch-v26.8.0" - nimbusVcTagTest string = "statusim/nimbus-validator-client:multiarch-v26.8.0" - - // Mainnet - nimbusBnTagProd string = "statusim/nimbus-eth2:multiarch-v26.8.0" - nimbusVcTagProd string = "statusim/nimbus-validator-client:multiarch-v26.8.0" - defaultNimbusMaxPeersArm uint16 = 100 defaultNimbusMaxPeersAmd uint16 = 160 ) @@ -81,9 +73,9 @@ func NewNimbusConfig(cfg *RocketPoolConfig) *NimbusConfig { BnContainerTag: config.Parameter{ ID: "bnContainerTag", Name: "Beacon Node Container Tag", - Description: "The tag name of the Nimbus Beacon Node container you want to use on Docker Hub.", + Description: "The tag name of the Nimbus Beacon Node container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, nimbusBnTagProd, nimbusBnTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusBnProd), cfg.imageDefault(ImageNimbusBnTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -92,9 +84,9 @@ func NewNimbusConfig(cfg *RocketPoolConfig) *NimbusConfig { VcContainerTag: config.Parameter{ ID: "containerTag", Name: "Validator Client Container Tag", - Description: "The tag name of the Nimbus Validator Client container you want to use on Docker Hub.", + Description: "The tag name of the Nimbus Validator Client container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, nimbusVcTagProd, nimbusVcTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusVcProd), cfg.imageDefault(ImageNimbusVcTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/prometheus-config.go b/shared/services/config/prometheus-config.go index a33401822..7a017087b 100644 --- a/shared/services/config/prometheus-config.go +++ b/shared/services/config/prometheus-config.go @@ -4,9 +4,6 @@ import ( "github.com/rocket-pool/smartnode/shared/types/config" ) -// Constants -const prometheusTag string = "prom/prometheus:v3.14.0" - // Defaults const defaultPrometheusPort uint16 = 9091 const defaultPrometheusOpenPort string = string(config.RPC_Closed) @@ -60,9 +57,9 @@ func NewPrometheusConfig(cfg *RocketPoolConfig) *PrometheusConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Prometheus Container Tag", - Description: "The tag name of the Prometheus container you want to use on Docker Hub.", + Description: "The tag name of the Prometheus container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: prometheusTag}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImagePrometheus)}, AffectsContainers: []config.ContainerID{config.ContainerID_Prometheus}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/prysm-config.go b/shared/services/config/prysm-config.go index ee47078ad..d8a93564a 100644 --- a/shared/services/config/prysm-config.go +++ b/shared/services/config/prysm-config.go @@ -5,10 +5,6 @@ import ( ) const ( - prysmBnTest string = "gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8" - prysmBnProd string = "gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8" - prysmVcTest string = "gcr.io/offchainlabs/prysm/validator:v7.1.8" - prysmVcProd string = "gcr.io/offchainlabs/prysm/validator:v7.1.8" defaultPrysmRpcPort uint16 = 5053 defaultPrysmOpenRpcPort string = string(config.RPC_Closed) defaultPrysmMaxPeers uint16 = 70 @@ -106,9 +102,9 @@ func NewPrysmConfig(cfg *RocketPoolConfig) *PrysmConfig { BnContainerTag: config.Parameter{ ID: "bnContainerTag", Name: "Beacon Node Container Tag", - Description: "The tag name of the Prysm Beacon Node container you want to use on Docker Hub.", + Description: "The tag name of the Prysm Beacon Node container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, prysmBnProd, prysmBnTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmBnProd), cfg.imageDefault(ImagePrysmBnTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -117,9 +113,9 @@ func NewPrysmConfig(cfg *RocketPoolConfig) *PrysmConfig { VcContainerTag: config.Parameter{ ID: "vcContainerTag", Name: "Validator Client Container Tag", - Description: "The tag name of the Prysm Validator Client container you want to use on Docker Hub.", + Description: "The tag name of the Prysm Validator Client container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, prysmVcProd, prysmVcTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmVcProd), cfg.imageDefault(ImagePrysmVcTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/reth-params.go b/shared/services/config/reth-config.go similarity index 95% rename from shared/services/config/reth-params.go rename to shared/services/config/reth-config.go index 2645a51d6..cdf303354 100644 --- a/shared/services/config/reth-params.go +++ b/shared/services/config/reth-config.go @@ -10,8 +10,6 @@ import ( // Constants const ( - rethTagProd string = "ghcr.io/paradigmxyz/reth:v2.5.2" - rethTagTest string = "ghcr.io/paradigmxyz/reth:v2.5.2" rethEventLogInterval int = 1000 rethStopSignal string = "SIGTERM" ) @@ -98,9 +96,9 @@ func NewRethConfig(cfg *RocketPoolConfig) *RethConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Reth container you want to use.", + Description: "The tag name of the Reth container you want to use. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, rethTagProd, rethTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageRethProd), cfg.imageDefault(ImageRethTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/rocket-pool-config.go b/shared/services/config/rocket-pool-config.go index 90693c37f..c984b8b52 100644 --- a/shared/services/config/rocket-pool-config.go +++ b/shared/services/config/rocket-pool-config.go @@ -68,6 +68,7 @@ type RocketPoolConfig struct { IsCLI bool `yaml:"-"` networks *NetworksConfig `yaml:"-"` + images *ImageCatalog `yaml:"-"` // Execution client settings ExecutionClientMode config.Parameter `yaml:"executionClientMode,omitempty"` @@ -264,6 +265,10 @@ func (cfg *RocketPoolConfig) Save(directory, filename string) error { return err } + if err := cfg.SaveImagesEnv(); err != nil { + return err + } + return nil } @@ -273,10 +278,14 @@ func NewRocketPoolConfig(rpDir string, isNativeMode bool) (*RocketPoolConfig, er if err != nil { return nil, fmt.Errorf("could not load networks: %w", err) } - return newRocketPoolConfig(rpDir, isNativeMode, networks) + images, err := LoadImages(rpDir) + if err != nil { + return nil, fmt.Errorf("could not load images: %w", err) + } + return newRocketPoolConfig(rpDir, isNativeMode, networks, images) } -func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConfig) (*RocketPoolConfig, error) { +func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConfig, images *ImageCatalog) (*RocketPoolConfig, error) { clientModes := []config.ParameterOption{{ Name: "Locally Managed", @@ -293,6 +302,7 @@ func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConf RocketPoolDirectory: rpDir, IsNativeMode: isNativeMode, networks: networks, + images: images, ExecutionClientMode: config.Parameter{ ID: "executionClientMode", @@ -614,8 +624,9 @@ func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConf cfg.MevBoost = NewMevBoostConfig(cfg) cfg.CommitBoost = NewCommitBoostConfig(cfg) // Addons - cfg.GraffitiWallWriter = addons.NewGraffitiWallWriter() + cfg.GraffitiWallWriter = addons.NewGraffitiWallWriter(images.Must(ImageGWW)) cfg.RescueNode = addons.NewRescueNode() + cfg.skipPersistingImageTags() // Apply the default values for the default network from YAML cfg.Smartnode.Network.Value = cfg.networks.DefaultNetwork() @@ -649,7 +660,7 @@ func getAugmentedEcDescription(client config.ExecutionClient, originalDescriptio // Create a copy of this configuration. func (cfg *RocketPoolConfig) CreateCopy() *RocketPoolConfig { - newConfig, err := newRocketPoolConfig(cfg.RocketPoolDirectory, cfg.IsNativeMode, cfg.networks) + newConfig, err := newRocketPoolConfig(cfg.RocketPoolDirectory, cfg.IsNativeMode, cfg.networks, cfg.images) if err != nil { panic(err) } @@ -1039,8 +1050,11 @@ func (cfg *RocketPoolConfig) GetVCContainerTag() (string, error) { if err != nil { return "", err } - - return cCfg.GetValidatorImage(), nil + ref, err := cfg.GetVCImageEnvRef() + if err != nil { + return cCfg.GetValidatorImage(), nil + } + return cfg.imageFromEnvRef(ref, cCfg.GetValidatorImage()), nil } // Used by text/template to format validator.yml @@ -1359,15 +1373,15 @@ func (cfg *RocketPoolConfig) GetECContainerTag() (string, error) { switch cfg.ExecutionClient.Value.(config.ExecutionClient) { case config.ExecutionClient_Geth: - return cfg.Geth.ContainerTag.Value.(string), nil + return cfg.resolveParamImage(&cfg.Geth.ContainerTag, ImageGethProd, ImageGethTest), nil case config.ExecutionClient_Nethermind: - return cfg.Nethermind.ContainerTag.Value.(string), nil + return cfg.resolveParamImage(&cfg.Nethermind.ContainerTag, ImageNethermindProd, ImageNethermindTest), nil case config.ExecutionClient_Besu: - return cfg.Besu.ContainerTag.Value.(string), nil + return cfg.resolveParamImage(&cfg.Besu.ContainerTag, ImageBesuProd, ImageBesuTest), nil case config.ExecutionClient_Reth: - return cfg.Reth.ContainerTag.Value.(string), nil + return cfg.resolveParamImage(&cfg.Reth.ContainerTag, ImageRethProd, ImageRethTest), nil case config.ExecutionClient_Erigon: - return cfg.Erigon.ContainerTag.Value.(string), nil + return cfg.resolveParamImage(&cfg.Erigon.ContainerTag, ImageErigonProd, ImageErigonTest), nil } return "", fmt.Errorf("Unknown Execution Client %s", string(cfg.ExecutionClient.Value.(config.ExecutionClient))) @@ -1542,8 +1556,11 @@ func (cfg *RocketPoolConfig) GetBeaconContainerTag() (string, error) { if err != nil { return "", err } - - return cCfg.GetBeaconNodeImage(), nil + ref, err := cfg.GetBeaconImageEnvRef() + if err != nil { + return cCfg.GetBeaconNodeImage(), nil + } + return cfg.imageFromEnvRef(ref, cCfg.GetBeaconNodeImage()), nil } // Used by text/template to format eth2.yml diff --git a/shared/services/config/smartnode-config.go b/shared/services/config/smartnode-config.go index 57a81f7a4..8ea120d4a 100644 --- a/shared/services/config/smartnode-config.go +++ b/shared/services/config/smartnode-config.go @@ -475,6 +475,11 @@ func (cfg *SmartnodeConfig) GetRplTokenAddress() string { } func (cfg *SmartnodeConfig) GetSmartnodeContainerTag() string { + if cfg.parent != nil { + if tag := cfg.parent.ResolvedImage(ImageSmartnode); tag != "" { + return tag + } + } return smartnodeTagPrefix + shared.RocketPoolVersion() } diff --git a/shared/services/config/teku-config.go b/shared/services/config/teku-config.go index 3eae6eaae..6c30e3a5b 100644 --- a/shared/services/config/teku-config.go +++ b/shared/services/config/teku-config.go @@ -7,8 +7,6 @@ import ( ) const ( - tekuTagTest string = "consensys/teku:26.8.0" - tekuTagProd string = "consensys/teku:26.8.0" defaultTekuMaxPeers uint16 = 100 defaultTekuP2pIpv6Port uint16 = 9090 defaultTekuP2pQuicIpv6Port uint16 = 8002 @@ -98,9 +96,9 @@ func NewTekuConfig(cfg *RocketPoolConfig) *TekuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Teku container you want to use on Docker Hub.", + Description: "The tag name of the Teku container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, tekuTagProd, tekuTagTest), + Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageTekuProd), cfg.imageDefault(ImageTekuTest)), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/rocketpool/assets/assets.go b/shared/services/rocketpool/assets/assets.go index f5c4016e0..d6c5ac933 100644 --- a/shared/services/rocketpool/assets/assets.go +++ b/shared/services/rocketpool/assets/assets.go @@ -38,6 +38,15 @@ func NetworksDefaultYAML() []byte { return bytes } +// ImagesEnv is the packaged official image catalog +func ImagesEnv() []byte { + bytes, err := installFS.ReadFile("install/images.env") + if err != nil { + panic("embedded install/images.env is missing: " + err.Error()) + } + return bytes +} + func InstallUpdateTrackerScript() ScriptWithContext { return ScriptWithContext{Script: installUpdateTrackerScript, Context: rpUpdateTrackerFS} } diff --git a/shared/services/rocketpool/assets/install/images.env b/shared/services/rocketpool/assets/install/images.env new file mode 100644 index 000000000..7911b6863 --- /dev/null +++ b/shared/services/rocketpool/assets/install/images.env @@ -0,0 +1,49 @@ +# Official Smart Node image pins. +# `rocketpool service config` writes container-tag changes into this file. +# The next `rocketpool service install` replaces it with the official catalog. +# +# Client images have _PROD (mainnet) and _TEST (testnet/devnet) variants. +# They may be equal today; keep both so they can diverge without a format change. +# Values are repo:tag@sha256: so Docker verifies the image and still +# selects the correct architecture. + +RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.23.1-dev + +RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 +RP_IMAGE_GETH_TEST=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 +RP_IMAGE_NETHERMIND_PROD=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 +RP_IMAGE_NETHERMIND_TEST=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 +RP_IMAGE_BESU_PROD=hyperledger/besu:26.8.1@sha256:6f3f21ce533383fcc8db3bce02252b59d5a9e776b72b5a1c8ecd2db011600042 +RP_IMAGE_BESU_TEST=hyperledger/besu:26.8.1@sha256:6f3f21ce533383fcc8db3bce02252b59d5a9e776b72b5a1c8ecd2db011600042 +RP_IMAGE_RETH_PROD=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f15bbce2047a92a771ff4efbcd08ee8f9dbd4d1 +RP_IMAGE_RETH_TEST=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f15bbce2047a92a771ff4efbcd08ee8f9dbd4d1 +RP_IMAGE_ERIGON_PROD=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 +RP_IMAGE_ERIGON_TEST=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 + +RP_IMAGE_LIGHTHOUSE_PROD=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 +RP_IMAGE_LIGHTHOUSE_TEST=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 +RP_IMAGE_LODESTAR_PROD=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 +RP_IMAGE_LODESTAR_TEST=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 +RP_IMAGE_NIMBUS_BN_PROD=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 +RP_IMAGE_NIMBUS_BN_TEST=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 +RP_IMAGE_NIMBUS_VC_PROD=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 +RP_IMAGE_NIMBUS_VC_TEST=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 +RP_IMAGE_TEKU_PROD=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e +RP_IMAGE_TEKU_TEST=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e +RP_IMAGE_PRYSM_BN_PROD=gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8@sha256:31239807cbbd446aabe8e7d18e0cd50c430a290ce7906b55eb5d54c7d4e3b676 +RP_IMAGE_PRYSM_BN_TEST=gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8@sha256:31239807cbbd446aabe8e7d18e0cd50c430a290ce7906b55eb5d54c7d4e3b676 +RP_IMAGE_PRYSM_VC_PROD=gcr.io/offchainlabs/prysm/validator:v7.1.8@sha256:7fe554adf0efd27c0e5c5a3f80a3bbbec3d3872626208cf0a003b0dee7761f89 +RP_IMAGE_PRYSM_VC_TEST=gcr.io/offchainlabs/prysm/validator:v7.1.8@sha256:7fe554adf0efd27c0e5c5a3f80a3bbbec3d3872626208cf0a003b0dee7761f89 + +RP_IMAGE_MEV_BOOST_PROD=flashbots/mev-boost:1.12.0@sha256:7d7f51ba6a3f02a592340086a425fa3aa80d39ace4575dd9556f01df7a4e8f26 +RP_IMAGE_MEV_BOOST_TEST=flashbots/mev-boost:1.12.0@sha256:7d7f51ba6a3f02a592340086a425fa3aa80d39ace4575dd9556f01df7a4e8f26 +RP_IMAGE_COMMIT_BOOST_PROD=ghcr.io/commit-boost/commit-boost:v0.10.1@sha256:aa8fbe1af3cd8b9cb2f9ce01603c502d2dc6c2ef7ca9bae4952f281f7d1030dd +RP_IMAGE_COMMIT_BOOST_TEST=ghcr.io/commit-boost/commit-boost:v0.10.1@sha256:aa8fbe1af3cd8b9cb2f9ce01603c502d2dc6c2ef7ca9bae4952f281f7d1030dd + +RP_IMAGE_PROMETHEUS=prom/prometheus:v3.14.0@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0 +RP_IMAGE_EXPORTER=prom/node-exporter:v1.12.1@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0 +RP_IMAGE_ALERTMANAGER=prom/alertmanager:v0.26.0@sha256:361db356b33041437517f1cd298462055580585f26555c317df1a3caf2868552 +RP_IMAGE_GRAFANA=grafana/grafana:9.5.18@sha256:42bb294e8807526658eceb19461e7646171ca48d15f9ac2ac511f1fe63599c54 +RP_IMAGE_GWW=rocketpool/graffiti-wall-addon:v1.0.1@sha256:bf3c224047d1c098d1f714e0ea1aae55d729521b99729ab5a4ddba4d5aec22f1 +RP_IMAGE_CURL=curlimages/curl:8.13.0@sha256:d43bdb28bae0be0998f3be83199bfb2b81e0a30b034b6d7586ce7e05de34c3fd +RP_IMAGE_ALPINE=alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c diff --git a/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl b/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl index e088ea881..ef458263c 100644 --- a/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl +++ b/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl @@ -7,7 +7,7 @@ services: addon_gww: - image: {{.GraffitiWallWriter.GetConfig.ContainerTag}} + image: ${RP_IMAGE_GWW} user: root container_name: {{.Smartnode.ProjectName}}_addon_gww restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl b/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl index b82b55e54..2c2ef02ca 100644 --- a/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl +++ b/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl @@ -7,7 +7,7 @@ services: alertmanager: - image: {{.Alertmanager.ContainerTag}} + image: ${RP_IMAGE_ALERTMANAGER} container_name: {{.Smartnode.ProjectName}}_alertmanager restart: unless-stopped command: diff --git a/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl b/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl index 12cb94fc2..9e3c65d13 100644 --- a/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl +++ b/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl @@ -7,7 +7,7 @@ services: commit-boost: - image: {{.CommitBoost.ContainerTag}} + image: {{.GetCommitBoostImageEnvRef}} container_name: {{.Smartnode.ProjectName}}_commit-boost restart: unless-stopped ports: [{{.GetCommitBoostOpenPorts}}] diff --git a/shared/services/rocketpool/assets/install/templates/eth1.tmpl b/shared/services/rocketpool/assets/install/templates/eth1.tmpl index 35b8114e4..6f0514dfb 100644 --- a/shared/services/rocketpool/assets/install/templates/eth1.tmpl +++ b/shared/services/rocketpool/assets/install/templates/eth1.tmpl @@ -7,7 +7,7 @@ services: eth1: - image: {{.GetECContainerTag}} + image: {{.GetECImageEnvRef}} user: root container_name: {{.Smartnode.ProjectName}}_eth1 restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/eth2.tmpl b/shared/services/rocketpool/assets/install/templates/eth2.tmpl index 7e2c317ea..714c12c32 100644 --- a/shared/services/rocketpool/assets/install/templates/eth2.tmpl +++ b/shared/services/rocketpool/assets/install/templates/eth2.tmpl @@ -7,7 +7,7 @@ {{define "GENESIS_DL"}} eth2_genesis_downloader: - image: curlimages/curl:8.13.0 + image: ${RP_IMAGE_CURL} user: root container_name: {{.Smartnode.ProjectName}}_genesis_downloader volumes: @@ -26,7 +26,7 @@ services: eth2: - image: {{.GetBeaconContainerTag}} + image: {{.GetBeaconImageEnvRef}} user: root container_name: {{.Smartnode.ProjectName}}_eth2 restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/exporter.tmpl b/shared/services/rocketpool/assets/install/templates/exporter.tmpl index 75df4c8d4..8655ac249 100644 --- a/shared/services/rocketpool/assets/install/templates/exporter.tmpl +++ b/shared/services/rocketpool/assets/install/templates/exporter.tmpl @@ -7,7 +7,7 @@ services: node-exporter: - image: {{.Exporter.ContainerTag}} + image: ${RP_IMAGE_EXPORTER} container_name: {{.Smartnode.ProjectName}}_exporter cap_drop: - ALL diff --git a/shared/services/rocketpool/assets/install/templates/grafana.tmpl b/shared/services/rocketpool/assets/install/templates/grafana.tmpl index de0467416..dd3a9f311 100644 --- a/shared/services/rocketpool/assets/install/templates/grafana.tmpl +++ b/shared/services/rocketpool/assets/install/templates/grafana.tmpl @@ -8,7 +8,7 @@ {{$port := (or .Grafana.Port.Value 3100) -}} services: grafana: - image: {{.Grafana.ContainerTag}} + image: ${RP_IMAGE_GRAFANA} container_name: {{.Smartnode.ProjectName}}_grafana restart: unless-stopped environment: diff --git a/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl b/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl index 16217769f..5bb4d39c8 100644 --- a/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl +++ b/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl @@ -7,7 +7,7 @@ services: mev-boost: - image: {{.MevBoost.ContainerTag}} + image: {{.GetMevBoostImageEnvRef}} container_name: {{.Smartnode.ProjectName}}_mev-boost restart: unless-stopped ports: [{{.GetMevBoostOpenPorts}}] diff --git a/shared/services/rocketpool/assets/install/templates/node.tmpl b/shared/services/rocketpool/assets/install/templates/node.tmpl index 482021489..5713796fe 100644 --- a/shared/services/rocketpool/assets/install/templates/node.tmpl +++ b/shared/services/rocketpool/assets/install/templates/node.tmpl @@ -7,7 +7,7 @@ services: node: - image: {{.Smartnode.GetSmartnodeContainerTag}} + image: ${RP_IMAGE_SMARTNODE} container_name: {{.Smartnode.ProjectName}}_node restart: unless-stopped tty: true diff --git a/shared/services/rocketpool/assets/install/templates/prometheus.tmpl b/shared/services/rocketpool/assets/install/templates/prometheus.tmpl index 1a4591e3d..a55c918a6 100644 --- a/shared/services/rocketpool/assets/install/templates/prometheus.tmpl +++ b/shared/services/rocketpool/assets/install/templates/prometheus.tmpl @@ -7,7 +7,7 @@ services: prometheus: - image: {{.Prometheus.ContainerTag}} + image: ${RP_IMAGE_PROMETHEUS} container_name: {{.Smartnode.ProjectName}}_prometheus restart: unless-stopped command: diff --git a/shared/services/rocketpool/assets/install/templates/validator.tmpl b/shared/services/rocketpool/assets/install/templates/validator.tmpl index 6d28e036b..134b6dd44 100644 --- a/shared/services/rocketpool/assets/install/templates/validator.tmpl +++ b/shared/services/rocketpool/assets/install/templates/validator.tmpl @@ -7,7 +7,7 @@ services: validator: - image: {{.GetVCContainerTag}} + image: {{.GetVCImageEnvRef}} user: root container_name: {{.Smartnode.ProjectName}}_validator restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/watchtower.tmpl b/shared/services/rocketpool/assets/install/templates/watchtower.tmpl index 06fa59472..bc4a31243 100644 --- a/shared/services/rocketpool/assets/install/templates/watchtower.tmpl +++ b/shared/services/rocketpool/assets/install/templates/watchtower.tmpl @@ -7,7 +7,7 @@ services: watchtower: - image: {{.Smartnode.GetSmartnodeContainerTag}} + image: ${RP_IMAGE_SMARTNODE} container_name: {{.Smartnode.ProjectName}}_watchtower restart: unless-stopped tty: true diff --git a/shared/services/rocketpool/assets/scripts/install.sh b/shared/services/rocketpool/assets/scripts/install.sh index 7ce01afac..c101897e2 100755 --- a/shared/services/rocketpool/assets/scripts/install.sh +++ b/shared/services/rocketpool/assets/scripts/install.sh @@ -411,6 +411,7 @@ progress 7 "Copying package files to Rocket Pool user data directory..." { cp -r "$PACKAGE_FILES_PATH/templates" "$RP_PATH" || fail "Could not copy templates folder to the Rocket Pool user data directory."; } >&2 { cp -r "$PACKAGE_FILES_PATH/alerting" "$RP_PATH" || fail "Could not copy alerting folder to the Rocket Pool user data directory."; } >&2 { cp -r "$PACKAGE_FILES_PATH/devnet" "$RP_PATH" || fail "Could not copy devnet folder to the Rocket Pool user data directory."; } >&2 +{ cp "$PACKAGE_FILES_PATH/images.env" "$RP_PATH/images.env" || fail "Could not copy images.env to the Rocket Pool user data directory."; } >&2 { cp \ "$PACKAGE_FILES_PATH/grafana-prometheus-datasource.yml" \ "$PACKAGE_FILES_PATH/prometheus.tmpl" \ diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index e20d19545..e74aba46d 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -925,9 +925,20 @@ func (c *Client) RunPruneProvisioner(container, volume string) error { return c.TouchEthclientMarker(container, volume, "prune.lock") } +func (c *Client) resolvedImage(key, fallback string) string { + cfg, _, err := c.LoadConfig() + if err != nil || cfg == nil { + return fallback + } + if v := cfg.ResolvedImage(key); v != "" { + return v + } + return fallback +} + // Creates a marker file on the execution client volume (used for prune and DB migrations). func (c *Client) TouchEthclientMarker(container, volume, marker string) error { - cmd := fmt.Sprintf("docker run --rm --name %s -v %s:/ethclient alpine:latest sh -c 'touch /ethclient/%s'", container, volume, marker) + cmd := fmt.Sprintf("docker run --rm --name %s -v %s:/ethclient %s sh -c 'touch /ethclient/%s'", container, volume, shellescape.Quote(c.resolvedImage(config.ImageAlpine, "alpine:3.21.3")), marker) output, err := c.readOutput(cmd) if err != nil { return err @@ -948,7 +959,7 @@ func (c *Client) RunNethermindPruneStarter(executionContainerName string) error for i := 0; i < retryCount; i++ { command := fmt.Sprintf(`-m 30 -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","method":"admin_prune","params":[],"id":%d}' %s`, i+1, nethermindAdminUrl) - cmdText := fmt.Sprintf(`docker run --quiet --rm --name curl%s --network container:%s curlimages/curl -Ss %s`, pruneStarterContainerSuffix, executionContainerName, command) + cmdText := fmt.Sprintf(`docker run --quiet --rm --name curl%s --network container:%s %s -Ss %s`, pruneStarterContainerSuffix, executionContainerName, shellescape.Quote(c.resolvedImage(config.ImageCurl, "curlimages/curl:8.13.0")), command) if i != 0 { fmt.Printf("Trying again in %v... (%d/%d)\n", retryTime, i+1, retryCount) @@ -1176,9 +1187,27 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(container))) } + imagesEnv := filepath.Join(expandedConfigPath, config.ImagesEnvFile) + if err := ensureImagesEnv(imagesEnv); err != nil { + return "", err + } + envFileFlag := fmt.Sprintf("--env-file %s", shellescape.Quote(imagesEnv)) + // Return command - return fmt.Sprintf("COMPOSE_PROJECT_NAME=%s docker compose --project-directory %s %s %s", cfg.Smartnode.ProjectName.Value.(string), shellescape.Quote(expandedConfigPath), strings.Join(composeFileFlags, " "), args), nil + return fmt.Sprintf("COMPOSE_PROJECT_NAME=%s docker compose %s --project-directory %s %s %s", cfg.Smartnode.ProjectName.Value.(string), envFileFlag, shellescape.Quote(expandedConfigPath), strings.Join(composeFileFlags, " "), args), nil + +} +func ensureImagesEnv(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not stat %s: %w", path, err) + } + if err := os.WriteFile(path, assets.ImagesEnv(), 0664); err != nil { + return fmt.Errorf("could not write %s: %w", path, err) + } + return nil } // Deploys all of the appropriate docker compose template files and provisions them based on the provided configuration From 1029351d5a30a7e01e242022caf0820cb180cc5c Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:05:34 -0300 Subject: [PATCH 2/8] Bump sn version --- shared/services/rocketpool/assets/install/images.env | 2 +- shared/version.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/services/rocketpool/assets/install/images.env b/shared/services/rocketpool/assets/install/images.env index 7911b6863..edece0100 100644 --- a/shared/services/rocketpool/assets/install/images.env +++ b/shared/services/rocketpool/assets/install/images.env @@ -7,7 +7,7 @@ # Values are repo:tag@sha256: so Docker verifies the image and still # selects the correct architecture. -RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.23.1-dev +RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.0 RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 RP_IMAGE_GETH_TEST=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 diff --git a/shared/version.txt b/shared/version.txt index 49e0a31d4..53cc1a6f9 100644 --- a/shared/version.txt +++ b/shared/version.txt @@ -1 +1 @@ -1.23.1 +1.24.0 From 97f5fa16ba38ca766cd2eb766546e56df828fd31 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:39:00 -0300 Subject: [PATCH 3/8] Tag the sn image --- rocketpool-cli/service/service.go | 4 ++++ shared/services/rocketpool/assets/install/images.env | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rocketpool-cli/service/service.go b/rocketpool-cli/service/service.go index f307dd0d0..46f5bd670 100644 --- a/rocketpool-cli/service/service.go +++ b/rocketpool-cli/service/service.go @@ -84,6 +84,10 @@ func installService(yes, verbose, noDeps bool, path string) error { if path == "" { path = rp.ConfigPath() } + path, err = homedir.Expand(path) + if err != nil { + return fmt.Errorf("error expanding install path [%s]: %w", path, err) + } // Install service err = rp.InstallService(verbose, noDeps, path, dataPath) diff --git a/shared/services/rocketpool/assets/install/images.env b/shared/services/rocketpool/assets/install/images.env index edece0100..f1ef60d8c 100644 --- a/shared/services/rocketpool/assets/install/images.env +++ b/shared/services/rocketpool/assets/install/images.env @@ -7,7 +7,7 @@ # Values are repo:tag@sha256: so Docker verifies the image and still # selects the correct architecture. -RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.0 +RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.0@sha256:8a6b3e35da71bdbfae5fa396bb817e63e95be44e44d3e18fb961dd4848d5fb19 RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 RP_IMAGE_GETH_TEST=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 From 63230cf61aedc375e54bee65d5f8b73cee2f4dea Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:21:08 -0300 Subject: [PATCH 4/8] Split mainnet.env and testnet.env --- addons/graffiti_wall_writer/config.go | 2 +- shared/services/config/alertmanager-config.go | 4 +- shared/services/config/besu-config.go | 4 +- shared/services/config/commit-boost-config.go | 6 +- shared/services/config/erigon-config.go | 4 +- shared/services/config/exporter-config.go | 4 +- shared/services/config/external-configs.go | 20 +- shared/services/config/geth-config.go | 4 +- shared/services/config/grafana-config.go | 4 +- shared/services/config/images.go | 592 +++++++++++------- shared/services/config/images_test.go | 159 +++-- shared/services/config/lighthouse-config.go | 4 +- shared/services/config/lodestar-config.go | 4 +- shared/services/config/mev-boost-config.go | 4 +- shared/services/config/nethermind-config.go | 4 +- shared/services/config/networks-config.go | 15 - .../services/config/networks-config_test.go | 2 +- shared/services/config/nimbus-config.go | 8 +- shared/services/config/prometheus-config.go | 4 +- shared/services/config/prysm-config.go | 8 +- shared/services/config/reth-config.go | 4 +- shared/services/config/rocket-pool-config.go | 33 +- shared/services/config/teku-config.go | 4 +- shared/services/rocketpool/assets/assets.go | 23 +- .../rocketpool/assets/install/devnet.env | 6 + .../rocketpool/assets/install/images.env | 49 -- .../rocketpool/assets/install/mainnet.env | 32 + .../templates/addons/gww/addon_gww.tmpl | 2 +- .../install/templates/alertmanager.tmpl | 2 +- .../install/templates/commit-boost.tmpl | 2 +- .../assets/install/templates/eth1.tmpl | 2 +- .../assets/install/templates/eth2.tmpl | 4 +- .../assets/install/templates/exporter.tmpl | 2 +- .../assets/install/templates/grafana.tmpl | 2 +- .../assets/install/templates/mev-boost.tmpl | 2 +- .../assets/install/templates/node.tmpl | 2 +- .../assets/install/templates/prometheus.tmpl | 2 +- .../assets/install/templates/validator.tmpl | 2 +- .../assets/install/templates/watchtower.tmpl | 2 +- .../rocketpool/assets/install/testnet.env | 7 + .../rocketpool/assets/scripts/install.sh | 5 +- shared/services/rocketpool/client.go | 103 ++- 42 files changed, 689 insertions(+), 459 deletions(-) create mode 100644 shared/services/rocketpool/assets/install/devnet.env delete mode 100644 shared/services/rocketpool/assets/install/images.env create mode 100644 shared/services/rocketpool/assets/install/mainnet.env create mode 100644 shared/services/rocketpool/assets/install/testnet.env diff --git a/addons/graffiti_wall_writer/config.go b/addons/graffiti_wall_writer/config.go index e3cf2a9b0..3c44f2eef 100644 --- a/addons/graffiti_wall_writer/config.go +++ b/addons/graffiti_wall_writer/config.go @@ -88,7 +88,7 @@ func NewConfig(containerTag string) *GraffitiWallWriterConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the container you want to use on Docker Hub.", Type: config.ParameterType_String, Default: map[config.Network]any{config.Network_All: containerTag}, AffectsContainers: []config.ContainerID{ContainerID_GraffitiWallWriter}, diff --git a/shared/services/config/alertmanager-config.go b/shared/services/config/alertmanager-config.go index 9a8025601..f30fc9891 100644 --- a/shared/services/config/alertmanager-config.go +++ b/shared/services/config/alertmanager-config.go @@ -173,9 +173,9 @@ func NewAlertmanagerConfig(cfg *RocketPoolConfig) *AlertmanagerConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Alertmanager Container Tag", - Description: "The tag name of the Alertmanager container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Alertmanager container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageAlertmanager)}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageMainnet(ImageAlertmanager)}, AffectsContainers: []config.ContainerID{config.ContainerID_Alertmanager}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/besu-config.go b/shared/services/config/besu-config.go index 9021ebd9f..27f808866 100644 --- a/shared/services/config/besu-config.go +++ b/shared/services/config/besu-config.go @@ -93,9 +93,9 @@ func NewBesuConfig(cfg *RocketPoolConfig) *BesuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Besu container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Besu container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageBesuProd), cfg.imageDefault(ImageBesuTest)), + Default: cfg.imageTagDefaults(ImageBesu), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/commit-boost-config.go b/shared/services/config/commit-boost-config.go index 54b7c8340..4b71e70e7 100644 --- a/shared/services/config/commit-boost-config.go +++ b/shared/services/config/commit-boost-config.go @@ -173,12 +173,12 @@ func NewCommitBoostConfig(cfg *RocketPoolConfig) *CommitBoostConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Commit-Boost container you want to use. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Commit-Boost container you want to use.", AffectsContainers: []config.ContainerID{config.ContainerID_CommitBoost}, CanBeBlank: false, OverwriteOnUpgrade: true, Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageCommitBoostProd), cfg.imageDefault(ImageCommitBoostTest)), + Default: cfg.imageTagDefaults(ImageCommitBoost), }, ExternalUrl: config.Parameter{ @@ -342,7 +342,7 @@ func (cfg *CommitBoostConfig) GetRelayString() string { // Get the container tag value as a string (for use in templates) func (cfg *CommitBoostConfig) GetContainerTag() string { if cfg.parentConfig != nil { - return cfg.parentConfig.resolveParamImage(&cfg.ContainerTag, ImageCommitBoostProd, ImageCommitBoostTest) + return cfg.parentConfig.resolveParamImage(&cfg.ContainerTag, ImageCommitBoost) } return fmt.Sprint(cfg.ContainerTag.Value) } diff --git a/shared/services/config/erigon-config.go b/shared/services/config/erigon-config.go index b5ac29443..f113ea5d4 100644 --- a/shared/services/config/erigon-config.go +++ b/shared/services/config/erigon-config.go @@ -81,9 +81,9 @@ func NewErigonConfig(cfg *RocketPoolConfig) *ErigonConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Erigon container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Erigon container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageErigonProd), cfg.imageDefault(ImageErigonTest)), + Default: cfg.imageTagDefaults(ImageErigon), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/exporter-config.go b/shared/services/config/exporter-config.go index 56f0eee30..ef6be072e 100644 --- a/shared/services/config/exporter-config.go +++ b/shared/services/config/exporter-config.go @@ -40,9 +40,9 @@ func NewExporterConfig(cfg *RocketPoolConfig) *ExporterConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Exporter Container Tag", - Description: "The tag name of the Prometheus Node Exporter container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Prometheus Node Exporter container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageExporter)}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageMainnet(ImageExporter)}, AffectsContainers: []config.ContainerID{config.ContainerID_Exporter}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/external-configs.go b/shared/services/config/external-configs.go index fbbb202b2..27c9922fc 100644 --- a/shared/services/config/external-configs.go +++ b/shared/services/config/external-configs.go @@ -237,9 +237,9 @@ func NewExternalLighthouseConfig(cfg *RocketPoolConfig) *ExternalLighthouseConfi ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lighthouse container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Lighthouse container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLighthouseProd), cfg.imageDefault(ImageLighthouseTest)), + Default: cfg.imageTagDefaults(ImageLighthouse), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -322,9 +322,9 @@ func NewExternalLodestarConfig(cfg *RocketPoolConfig) *ExternalLodestarConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lodestar container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Lodestar container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLodestarProd), cfg.imageDefault(ImageLodestarTest)), + Default: cfg.imageTagDefaults(ImageLodestar), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -408,9 +408,9 @@ func NewExternalNimbusConfig(cfg *RocketPoolConfig) *ExternalNimbusConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Nimbus validator container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Nimbus validator container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusVcProd), cfg.imageDefault(ImageNimbusVcTest)), + Default: cfg.imageTagDefaults(ImageNimbusVc), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -504,9 +504,9 @@ func NewExternalPrysmConfig(cfg *RocketPoolConfig) *ExternalPrysmConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Prysm validator container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Prysm validator container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmVcProd), cfg.imageDefault(ImagePrysmVcTest)), + Default: cfg.imageTagDefaults(ImagePrysmVc), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -589,9 +589,9 @@ func NewExternalTekuConfig(cfg *RocketPoolConfig) *ExternalTekuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Teku container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", + Description: "The tag name of the Teku container you want to use from Docker Hub. This will be used for the Validator Client that Rocket Pool manages with your minipool keys.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageTekuProd), cfg.imageDefault(ImageTekuTest)), + Default: cfg.imageTagDefaults(ImageTeku), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/geth-config.go b/shared/services/config/geth-config.go index f52d7b8b9..134d0af36 100644 --- a/shared/services/config/geth-config.go +++ b/shared/services/config/geth-config.go @@ -80,9 +80,9 @@ func NewGethConfig(cfg *RocketPoolConfig) *GethConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Geth container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Geth container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageGethProd), cfg.imageDefault(ImageGethTest)), + Default: cfg.imageTagDefaults(ImageGeth), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/grafana-config.go b/shared/services/config/grafana-config.go index 6f877775f..41e46ffc6 100644 --- a/shared/services/config/grafana-config.go +++ b/shared/services/config/grafana-config.go @@ -54,9 +54,9 @@ func NewGrafanaConfig(cfg *RocketPoolConfig) *GrafanaConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Grafana Container Tag", - Description: "The tag name of the Grafana container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Grafana container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImageGrafana)}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageMainnet(ImageGrafana)}, AffectsContainers: []config.ContainerID{config.ContainerID_Grafana}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/images.go b/shared/services/config/images.go index 6b7204de7..931304b91 100644 --- a/shared/services/config/images.go +++ b/shared/services/config/images.go @@ -14,40 +14,29 @@ import ( ) const ( - ImagesEnvFile = "images.env" + ImagesMainnetFile = "mainnet.env" + ImagesTestnetFile = "testnet.env" + ImagesDevnetFile = "devnet.env" + ComposeMainFile = "compose.yml" ImageSmartnode = "RP_IMAGE_SMARTNODE" - ImageGethProd = "RP_IMAGE_GETH_PROD" - ImageGethTest = "RP_IMAGE_GETH_TEST" - ImageNethermindProd = "RP_IMAGE_NETHERMIND_PROD" - ImageNethermindTest = "RP_IMAGE_NETHERMIND_TEST" - ImageBesuProd = "RP_IMAGE_BESU_PROD" - ImageBesuTest = "RP_IMAGE_BESU_TEST" - ImageRethProd = "RP_IMAGE_RETH_PROD" - ImageRethTest = "RP_IMAGE_RETH_TEST" - ImageErigonProd = "RP_IMAGE_ERIGON_PROD" - ImageErigonTest = "RP_IMAGE_ERIGON_TEST" - - ImageLighthouseProd = "RP_IMAGE_LIGHTHOUSE_PROD" - ImageLighthouseTest = "RP_IMAGE_LIGHTHOUSE_TEST" - ImageLodestarProd = "RP_IMAGE_LODESTAR_PROD" - ImageLodestarTest = "RP_IMAGE_LODESTAR_TEST" - ImageNimbusBnProd = "RP_IMAGE_NIMBUS_BN_PROD" - ImageNimbusBnTest = "RP_IMAGE_NIMBUS_BN_TEST" - ImageNimbusVcProd = "RP_IMAGE_NIMBUS_VC_PROD" - ImageNimbusVcTest = "RP_IMAGE_NIMBUS_VC_TEST" - ImageTekuProd = "RP_IMAGE_TEKU_PROD" - ImageTekuTest = "RP_IMAGE_TEKU_TEST" - ImagePrysmBnProd = "RP_IMAGE_PRYSM_BN_PROD" - ImagePrysmBnTest = "RP_IMAGE_PRYSM_BN_TEST" - ImagePrysmVcProd = "RP_IMAGE_PRYSM_VC_PROD" - ImagePrysmVcTest = "RP_IMAGE_PRYSM_VC_TEST" - - ImageMevBoostProd = "RP_IMAGE_MEV_BOOST_PROD" - ImageMevBoostTest = "RP_IMAGE_MEV_BOOST_TEST" - ImageCommitBoostProd = "RP_IMAGE_COMMIT_BOOST_PROD" - ImageCommitBoostTest = "RP_IMAGE_COMMIT_BOOST_TEST" + ImageGeth = "RP_IMAGE_GETH" + ImageNethermind = "RP_IMAGE_NETHERMIND" + ImageBesu = "RP_IMAGE_BESU" + ImageReth = "RP_IMAGE_RETH" + ImageErigon = "RP_IMAGE_ERIGON" + + ImageLighthouse = "RP_IMAGE_LIGHTHOUSE" + ImageLodestar = "RP_IMAGE_LODESTAR" + ImageNimbusBn = "RP_IMAGE_NIMBUS_BN" + ImageNimbusVc = "RP_IMAGE_NIMBUS_VC" + ImageTeku = "RP_IMAGE_TEKU" + ImagePrysmBn = "RP_IMAGE_PRYSM_BN" + ImagePrysmVc = "RP_IMAGE_PRYSM_VC" + + ImageMevBoost = "RP_IMAGE_MEV_BOOST" + ImageCommitBoost = "RP_IMAGE_COMMIT_BOOST" ImagePrometheus = "RP_IMAGE_PROMETHEUS" ImageExporter = "RP_IMAGE_EXPORTER" @@ -56,25 +45,45 @@ const ( ImageGWW = "RP_IMAGE_GWW" ImageCurl = "RP_IMAGE_CURL" ImageAlpine = "RP_IMAGE_ALPINE" + + ECImageTagDefault = "EC_IMAGE_TAG_DEFAULT" + ECImageTagOverride = "EC_IMAGE_TAG_OVERRIDE" + BNImageTagDefault = "BN_IMAGE_TAG_DEFAULT" + BNImageTagOverride = "BN_IMAGE_TAG_OVERRIDE" + VCImageTagDefault = "VC_IMAGE_TAG_DEFAULT" + VCImageTagOverride = "VC_IMAGE_TAG_OVERRIDE" + SmartnodeImageTagDefault = "SMARTNODE_IMAGE_TAG_DEFAULT" + SmartnodeImageTagOverride = "SMARTNODE_IMAGE_TAG_OVERRIDE" + MevBoostImageTagDefault = "MEV_BOOST_IMAGE_TAG_DEFAULT" + MevBoostImageTagOverride = "MEV_BOOST_IMAGE_TAG_OVERRIDE" + CommitBoostImageTagDefault = "COMMIT_BOOST_IMAGE_TAG_DEFAULT" + CommitBoostImageTagOverride = "COMMIT_BOOST_IMAGE_TAG_OVERRIDE" + PrometheusImageTagDefault = "PROMETHEUS_IMAGE_TAG_DEFAULT" + PrometheusImageTagOverride = "PROMETHEUS_IMAGE_TAG_OVERRIDE" + GrafanaImageTagDefault = "GRAFANA_IMAGE_TAG_DEFAULT" + GrafanaImageTagOverride = "GRAFANA_IMAGE_TAG_OVERRIDE" + ExporterImageTagDefault = "EXPORTER_IMAGE_TAG_DEFAULT" + ExporterImageTagOverride = "EXPORTER_IMAGE_TAG_OVERRIDE" + AlertmanagerImageTagDefault = "ALERTMANAGER_IMAGE_TAG_DEFAULT" + AlertmanagerImageTagOverride = "ALERTMANAGER_IMAGE_TAG_OVERRIDE" + GWWImageTagDefault = "GWW_IMAGE_TAG_DEFAULT" + GWWImageTagOverride = "GWW_IMAGE_TAG_OVERRIDE" + CurlImageTagDefault = "CURL_IMAGE_TAG_DEFAULT" + CurlImageTagOverride = "CURL_IMAGE_TAG_OVERRIDE" + + ImageTagsEnvFile = "runtime/image-tags.env" ) -// requiredImageKeys must be present in the official catalog. +func ImageTagRef(overrideKey, defaultKey string) string { + return "${" + overrideKey + ":-${" + defaultKey + "}}" +} + +// requiredImageKeys must be present in mainnet.env. Network overlays may omit keys. var requiredImageKeys = []string{ ImageSmartnode, - ImageGethProd, ImageGethTest, - ImageNethermindProd, ImageNethermindTest, - ImageBesuProd, ImageBesuTest, - ImageRethProd, ImageRethTest, - ImageErigonProd, ImageErigonTest, - ImageLighthouseProd, ImageLighthouseTest, - ImageLodestarProd, ImageLodestarTest, - ImageNimbusBnProd, ImageNimbusBnTest, - ImageNimbusVcProd, ImageNimbusVcTest, - ImageTekuProd, ImageTekuTest, - ImagePrysmBnProd, ImagePrysmBnTest, - ImagePrysmVcProd, ImagePrysmVcTest, - ImageMevBoostProd, ImageMevBoostTest, - ImageCommitBoostProd, ImageCommitBoostTest, + ImageGeth, ImageNethermind, ImageBesu, ImageReth, ImageErigon, + ImageLighthouse, ImageLodestar, ImageNimbusBn, ImageNimbusVc, ImageTeku, ImagePrysmBn, ImagePrysmVc, + ImageMevBoost, ImageCommitBoost, ImagePrometheus, ImageExporter, ImageAlertmanager, ImageGrafana, ImageGWW, ImageCurl, ImageAlpine, } @@ -167,31 +176,87 @@ func LoadEnvFile(path string) (map[string]string, error) { return ParseEnvFile(data) } -// LoadImages loads the official catalog: embed, then on-disk images.env if present. -func LoadImages(rpDir string) (*ImageCatalog, error) { - values, err := ParseEnvFile(assets.ImagesEnv()) +func catalogFileName(network config.Network) string { + if network == "" || network == config.Network_Unknown { + return "" + } + return string(network) + ".env" +} + +func loadCatalogBytes(name string, data []byte, requireAll bool) (*ImageCatalog, error) { + values, err := ParseEnvFile(data) + if err != nil { + return nil, fmt.Errorf("could not parse %s: %w", name, err) + } + if requireAll { + if err := validateImageCatalog(name, values); err != nil { + return nil, err + } + } + return &ImageCatalog{values: values}, nil +} + +func loadCatalogFromDisk(rpDir, name string, requireAll bool) (*ImageCatalog, error) { + path := filepath.Join(rpDir, name) + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return loadCatalogBytes(name, data, requireAll) +} + +// LoadImageCatalogs loads mainnet.env (full catalog) and optional per-network +// overlays ({network}.env). Overlay keys replace mainnet; missing keys inherit. +func LoadImageCatalogs(rpDir string, networks *NetworksConfig) (mainnet *ImageCatalog, overlays map[config.Network]*ImageCatalog, err error) { + mainnet, err = loadCatalogBytes(ImagesMainnetFile, assets.ImagesMainnetEnv(), true) if err != nil { - return nil, fmt.Errorf("could not parse embedded %s: %w", ImagesEnvFile, err) + return nil, nil, err } if rpDir != "" { - diskPath := filepath.Join(rpDir, ImagesEnvFile) - if _, err := os.Stat(diskPath); err == nil { - diskValues, err := LoadEnvFile(diskPath) - if err != nil { - return nil, fmt.Errorf("could not load %s: %w", diskPath, err) - } - values = diskValues + disk, err := loadCatalogFromDisk(rpDir, ImagesMainnetFile, true) + if err == nil { + mainnet = disk } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("could not stat %s: %w", diskPath, err) + return nil, nil, fmt.Errorf("could not load %s: %w", filepath.Join(rpDir, ImagesMainnetFile), err) } } - if err := validateImageCatalog(values); err != nil { - return nil, err + + overlays = map[config.Network]*ImageCatalog{} + if networks == nil { + return mainnet, overlays, nil } - return &ImageCatalog{values: values}, nil + for _, n := range networks.AllNetworks() { + id := n.ID() + if id == "mainnet" { + continue + } + name := catalogFileName(id) + var overlay *ImageCatalog + if rpDir != "" { + disk, err := loadCatalogFromDisk(rpDir, name, false) + if err == nil { + overlay = disk + } else if !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("could not load %s: %w", filepath.Join(rpDir, name), err) + } + } + if overlay == nil { + if data, ok := assets.EmbeddedNetworkEnv(name); ok { + parsed, err := loadCatalogBytes(name, data, false) + if err != nil { + return nil, nil, err + } + overlay = parsed + } + } + if overlay != nil { + overlays[id] = overlay + } + } + return mainnet, overlays, nil } -func validateImageCatalog(values map[string]string) error { +func validateImageCatalog(name string, values map[string]string) error { var missing []string for _, key := range requiredImageKeys { if v, ok := values[key]; !ok || v == "" { @@ -199,7 +264,7 @@ func validateImageCatalog(values map[string]string) error { } } if len(missing) > 0 { - return fmt.Errorf("%s is missing required keys: %s", ImagesEnvFile, strings.Join(missing, ", ")) + return fmt.Errorf("%s is missing required keys: %s", name, strings.Join(missing, ", ")) } return nil } @@ -282,70 +347,134 @@ func WriteEnvFile(path string, values map[string]string) error { return os.Rename(tmp, path) } -func (cfg *RocketPoolConfig) imageDefault(key string) string { - return cfg.images.Must(key) +func (cfg *RocketPoolConfig) imageMainnet(key string) string { + return cfg.imagesMainnet.Must(key) } -func (cfg *RocketPoolConfig) clientImageKey(prodKey, testKey string) string { - info := cfg.GetNetworkInfo() - if info != nil && info.ClientTagSet == config.ClientTagSetProduction { - return prodKey +func (cfg *RocketPoolConfig) imageForNetwork(key string, network config.Network) string { + if overlay := cfg.imagesByNetwork[network]; overlay != nil { + if v, ok := overlay.Get(key); ok && v != "" { + return v + } } - return testKey + return cfg.imagesMainnet.Must(key) } -func (cfg *RocketPoolConfig) LoadedImages() *ImageCatalog { - return cfg.images +func (cfg *RocketPoolConfig) imageTagDefaults(key string) map[config.Network]interface{} { + defaults := map[config.Network]interface{}{config.Network_All: cfg.imageMainnet(key)} + if cfg.networks == nil { + return defaults + } + for _, n := range cfg.networks.AllNetworks() { + defaults[n.ID()] = cfg.imageForNetwork(key, n.ID()) + } + return defaults } -func (cfg *RocketPoolConfig) ImageEnvPath() string { - return filepath.Join(cfg.RocketPoolDirectory, ImagesEnvFile) +func (cfg *RocketPoolConfig) overlayFileName() string { + info := cfg.GetNetworkInfo() + if info == nil || info.Name == "" || info.Name == "mainnet" { + return "" + } + return catalogFileName(info.ID()) } -// GetECImageEnvRef returns the compose interpolation for the selected execution client. -func (cfg *RocketPoolConfig) GetECImageEnvRef() (string, error) { +// ImagesEnvFiles is the Compose env_file list: mainnet.env, then the network overlay if it exists. +func (cfg *RocketPoolConfig) ImagesEnvFiles() []string { + files := []string{ImagesMainnetFile} + if name := cfg.overlayFileName(); name != "" { + path := filepath.Join(cfg.RocketPoolDirectory, name) + if cfg.RocketPoolDirectory == "" { + if _, ok := assets.EmbeddedNetworkEnv(name); ok { + files = append(files, name) + } + return files + } + if _, err := os.Stat(path); err == nil { + files = append(files, name) + } else if _, ok := assets.EmbeddedNetworkEnv(name); ok { + files = append(files, name) + } + } + return files +} + +func (cfg *RocketPoolConfig) selectedECCatalogKey() (string, error) { if !cfg.ExecutionClientLocal() { return "", fmt.Errorf("Execution client is external, there is no container tag") } switch cfg.ExecutionClient.Value.(config.ExecutionClient) { case config.ExecutionClient_Geth: - return EnvRef(cfg.clientImageKey(ImageGethProd, ImageGethTest)), nil + return ImageGeth, nil case config.ExecutionClient_Nethermind: - return EnvRef(cfg.clientImageKey(ImageNethermindProd, ImageNethermindTest)), nil + return ImageNethermind, nil case config.ExecutionClient_Besu: - return EnvRef(cfg.clientImageKey(ImageBesuProd, ImageBesuTest)), nil + return ImageBesu, nil case config.ExecutionClient_Reth: - return EnvRef(cfg.clientImageKey(ImageRethProd, ImageRethTest)), nil + return ImageReth, nil case config.ExecutionClient_Erigon: - return EnvRef(cfg.clientImageKey(ImageErigonProd, ImageErigonTest)), nil + return ImageErigon, nil } return "", fmt.Errorf("Unknown Execution Client %s", string(cfg.ExecutionClient.Value.(config.ExecutionClient))) } -// GetBeaconImageEnvRef returns the compose interpolation for the selected beacon client. -func (cfg *RocketPoolConfig) GetBeaconImageEnvRef() (string, error) { +func (cfg *RocketPoolConfig) selectedECTagParam() (*config.Parameter, error) { + if !cfg.ExecutionClientLocal() { + return nil, fmt.Errorf("Execution client is external, there is no container tag") + } + switch cfg.ExecutionClient.Value.(config.ExecutionClient) { + case config.ExecutionClient_Geth: + return &cfg.Geth.ContainerTag, nil + case config.ExecutionClient_Nethermind: + return &cfg.Nethermind.ContainerTag, nil + case config.ExecutionClient_Besu: + return &cfg.Besu.ContainerTag, nil + case config.ExecutionClient_Reth: + return &cfg.Reth.ContainerTag, nil + case config.ExecutionClient_Erigon: + return &cfg.Erigon.ContainerTag, nil + } + return nil, fmt.Errorf("Unknown Execution Client %s", string(cfg.ExecutionClient.Value.(config.ExecutionClient))) +} + +// GetECImageEnvRef is ${EC_IMAGE_TAG_OVERRIDE:-${EC_IMAGE_TAG_DEFAULT}}. +func (cfg *RocketPoolConfig) GetECImageEnvRef() (string, error) { + if _, err := cfg.selectedECCatalogKey(); err != nil { + return "", err + } + return ImageTagRef(ECImageTagOverride, ECImageTagDefault), nil +} + +func (cfg *RocketPoolConfig) selectedBNCatalogKey() (string, error) { cCfg, err := cfg.GetSelectedConsensusClientConfig() if err != nil { return "", err } switch cCfg.(type) { case *LighthouseConfig: - return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + return ImageLighthouse, nil case *LodestarConfig: - return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + return ImageLodestar, nil case *NimbusConfig: - return EnvRef(cfg.clientImageKey(ImageNimbusBnProd, ImageNimbusBnTest)), nil + return ImageNimbusBn, nil case *PrysmConfig: - return EnvRef(cfg.clientImageKey(ImagePrysmBnProd, ImagePrysmBnTest)), nil + return ImagePrysmBn, nil case *TekuConfig: - return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + return ImageTeku, nil default: return "", fmt.Errorf("unknown consensus client config %T", cCfg) } } -// GetVCImageEnvRef returns the compose interpolation for the selected validator client. -func (cfg *RocketPoolConfig) GetVCImageEnvRef() (string, error) { +// GetBeaconImageEnvRef is ${BN_IMAGE_TAG_OVERRIDE:-${BN_IMAGE_TAG_DEFAULT}}. +func (cfg *RocketPoolConfig) GetBeaconImageEnvRef() (string, error) { + if _, err := cfg.selectedBNCatalogKey(); err != nil { + return "", err + } + return ImageTagRef(BNImageTagOverride, BNImageTagDefault), nil +} + +func (cfg *RocketPoolConfig) selectedVCCatalogKey() (string, error) { mode := cfg.ConsensusClientMode.Value.(config.Mode) if mode == config.Mode_Local { cCfg, err := cfg.GetSelectedConsensusClientConfig() @@ -354,15 +483,15 @@ func (cfg *RocketPoolConfig) GetVCImageEnvRef() (string, error) { } switch cCfg.(type) { case *LighthouseConfig: - return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + return ImageLighthouse, nil case *LodestarConfig: - return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + return ImageLodestar, nil case *NimbusConfig: - return EnvRef(cfg.clientImageKey(ImageNimbusVcProd, ImageNimbusVcTest)), nil + return ImageNimbusVc, nil case *PrysmConfig: - return EnvRef(cfg.clientImageKey(ImagePrysmVcProd, ImagePrysmVcTest)), nil + return ImagePrysmVc, nil case *TekuConfig: - return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + return ImageTeku, nil default: return "", fmt.Errorf("unknown consensus client config %T", cCfg) } @@ -370,139 +499,174 @@ func (cfg *RocketPoolConfig) GetVCImageEnvRef() (string, error) { client := cfg.ExternalConsensusClient.Value.(config.ConsensusClient) switch client { case config.ConsensusClient_Lighthouse: - return EnvRef(cfg.clientImageKey(ImageLighthouseProd, ImageLighthouseTest)), nil + return ImageLighthouse, nil case config.ConsensusClient_Lodestar: - return EnvRef(cfg.clientImageKey(ImageLodestarProd, ImageLodestarTest)), nil + return ImageLodestar, nil case config.ConsensusClient_Nimbus: - return EnvRef(cfg.clientImageKey(ImageNimbusVcProd, ImageNimbusVcTest)), nil + return ImageNimbusVc, nil case config.ConsensusClient_Prysm: - return EnvRef(cfg.clientImageKey(ImagePrysmVcProd, ImagePrysmVcTest)), nil + return ImagePrysmVc, nil case config.ConsensusClient_Teku: - return EnvRef(cfg.clientImageKey(ImageTekuProd, ImageTekuTest)), nil + return ImageTeku, nil default: return "", fmt.Errorf("unknown external consensus client [%v]", client) } } +func (cfg *RocketPoolConfig) selectedVCTagParam() *config.Parameter { + mode := cfg.ConsensusClientMode.Value.(config.Mode) + if mode == config.Mode_Local { + switch cfg.ConsensusClient.Value.(config.ConsensusClient) { + case config.ConsensusClient_Lighthouse: + return &cfg.Lighthouse.ContainerTag + case config.ConsensusClient_Lodestar: + return &cfg.Lodestar.ContainerTag + case config.ConsensusClient_Nimbus: + return &cfg.Nimbus.VcContainerTag + case config.ConsensusClient_Prysm: + return &cfg.Prysm.VcContainerTag + case config.ConsensusClient_Teku: + return &cfg.Teku.ContainerTag + } + return nil + } + switch cfg.ExternalConsensusClient.Value.(config.ConsensusClient) { + case config.ConsensusClient_Lighthouse: + return &cfg.ExternalLighthouse.ContainerTag + case config.ConsensusClient_Lodestar: + return &cfg.ExternalLodestar.ContainerTag + case config.ConsensusClient_Nimbus: + return &cfg.ExternalNimbus.ContainerTag + case config.ConsensusClient_Prysm: + return &cfg.ExternalPrysm.ContainerTag + case config.ConsensusClient_Teku: + return &cfg.ExternalTeku.ContainerTag + } + return nil +} + +func (cfg *RocketPoolConfig) selectedBNTagParam() *config.Parameter { + if cfg.ConsensusClientMode.Value.(config.Mode) != config.Mode_Local { + return nil + } + switch cfg.ConsensusClient.Value.(config.ConsensusClient) { + case config.ConsensusClient_Lighthouse: + return &cfg.Lighthouse.ContainerTag + case config.ConsensusClient_Lodestar: + return &cfg.Lodestar.ContainerTag + case config.ConsensusClient_Nimbus: + return &cfg.Nimbus.BnContainerTag + case config.ConsensusClient_Prysm: + return &cfg.Prysm.BnContainerTag + case config.ConsensusClient_Teku: + return &cfg.Teku.ContainerTag + } + return nil +} + +// GetVCImageEnvRef is ${VC_IMAGE_TAG_OVERRIDE:-${VC_IMAGE_TAG_DEFAULT}}. +func (cfg *RocketPoolConfig) GetVCImageEnvRef() (string, error) { + if _, err := cfg.selectedVCCatalogKey(); err != nil { + return "", err + } + return ImageTagRef(VCImageTagOverride, VCImageTagDefault), nil +} + func (cfg *RocketPoolConfig) GetMevBoostImageEnvRef() string { - return EnvRef(cfg.clientImageKey(ImageMevBoostProd, ImageMevBoostTest)) + return ImageTagRef(MevBoostImageTagOverride, MevBoostImageTagDefault) } func (cfg *RocketPoolConfig) GetCommitBoostImageEnvRef() string { - return EnvRef(cfg.clientImageKey(ImageCommitBoostProd, ImageCommitBoostTest)) + return ImageTagRef(CommitBoostImageTagOverride, CommitBoostImageTagDefault) } -type imageOverrideParam struct { - param *config.Parameter - prodKey string - testKey string +func (cfg *RocketPoolConfig) tuiOverride(param *config.Parameter) (string, bool) { + if param == nil || param.Value == nil { + return "", false + } + value := fmt.Sprint(param.Value) + if value == "" { + return "", false + } + def, err := param.GetDefault(cfg.GetNetwork()) + if err != nil || value == fmt.Sprint(def) { + return "", false + } + return value, true } -func (cfg *RocketPoolConfig) imageOverrideParams() []imageOverrideParam { - gwwTag := cfg.GraffitiWallWriter.GetConfig().GetParameters() - var gwwContainer *config.Parameter - for _, p := range gwwTag { - if p.ID == "containerTag" { - gwwContainer = p - break - } - } - params := []imageOverrideParam{ - {&cfg.Geth.ContainerTag, ImageGethProd, ImageGethTest}, - {&cfg.Nethermind.ContainerTag, ImageNethermindProd, ImageNethermindTest}, - {&cfg.Besu.ContainerTag, ImageBesuProd, ImageBesuTest}, - {&cfg.Reth.ContainerTag, ImageRethProd, ImageRethTest}, - {&cfg.Erigon.ContainerTag, ImageErigonProd, ImageErigonTest}, - {&cfg.Lighthouse.ContainerTag, ImageLighthouseProd, ImageLighthouseTest}, - {&cfg.Lodestar.ContainerTag, ImageLodestarProd, ImageLodestarTest}, - {&cfg.Nimbus.BnContainerTag, ImageNimbusBnProd, ImageNimbusBnTest}, - {&cfg.Nimbus.VcContainerTag, ImageNimbusVcProd, ImageNimbusVcTest}, - {&cfg.Prysm.BnContainerTag, ImagePrysmBnProd, ImagePrysmBnTest}, - {&cfg.Prysm.VcContainerTag, ImagePrysmVcProd, ImagePrysmVcTest}, - {&cfg.Teku.ContainerTag, ImageTekuProd, ImageTekuTest}, - {&cfg.ExternalLighthouse.ContainerTag, ImageLighthouseProd, ImageLighthouseTest}, - {&cfg.ExternalLodestar.ContainerTag, ImageLodestarProd, ImageLodestarTest}, - {&cfg.ExternalNimbus.ContainerTag, ImageNimbusVcProd, ImageNimbusVcTest}, - {&cfg.ExternalPrysm.ContainerTag, ImagePrysmVcProd, ImagePrysmVcTest}, - {&cfg.ExternalTeku.ContainerTag, ImageTekuProd, ImageTekuTest}, - {&cfg.MevBoost.ContainerTag, ImageMevBoostProd, ImageMevBoostTest}, - {&cfg.CommitBoost.ContainerTag, ImageCommitBoostProd, ImageCommitBoostTest}, - {&cfg.Prometheus.ContainerTag, ImagePrometheus, ""}, - {&cfg.Grafana.ContainerTag, ImageGrafana, ""}, - {&cfg.Exporter.ContainerTag, ImageExporter, ""}, - {&cfg.Alertmanager.ContainerTag, ImageAlertmanager, ""}, - } - if gwwContainer != nil { - params = append(params, imageOverrideParam{gwwContainer, ImageGWW, ""}) - } - return params -} - -func (cfg *RocketPoolConfig) skipPersistingImageTags() { - for _, item := range cfg.imageOverrideParams() { - item.param.SkipUserSettings = true - } -} - -// tuiImageValues maps the current TUI container-tag values onto images.env keys -// for the selected network (_PROD vs _TEST). -func (cfg *RocketPoolConfig) tuiImageValues() map[string]string { - out := map[string]string{} - for _, item := range cfg.imageOverrideParams() { - if item.param.Value == nil { - continue - } - value := fmt.Sprint(item.param.Value) - if value == "" { - continue - } - key := item.prodKey - if item.testKey != "" { - key = cfg.clientImageKey(item.prodKey, item.testKey) - } - out[key] = value +// ComposeImageDefaults maps service-level *_IMAGE_TAG_DEFAULT vars to the +// catalog pin for the selected client/network. +func (cfg *RocketPoolConfig) ComposeImageDefaults() map[string]string { + out := map[string]string{ + SmartnodeImageTagDefault: cfg.ResolvedImage(ImageSmartnode), + PrometheusImageTagDefault: cfg.ResolvedImage(ImagePrometheus), + GrafanaImageTagDefault: cfg.ResolvedImage(ImageGrafana), + ExporterImageTagDefault: cfg.ResolvedImage(ImageExporter), + AlertmanagerImageTagDefault: cfg.ResolvedImage(ImageAlertmanager), + GWWImageTagDefault: cfg.ResolvedImage(ImageGWW), + CurlImageTagDefault: cfg.ResolvedImage(ImageCurl), + MevBoostImageTagDefault: cfg.ResolvedImage(ImageMevBoost), + CommitBoostImageTagDefault: cfg.ResolvedImage(ImageCommitBoost), + } + if key, err := cfg.selectedECCatalogKey(); err == nil { + out[ECImageTagDefault] = cfg.ResolvedImage(key) + } + if key, err := cfg.selectedBNCatalogKey(); err == nil { + out[BNImageTagDefault] = cfg.ResolvedImage(key) + } + if key, err := cfg.selectedVCCatalogKey(); err == nil { + out[VCImageTagDefault] = cfg.ResolvedImage(key) } return out } -// SaveImagesEnv writes current TUI container tags into images.env. -// A later `rocketpool service install` replaces that file with the official catalog. -func (cfg *RocketPoolConfig) SaveImagesEnv() error { - if cfg.RocketPoolDirectory == "" { - return nil +// ComposeEnvOverrides returns *_IMAGE_TAG_OVERRIDE vars for TUI values that +// differ from the catalog pin. Unset override vars fall back to *_DEFAULT. +func (cfg *RocketPoolConfig) ComposeEnvOverrides() map[string]string { + out := map[string]string{} + if param, err := cfg.selectedECTagParam(); err == nil { + if v, ok := cfg.tuiOverride(param); ok { + out[ECImageTagOverride] = v + } } - path := cfg.ImageEnvPath() - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil - } else if err != nil { - return err + if v, ok := cfg.tuiOverride(cfg.selectedBNTagParam()); ok { + out[BNImageTagOverride] = v } - updates := cfg.tuiImageValues() - if err := UpdateEnvFile(path, updates); err != nil { - return fmt.Errorf("could not update %s: %w", path, err) + if v, ok := cfg.tuiOverride(cfg.selectedVCTagParam()); ok { + out[VCImageTagOverride] = v } - network := cfg.GetNetwork() - for _, item := range cfg.imageOverrideParams() { - if item.param.Value == nil { - continue - } - value := fmt.Sprint(item.param.Value) - if value == "" { - continue - } - key := item.prodKey - if item.testKey != "" { - key = cfg.clientImageKey(item.prodKey, item.testKey) - item.param.Default[network] = value - } else { - item.param.Default[config.Network_All] = value + if v, ok := cfg.tuiOverride(&cfg.MevBoost.ContainerTag); ok { + out[MevBoostImageTagOverride] = v + } + if v, ok := cfg.tuiOverride(&cfg.CommitBoost.ContainerTag); ok { + out[CommitBoostImageTagOverride] = v + } + if v, ok := cfg.tuiOverride(&cfg.Prometheus.ContainerTag); ok { + out[PrometheusImageTagOverride] = v + } + if v, ok := cfg.tuiOverride(&cfg.Grafana.ContainerTag); ok { + out[GrafanaImageTagOverride] = v + } + if v, ok := cfg.tuiOverride(&cfg.Exporter.ContainerTag); ok { + out[ExporterImageTagOverride] = v + } + if v, ok := cfg.tuiOverride(&cfg.Alertmanager.ContainerTag); ok { + out[AlertmanagerImageTagOverride] = v + } + gww := cfg.GraffitiWallWriter.GetConfig().GetParameters() + for _, p := range gww { + if p.ID == "containerTag" { + if v, ok := cfg.tuiOverride(p); ok { + out[GWWImageTagOverride] = v + } + break } - cfg.images.Set(key, value) } - return nil + return out } -func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, prodKey, testKey string) string { +func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, key string) string { if param == nil { return "" } @@ -513,10 +677,6 @@ func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, prodKey, return value } } - key := prodKey - if testKey != "" { - key = cfg.clientImageKey(prodKey, testKey) - } if v := cfg.ResolvedImage(key); v != "" { return v } @@ -525,20 +685,28 @@ func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, prodKey, func (cfg *RocketPoolConfig) imageFromEnvRef(ref, tuiValue string) string { key := strings.TrimSuffix(strings.TrimPrefix(ref, "${"), "}") - if cfg.images != nil { - if catalog, ok := cfg.images.Get(key); ok && tuiValue != "" && tuiValue != catalog { - return tuiValue - } + official := cfg.ResolvedImage(key) + if tuiValue != "" && official != "" && tuiValue != official { + return tuiValue } - if v := cfg.ResolvedImage(key); v != "" { - return v + if official != "" { + return official } return tuiValue } func (cfg *RocketPoolConfig) ResolvedImage(key string) string { - if cfg.images != nil { - if v, ok := cfg.images.Get(key); ok && v != "" { + network := config.Network_Unknown + if cfg.Smartnode != nil && cfg.Smartnode.Network.Value != nil { + network = cfg.Smartnode.Network.Value.(config.Network) + } + if overlay := cfg.imagesByNetwork[network]; overlay != nil { + if v, ok := overlay.Get(key); ok && v != "" { + return v + } + } + if cfg.imagesMainnet != nil { + if v, ok := cfg.imagesMainnet.Get(key); ok && v != "" { return v } } diff --git a/shared/services/config/images_test.go b/shared/services/config/images_test.go index c65b6f59a..5d348a9be 100644 --- a/shared/services/config/images_test.go +++ b/shared/services/config/images_test.go @@ -12,7 +12,7 @@ import ( func TestParseEnvFile(t *testing.T) { data := []byte(` # comment -RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:abc +RP_IMAGE_GETH=ethereum/client-go:v1.17.5@sha256:abc RP_IMAGE_CURL="curlimages/curl:8.13.0" RP_IMAGE_ALPINE='alpine:3.21.3' @@ -21,8 +21,8 @@ RP_IMAGE_ALPINE='alpine:3.21.3' if err != nil { t.Fatal(err) } - if got["RP_IMAGE_GETH_PROD"] != "ethereum/client-go:v1.17.5@sha256:abc" { - t.Fatalf("geth: %q", got["RP_IMAGE_GETH_PROD"]) + if got["RP_IMAGE_GETH"] != "ethereum/client-go:v1.17.5@sha256:abc" { + t.Fatalf("geth: %q", got["RP_IMAGE_GETH"]) } if got["RP_IMAGE_CURL"] != "curlimages/curl:8.13.0" { t.Fatalf("curl: %q", got["RP_IMAGE_CURL"]) @@ -38,46 +38,76 @@ func TestParseEnvFileRejectsBadLine(t *testing.T) { } } -func TestLoadImagesEmbedded(t *testing.T) { - catalog, err := LoadImages("") +func TestLoadImageCatalogsEmbedded(t *testing.T) { + mainnet, overlays, err := LoadImageCatalogs("", nil) if err != nil { t.Fatal(err) } - if catalog.Must(ImageGethProd) == "" { - t.Fatal("empty geth prod") + if !strings.Contains(mainnet.Must(ImageGeth), "ethereum/client-go:") { + t.Fatalf("unexpected geth image %s", mainnet.Must(ImageGeth)) } - if !strings.Contains(catalog.Must(ImageGethProd), "ethereum/client-go:") { - t.Fatalf("unexpected geth image %s", catalog.Must(ImageGethProd)) + if len(overlays) != 0 { + t.Fatalf("expected no overlays without networks, got %v", overlays) } } -func TestLoadImagesPrefersDisk(t *testing.T) { +func TestLoadImageCatalogsPrefersDisk(t *testing.T) { dir := t.TempDir() - embedded, err := LoadImages("") + mainnet, _, err := LoadImageCatalogs("", nil) if err != nil { t.Fatal(err) } - values := embedded.Map() - values[ImageGethProd] = "example.com/geth:custom" - if err := WriteEnvFile(filepath.Join(dir, ImagesEnvFile), values); err != nil { + values := mainnet.Map() + values[ImageGeth] = "example.com/geth:custom" + if err := WriteEnvFile(filepath.Join(dir, ImagesMainnetFile), values); err != nil { t.Fatal(err) } - catalog, err := LoadImages(dir) + loaded, _, err := LoadImageCatalogs(dir, nil) if err != nil { t.Fatal(err) } - if catalog.Must(ImageGethProd) != "example.com/geth:custom" { - t.Fatalf("got %s", catalog.Must(ImageGethProd)) + if loaded.Must(ImageGeth) != "example.com/geth:custom" { + t.Fatalf("got %s", loaded.Must(ImageGeth)) + } +} + +func TestNetworkOverlayOverridesMainnet(t *testing.T) { + dir := t.TempDir() + mainnet, _, err := LoadImageCatalogs("", nil) + if err != nil { + t.Fatal(err) + } + if err := WriteEnvFile(filepath.Join(dir, ImagesMainnetFile), mainnet.Map()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ImagesTestnetFile), []byte("RP_IMAGE_GETH=example.com/geth:testnet\n"), 0644); err != nil { + t.Fatal(err) + } + networks, err := LoadNetworks("") + if err != nil { + t.Fatal(err) + } + cfg := mustNewRocketPoolConfig(t, dir, false) + cfg.networks = networks + cfg.Smartnode.Network.Value = config.Network("testnet") + if got := cfg.imageForNetwork(ImageGeth, config.Network("testnet")); got != "example.com/geth:testnet" { + t.Fatalf("testnet geth %s", got) + } + if got := cfg.imageForNetwork(ImageLighthouse, config.Network("testnet")); got != mainnet.Must(ImageLighthouse) { + t.Fatalf("testnet lighthouse should inherit mainnet, got %s", got) + } + if got := cfg.imageForNetwork(ImageGeth, config.Network("mainnet")); got != mainnet.Must(ImageGeth) { + t.Fatalf("mainnet geth should be unchanged, got %s", got) } } func TestUpdateEnvFilePreservesComments(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, ImagesEnvFile) - if err := os.WriteFile(path, []byte("# comment\nRP_IMAGE_GETH_PROD=old/geth:1\nRP_IMAGE_CURL=keep/curl:1\n"), 0644); err != nil { + path := filepath.Join(dir, ImagesMainnetFile) + if err := os.WriteFile(path, []byte("# comment\nRP_IMAGE_GETH=old/geth:1\nRP_IMAGE_CURL=keep/curl:1\n"), 0644); err != nil { t.Fatal(err) } - if err := UpdateEnvFile(path, map[string]string{ImageGethProd: "my/geth:custom"}); err != nil { + if err := UpdateEnvFile(path, map[string]string{ImageGeth: "my/geth:custom"}); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) @@ -92,8 +122,8 @@ func TestUpdateEnvFilePreservesComments(t *testing.T) { if err != nil { t.Fatal(err) } - if parsed[ImageGethProd] != "my/geth:custom" { - t.Fatalf("geth %q", parsed[ImageGethProd]) + if parsed[ImageGeth] != "my/geth:custom" { + t.Fatalf("geth %q", parsed[ImageGeth]) } if parsed[ImageCurl] != "keep/curl:1" { t.Fatalf("curl %q", parsed[ImageCurl]) @@ -112,94 +142,63 @@ func TestClientImageEnvRefs(t *testing.T) { if err != nil { t.Fatal(err) } - if ref != EnvRef(ImageGethProd) { + if ref != ImageTagRef(ECImageTagOverride, ECImageTagDefault) { t.Fatalf("ec ref %s", ref) } bn, err := cfg.GetBeaconImageEnvRef() if err != nil { t.Fatal(err) } - if bn != EnvRef(ImageLighthouseProd) { + if bn != ImageTagRef(BNImageTagOverride, BNImageTagDefault) { t.Fatalf("bn ref %s", bn) } - - cfg.ChangeNetwork(config.Network("testnet")) - ref, err = cfg.GetECImageEnvRef() + vc, err := cfg.GetVCImageEnvRef() if err != nil { t.Fatal(err) } - if ref != EnvRef(ImageGethTest) { - t.Fatalf("ec test ref %s", ref) + if vc != ImageTagRef(VCImageTagOverride, VCImageTagDefault) { + t.Fatalf("vc ref %s", vc) } -} -func TestComposeTemplatesUseEnvRefs(t *testing.T) { - cfg := mustNewRocketPoolConfig(t, "", false) - cfg.ExecutionClientMode.Value = config.Mode_Local - cfg.ExecutionClient.Value = config.ExecutionClient_Geth - cfg.ConsensusClientMode.Value = config.Mode_Local - cfg.ConsensusClient.Value = config.ConsensusClient_Lighthouse - cfg.Smartnode.Network.Value = config.Network("mainnet") - - ec, err := cfg.GetECImageEnvRef() + cfg.ChangeNetwork(config.Network("testnet")) + ref, err = cfg.GetECImageEnvRef() if err != nil { t.Fatal(err) } - if !strings.HasPrefix(ec, "${RP_IMAGE_") || !strings.HasSuffix(ec, "}") { - t.Fatalf("ec ref should be compose interpolation, got %s", ec) - } - bn, err := cfg.GetBeaconImageEnvRef() - if err != nil { - t.Fatal(err) + if ref != ImageTagRef(ECImageTagOverride, ECImageTagDefault) { + t.Fatalf("ec test ref %s", ref) } - if !strings.HasPrefix(bn, "${RP_IMAGE_") { - t.Fatalf("bn ref %s", bn) + files := cfg.ImagesEnvFiles() + if len(files) < 1 || files[0] != ImagesMainnetFile { + t.Fatalf("expected mainnet.env first, got %v", files) } } -func TestSaveImagesEnvWritesTuiOverrides(t *testing.T) { - dir := t.TempDir() - embedded, err := LoadImages("") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, ImagesEnvFile) - if err := WriteEnvFile(path, embedded.Map()); err != nil { - t.Fatal(err) - } - cfg := mustNewRocketPoolConfig(t, dir, false) +func TestComposeEnvOverrides(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) cfg.Smartnode.Network.Value = config.Network("mainnet") - cfg.Geth.ContainerTag.Value = "my/geth:custom" - if err := cfg.SaveImagesEnv(); err != nil { + if err := cfg.Geth.ContainerTag.SetToDefault(config.Network("mainnet")); err != nil { t.Fatal(err) } - parsed, err := LoadEnvFile(path) - if err != nil { - t.Fatal(err) - } - if parsed[ImageGethProd] != "my/geth:custom" { - t.Fatalf("images.env geth %q", parsed[ImageGethProd]) - } - if cfg.ResolvedImage(ImageGethProd) != "my/geth:custom" { - t.Fatalf("catalog geth %q", cfg.ResolvedImage(ImageGethProd)) + if len(cfg.ComposeEnvOverrides()) != 0 { + t.Fatalf("expected no overrides, got %v", cfg.ComposeEnvOverrides()) } - - // Reloading picks the TUI value from images.env, not user-settings.yml. - reloaded := mustNewRocketPoolConfig(t, dir, false) - reloaded.Smartnode.Network.Value = config.Network("mainnet") - if err := reloaded.Geth.ContainerTag.SetToDefault(config.Network("mainnet")); err != nil { - t.Fatal(err) + cfg.Geth.ContainerTag.Value = "my/geth:custom" + overrides := cfg.ComposeEnvOverrides() + if overrides[ECImageTagOverride] != "my/geth:custom" { + t.Fatalf("got %v", overrides) } - if reloaded.Geth.ContainerTag.Value != "my/geth:custom" { - t.Fatalf("reloaded geth default %v", reloaded.Geth.ContainerTag.Value) + defaults := cfg.ComposeImageDefaults() + if defaults[ECImageTagDefault] == "" || defaults[ECImageTagDefault] == "my/geth:custom" { + t.Fatalf("default should stay the catalog pin, got %q", defaults[ECImageTagDefault]) } } -func TestContainerTagsAreNotSerialized(t *testing.T) { +func TestContainerTagsAreSerialized(t *testing.T) { cfg := mustNewRocketPoolConfig(t, "", false) cfg.Geth.ContainerTag.Value = "my/geth:custom" serialized := cfg.Serialize() - if tag, ok := serialized["geth"]["containerTag"]; ok { - t.Fatalf("container tags must not be saved to user-settings.yml, got %q", tag) + if tag, ok := serialized["geth"]["containerTag"]; !ok || tag != "my/geth:custom" { + t.Fatalf("container tags should be saved to user-settings.yml, got %v", serialized["geth"]["containerTag"]) } } diff --git a/shared/services/config/lighthouse-config.go b/shared/services/config/lighthouse-config.go index 1e3ec244d..50577f756 100644 --- a/shared/services/config/lighthouse-config.go +++ b/shared/services/config/lighthouse-config.go @@ -63,9 +63,9 @@ func NewLighthouseConfig(cfg *RocketPoolConfig) *LighthouseConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lighthouse container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Lighthouse container you want to use from Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLighthouseProd), cfg.imageDefault(ImageLighthouseTest)), + Default: cfg.imageTagDefaults(ImageLighthouse), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/lodestar-config.go b/shared/services/config/lodestar-config.go index 0e79f4f2a..aa736f35e 100644 --- a/shared/services/config/lodestar-config.go +++ b/shared/services/config/lodestar-config.go @@ -64,9 +64,9 @@ func NewLodestarConfig(cfg *RocketPoolConfig) *LodestarConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Lodestar container you want to use from Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Lodestar container you want to use from Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageLodestarProd), cfg.imageDefault(ImageLodestarTest)), + Default: cfg.imageTagDefaults(ImageLodestar), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/mev-boost-config.go b/shared/services/config/mev-boost-config.go index eb1197543..f305e2696 100644 --- a/shared/services/config/mev-boost-config.go +++ b/shared/services/config/mev-boost-config.go @@ -175,9 +175,9 @@ func NewMevBoostConfig(cfg *RocketPoolConfig) *MevBoostConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the MEV-Boost container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the MEV-Boost container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageMevBoostProd), cfg.imageDefault(ImageMevBoostTest)), + Default: cfg.imageTagDefaults(ImageMevBoost), AffectsContainers: []config.ContainerID{config.ContainerID_MevBoost}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/nethermind-config.go b/shared/services/config/nethermind-config.go index c2a7b103c..1bf6bd8d2 100644 --- a/shared/services/config/nethermind-config.go +++ b/shared/services/config/nethermind-config.go @@ -166,9 +166,9 @@ func NewNethermindConfig(cfg *RocketPoolConfig) *NethermindConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Nethermind container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Nethermind container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNethermindProd), cfg.imageDefault(ImageNethermindTest)), + Default: cfg.imageTagDefaults(ImageNethermind), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/networks-config.go b/shared/services/config/networks-config.go index ccf420770..bc046cb4b 100644 --- a/shared/services/config/networks-config.go +++ b/shared/services/config/networks-config.go @@ -244,21 +244,6 @@ func hexAddresses(values []string) []common.Address { return out } -func clientTagDefaults(networks *NetworksConfig, prod, test string) map[config.Network]interface{} { - defaults := map[config.Network]interface{}{config.Network_All: test} - if networks == nil { - return defaults - } - for _, n := range networks.AllNetworks() { - if n.ClientTagSet == config.ClientTagSetProduction { - defaults[n.ID()] = prod - } else { - defaults[n.ID()] = test - } - } - return defaults -} - func nethermindPruneThresholdDefaults(networks *NetworksConfig) map[config.Network]interface{} { defaults := map[config.Network]interface{}{config.Network_All: uint64(51200)} if networks == nil { diff --git a/shared/services/config/networks-config_test.go b/shared/services/config/networks-config_test.go index 42bf95c66..4497d92b8 100644 --- a/shared/services/config/networks-config_test.go +++ b/shared/services/config/networks-config_test.go @@ -185,7 +185,7 @@ func TestOfficialGetterLock(t *testing.T) { if cfg.Smartnode.GetChainID() != 1 { t.Fatalf("mainnet chainID %d", cfg.Smartnode.GetChainID()) } - if tag, _ := cfg.Geth.ContainerTag.GetDefault(config.Network("mainnet")); tag != cfg.imageDefault(ImageGethProd) { + if tag, _ := cfg.Geth.ContainerTag.GetDefault(config.Network("mainnet")); tag != cfg.imageMainnet(ImageGeth) { t.Fatalf("mainnet geth tag %v", tag) } diff --git a/shared/services/config/nimbus-config.go b/shared/services/config/nimbus-config.go index fa1d5d85b..81c5680f5 100644 --- a/shared/services/config/nimbus-config.go +++ b/shared/services/config/nimbus-config.go @@ -73,9 +73,9 @@ func NewNimbusConfig(cfg *RocketPoolConfig) *NimbusConfig { BnContainerTag: config.Parameter{ ID: "bnContainerTag", Name: "Beacon Node Container Tag", - Description: "The tag name of the Nimbus Beacon Node container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Nimbus Beacon Node container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusBnProd), cfg.imageDefault(ImageNimbusBnTest)), + Default: cfg.imageTagDefaults(ImageNimbusBn), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -84,9 +84,9 @@ func NewNimbusConfig(cfg *RocketPoolConfig) *NimbusConfig { VcContainerTag: config.Parameter{ ID: "containerTag", Name: "Validator Client Container Tag", - Description: "The tag name of the Nimbus Validator Client container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Nimbus Validator Client container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageNimbusVcProd), cfg.imageDefault(ImageNimbusVcTest)), + Default: cfg.imageTagDefaults(ImageNimbusVc), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/prometheus-config.go b/shared/services/config/prometheus-config.go index 7a017087b..18cfde535 100644 --- a/shared/services/config/prometheus-config.go +++ b/shared/services/config/prometheus-config.go @@ -57,9 +57,9 @@ func NewPrometheusConfig(cfg *RocketPoolConfig) *PrometheusConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Prometheus Container Tag", - Description: "The tag name of the Prometheus container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Prometheus container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: map[config.Network]interface{}{config.Network_All: cfg.imageDefault(ImagePrometheus)}, + Default: map[config.Network]interface{}{config.Network_All: cfg.imageMainnet(ImagePrometheus)}, AffectsContainers: []config.ContainerID{config.ContainerID_Prometheus}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/prysm-config.go b/shared/services/config/prysm-config.go index d8a93564a..e9259f94d 100644 --- a/shared/services/config/prysm-config.go +++ b/shared/services/config/prysm-config.go @@ -102,9 +102,9 @@ func NewPrysmConfig(cfg *RocketPoolConfig) *PrysmConfig { BnContainerTag: config.Parameter{ ID: "bnContainerTag", Name: "Beacon Node Container Tag", - Description: "The tag name of the Prysm Beacon Node container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Prysm Beacon Node container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmBnProd), cfg.imageDefault(ImagePrysmBnTest)), + Default: cfg.imageTagDefaults(ImagePrysmBn), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2}, CanBeBlank: false, OverwriteOnUpgrade: true, @@ -113,9 +113,9 @@ func NewPrysmConfig(cfg *RocketPoolConfig) *PrysmConfig { VcContainerTag: config.Parameter{ ID: "vcContainerTag", Name: "Validator Client Container Tag", - Description: "The tag name of the Prysm Validator Client container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Prysm Validator Client container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImagePrysmVcProd), cfg.imageDefault(ImagePrysmVcTest)), + Default: cfg.imageTagDefaults(ImagePrysmVc), AffectsContainers: []config.ContainerID{config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/reth-config.go b/shared/services/config/reth-config.go index cdf303354..4974b050e 100644 --- a/shared/services/config/reth-config.go +++ b/shared/services/config/reth-config.go @@ -96,9 +96,9 @@ func NewRethConfig(cfg *RocketPoolConfig) *RethConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Reth container you want to use. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Reth container you want to use.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageRethProd), cfg.imageDefault(ImageRethTest)), + Default: cfg.imageTagDefaults(ImageReth), AffectsContainers: []config.ContainerID{config.ContainerID_Eth1}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/config/rocket-pool-config.go b/shared/services/config/rocket-pool-config.go index c984b8b52..9b7a783e6 100644 --- a/shared/services/config/rocket-pool-config.go +++ b/shared/services/config/rocket-pool-config.go @@ -67,8 +67,9 @@ type RocketPoolConfig struct { // IsCLI is true when this config was loaded by the rocketpool CLI (or TUI) not by the node daemon. IsCLI bool `yaml:"-"` - networks *NetworksConfig `yaml:"-"` - images *ImageCatalog `yaml:"-"` + networks *NetworksConfig `yaml:"-"` + imagesMainnet *ImageCatalog `yaml:"-"` + imagesByNetwork map[config.Network]*ImageCatalog `yaml:"-"` // Execution client settings ExecutionClientMode config.Parameter `yaml:"executionClientMode,omitempty"` @@ -265,10 +266,6 @@ func (cfg *RocketPoolConfig) Save(directory, filename string) error { return err } - if err := cfg.SaveImagesEnv(); err != nil { - return err - } - return nil } @@ -278,14 +275,14 @@ func NewRocketPoolConfig(rpDir string, isNativeMode bool) (*RocketPoolConfig, er if err != nil { return nil, fmt.Errorf("could not load networks: %w", err) } - images, err := LoadImages(rpDir) + mainnet, overlays, err := LoadImageCatalogs(rpDir, networks) if err != nil { return nil, fmt.Errorf("could not load images: %w", err) } - return newRocketPoolConfig(rpDir, isNativeMode, networks, images) + return newRocketPoolConfig(rpDir, isNativeMode, networks, mainnet, overlays) } -func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConfig, images *ImageCatalog) (*RocketPoolConfig, error) { +func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConfig, mainnet *ImageCatalog, overlays map[config.Network]*ImageCatalog) (*RocketPoolConfig, error) { clientModes := []config.ParameterOption{{ Name: "Locally Managed", @@ -302,7 +299,8 @@ func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConf RocketPoolDirectory: rpDir, IsNativeMode: isNativeMode, networks: networks, - images: images, + imagesMainnet: mainnet, + imagesByNetwork: overlays, ExecutionClientMode: config.Parameter{ ID: "executionClientMode", @@ -624,9 +622,8 @@ func newRocketPoolConfig(rpDir string, isNativeMode bool, networks *NetworksConf cfg.MevBoost = NewMevBoostConfig(cfg) cfg.CommitBoost = NewCommitBoostConfig(cfg) // Addons - cfg.GraffitiWallWriter = addons.NewGraffitiWallWriter(images.Must(ImageGWW)) + cfg.GraffitiWallWriter = addons.NewGraffitiWallWriter(mainnet.Must(ImageGWW)) cfg.RescueNode = addons.NewRescueNode() - cfg.skipPersistingImageTags() // Apply the default values for the default network from YAML cfg.Smartnode.Network.Value = cfg.networks.DefaultNetwork() @@ -660,7 +657,7 @@ func getAugmentedEcDescription(client config.ExecutionClient, originalDescriptio // Create a copy of this configuration. func (cfg *RocketPoolConfig) CreateCopy() *RocketPoolConfig { - newConfig, err := newRocketPoolConfig(cfg.RocketPoolDirectory, cfg.IsNativeMode, cfg.networks, cfg.images) + newConfig, err := newRocketPoolConfig(cfg.RocketPoolDirectory, cfg.IsNativeMode, cfg.networks, cfg.imagesMainnet, cfg.imagesByNetwork) if err != nil { panic(err) } @@ -1373,15 +1370,15 @@ func (cfg *RocketPoolConfig) GetECContainerTag() (string, error) { switch cfg.ExecutionClient.Value.(config.ExecutionClient) { case config.ExecutionClient_Geth: - return cfg.resolveParamImage(&cfg.Geth.ContainerTag, ImageGethProd, ImageGethTest), nil + return cfg.resolveParamImage(&cfg.Geth.ContainerTag, ImageGeth), nil case config.ExecutionClient_Nethermind: - return cfg.resolveParamImage(&cfg.Nethermind.ContainerTag, ImageNethermindProd, ImageNethermindTest), nil + return cfg.resolveParamImage(&cfg.Nethermind.ContainerTag, ImageNethermind), nil case config.ExecutionClient_Besu: - return cfg.resolveParamImage(&cfg.Besu.ContainerTag, ImageBesuProd, ImageBesuTest), nil + return cfg.resolveParamImage(&cfg.Besu.ContainerTag, ImageBesu), nil case config.ExecutionClient_Reth: - return cfg.resolveParamImage(&cfg.Reth.ContainerTag, ImageRethProd, ImageRethTest), nil + return cfg.resolveParamImage(&cfg.Reth.ContainerTag, ImageReth), nil case config.ExecutionClient_Erigon: - return cfg.resolveParamImage(&cfg.Erigon.ContainerTag, ImageErigonProd, ImageErigonTest), nil + return cfg.resolveParamImage(&cfg.Erigon.ContainerTag, ImageErigon), nil } return "", fmt.Errorf("Unknown Execution Client %s", string(cfg.ExecutionClient.Value.(config.ExecutionClient))) diff --git a/shared/services/config/teku-config.go b/shared/services/config/teku-config.go index 6c30e3a5b..9d696f845 100644 --- a/shared/services/config/teku-config.go +++ b/shared/services/config/teku-config.go @@ -96,9 +96,9 @@ func NewTekuConfig(cfg *RocketPoolConfig) *TekuConfig { ContainerTag: config.Parameter{ ID: "containerTag", Name: "Container Tag", - Description: "The tag name of the Teku container you want to use on Docker Hub. Official pins are in images.env. TUI container-tag changes are written there and replaced on the next Smart Node install.", + Description: "The tag name of the Teku container you want to use on Docker Hub.", Type: config.ParameterType_String, - Default: clientTagDefaults(cfg.networks, cfg.imageDefault(ImageTekuProd), cfg.imageDefault(ImageTekuTest)), + Default: cfg.imageTagDefaults(ImageTeku), AffectsContainers: []config.ContainerID{config.ContainerID_Eth2, config.ContainerID_Validator}, CanBeBlank: false, OverwriteOnUpgrade: true, diff --git a/shared/services/rocketpool/assets/assets.go b/shared/services/rocketpool/assets/assets.go index d6c5ac933..e12ad64d4 100644 --- a/shared/services/rocketpool/assets/assets.go +++ b/shared/services/rocketpool/assets/assets.go @@ -38,15 +38,30 @@ func NetworksDefaultYAML() []byte { return bytes } -// ImagesEnv is the packaged official image catalog -func ImagesEnv() []byte { - bytes, err := installFS.ReadFile("install/images.env") +func ImagesMainnetEnv() []byte { + bytes, err := installFS.ReadFile("install/mainnet.env") if err != nil { - panic("embedded install/images.env is missing: " + err.Error()) + panic("embedded install/mainnet.env is missing: " + err.Error()) } return bytes } +func ImagesTestnetEnv() []byte { + bytes, ok := EmbeddedNetworkEnv("testnet.env") + if !ok { + panic("embedded install/testnet.env is missing") + } + return bytes +} + +func EmbeddedNetworkEnv(name string) ([]byte, bool) { + bytes, err := installFS.ReadFile("install/" + name) + if err != nil { + return nil, false + } + return bytes, true +} + func InstallUpdateTrackerScript() ScriptWithContext { return ScriptWithContext{Script: installUpdateTrackerScript, Context: rpUpdateTrackerFS} } diff --git a/shared/services/rocketpool/assets/install/devnet.env b/shared/services/rocketpool/assets/install/devnet.env new file mode 100644 index 000000000..407736be2 --- /dev/null +++ b/shared/services/rocketpool/assets/install/devnet.env @@ -0,0 +1,6 @@ +# Optional image overrides for the devnet network. +# Uncomment or add KEY=value lines to override mainnet.env for this network only. +# Keys not listed here inherit the mainnet pin. +# +# Example: +# RP_IMAGE_GETH=ethereum/client-go:v1.17.4@sha256:... diff --git a/shared/services/rocketpool/assets/install/images.env b/shared/services/rocketpool/assets/install/images.env deleted file mode 100644 index f1ef60d8c..000000000 --- a/shared/services/rocketpool/assets/install/images.env +++ /dev/null @@ -1,49 +0,0 @@ -# Official Smart Node image pins. -# `rocketpool service config` writes container-tag changes into this file. -# The next `rocketpool service install` replaces it with the official catalog. -# -# Client images have _PROD (mainnet) and _TEST (testnet/devnet) variants. -# They may be equal today; keep both so they can diverge without a format change. -# Values are repo:tag@sha256: so Docker verifies the image and still -# selects the correct architecture. - -RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.0@sha256:8a6b3e35da71bdbfae5fa396bb817e63e95be44e44d3e18fb961dd4848d5fb19 - -RP_IMAGE_GETH_PROD=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 -RP_IMAGE_GETH_TEST=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 -RP_IMAGE_NETHERMIND_PROD=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 -RP_IMAGE_NETHERMIND_TEST=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 -RP_IMAGE_BESU_PROD=hyperledger/besu:26.8.1@sha256:6f3f21ce533383fcc8db3bce02252b59d5a9e776b72b5a1c8ecd2db011600042 -RP_IMAGE_BESU_TEST=hyperledger/besu:26.8.1@sha256:6f3f21ce533383fcc8db3bce02252b59d5a9e776b72b5a1c8ecd2db011600042 -RP_IMAGE_RETH_PROD=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f15bbce2047a92a771ff4efbcd08ee8f9dbd4d1 -RP_IMAGE_RETH_TEST=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f15bbce2047a92a771ff4efbcd08ee8f9dbd4d1 -RP_IMAGE_ERIGON_PROD=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 -RP_IMAGE_ERIGON_TEST=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 - -RP_IMAGE_LIGHTHOUSE_PROD=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 -RP_IMAGE_LIGHTHOUSE_TEST=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 -RP_IMAGE_LODESTAR_PROD=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 -RP_IMAGE_LODESTAR_TEST=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 -RP_IMAGE_NIMBUS_BN_PROD=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 -RP_IMAGE_NIMBUS_BN_TEST=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 -RP_IMAGE_NIMBUS_VC_PROD=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 -RP_IMAGE_NIMBUS_VC_TEST=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 -RP_IMAGE_TEKU_PROD=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e -RP_IMAGE_TEKU_TEST=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e -RP_IMAGE_PRYSM_BN_PROD=gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8@sha256:31239807cbbd446aabe8e7d18e0cd50c430a290ce7906b55eb5d54c7d4e3b676 -RP_IMAGE_PRYSM_BN_TEST=gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8@sha256:31239807cbbd446aabe8e7d18e0cd50c430a290ce7906b55eb5d54c7d4e3b676 -RP_IMAGE_PRYSM_VC_PROD=gcr.io/offchainlabs/prysm/validator:v7.1.8@sha256:7fe554adf0efd27c0e5c5a3f80a3bbbec3d3872626208cf0a003b0dee7761f89 -RP_IMAGE_PRYSM_VC_TEST=gcr.io/offchainlabs/prysm/validator:v7.1.8@sha256:7fe554adf0efd27c0e5c5a3f80a3bbbec3d3872626208cf0a003b0dee7761f89 - -RP_IMAGE_MEV_BOOST_PROD=flashbots/mev-boost:1.12.0@sha256:7d7f51ba6a3f02a592340086a425fa3aa80d39ace4575dd9556f01df7a4e8f26 -RP_IMAGE_MEV_BOOST_TEST=flashbots/mev-boost:1.12.0@sha256:7d7f51ba6a3f02a592340086a425fa3aa80d39ace4575dd9556f01df7a4e8f26 -RP_IMAGE_COMMIT_BOOST_PROD=ghcr.io/commit-boost/commit-boost:v0.10.1@sha256:aa8fbe1af3cd8b9cb2f9ce01603c502d2dc6c2ef7ca9bae4952f281f7d1030dd -RP_IMAGE_COMMIT_BOOST_TEST=ghcr.io/commit-boost/commit-boost:v0.10.1@sha256:aa8fbe1af3cd8b9cb2f9ce01603c502d2dc6c2ef7ca9bae4952f281f7d1030dd - -RP_IMAGE_PROMETHEUS=prom/prometheus:v3.14.0@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0 -RP_IMAGE_EXPORTER=prom/node-exporter:v1.12.1@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0 -RP_IMAGE_ALERTMANAGER=prom/alertmanager:v0.26.0@sha256:361db356b33041437517f1cd298462055580585f26555c317df1a3caf2868552 -RP_IMAGE_GRAFANA=grafana/grafana:9.5.18@sha256:42bb294e8807526658eceb19461e7646171ca48d15f9ac2ac511f1fe63599c54 -RP_IMAGE_GWW=rocketpool/graffiti-wall-addon:v1.0.1@sha256:bf3c224047d1c098d1f714e0ea1aae55d729521b99729ab5a4ddba4d5aec22f1 -RP_IMAGE_CURL=curlimages/curl:8.13.0@sha256:d43bdb28bae0be0998f3be83199bfb2b81e0a30b034b6d7586ce7e05de34c3fd -RP_IMAGE_ALPINE=alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c diff --git a/shared/services/rocketpool/assets/install/mainnet.env b/shared/services/rocketpool/assets/install/mainnet.env new file mode 100644 index 000000000..26eb7dc91 --- /dev/null +++ b/shared/services/rocketpool/assets/install/mainnet.env @@ -0,0 +1,32 @@ +# Default Smart Node image pins (used on every network). +# Do not edit this file; `rocketpool service install` replaces it. +# Per-network overrides go in {network}.env (testnet.env, devnet.env, +# plataberget.env, ...): only keys present there replace these defaults. +# TUI container-tag changes are set in the Compose process environment and win over both. + +RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.0@sha256:8a6b3e35da71bdbfae5fa396bb817e63e95be44e44d3e18fb961dd4848d5fb19 + +RP_IMAGE_GETH=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 +RP_IMAGE_NETHERMIND=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 +RP_IMAGE_BESU=hyperledger/besu:26.8.1@sha256:6f3f21ce533383fcc8db3bce02252b59d5a9e776b72b5a1c8ecd2db011600042 +RP_IMAGE_RETH=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f15bbce2047a92a771ff4efbcd08ee8f9dbd4d1 +RP_IMAGE_ERIGON=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 + +RP_IMAGE_LIGHTHOUSE=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 +RP_IMAGE_LODESTAR=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 +RP_IMAGE_NIMBUS_BN=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 +RP_IMAGE_NIMBUS_VC=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 +RP_IMAGE_TEKU=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e +RP_IMAGE_PRYSM_BN=gcr.io/offchainlabs/prysm/beacon-chain:v7.1.8@sha256:31239807cbbd446aabe8e7d18e0cd50c430a290ce7906b55eb5d54c7d4e3b676 +RP_IMAGE_PRYSM_VC=gcr.io/offchainlabs/prysm/validator:v7.1.8@sha256:7fe554adf0efd27c0e5c5a3f80a3bbbec3d3872626208cf0a003b0dee7761f89 + +RP_IMAGE_MEV_BOOST=flashbots/mev-boost:1.12.0@sha256:7d7f51ba6a3f02a592340086a425fa3aa80d39ace4575dd9556f01df7a4e8f26 +RP_IMAGE_COMMIT_BOOST=ghcr.io/commit-boost/commit-boost:v0.10.1@sha256:aa8fbe1af3cd8b9cb2f9ce01603c502d2dc6c2ef7ca9bae4952f281f7d1030dd + +RP_IMAGE_PROMETHEUS=prom/prometheus:v3.14.0@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0 +RP_IMAGE_EXPORTER=prom/node-exporter:v1.12.1@sha256:1b4e4438faca4dd7e001dd445d161a4a2091b0fededa84093b3a8dfeae1f1be0 +RP_IMAGE_ALERTMANAGER=prom/alertmanager:v0.26.0@sha256:361db356b33041437517f1cd298462055580585f26555c317df1a3caf2868552 +RP_IMAGE_GRAFANA=grafana/grafana:9.5.18@sha256:42bb294e8807526658eceb19461e7646171ca48d15f9ac2ac511f1fe63599c54 +RP_IMAGE_GWW=rocketpool/graffiti-wall-addon:v1.0.1@sha256:bf3c224047d1c098d1f714e0ea1aae55d729521b99729ab5a4ddba4d5aec22f1 +RP_IMAGE_CURL=curlimages/curl:8.13.0@sha256:d43bdb28bae0be0998f3be83199bfb2b81e0a30b034b6d7586ce7e05de34c3fd +RP_IMAGE_ALPINE=alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c diff --git a/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl b/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl index ef458263c..802b502e0 100644 --- a/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl +++ b/shared/services/rocketpool/assets/install/templates/addons/gww/addon_gww.tmpl @@ -7,7 +7,7 @@ services: addon_gww: - image: ${RP_IMAGE_GWW} + image: ${GWW_IMAGE_TAG_OVERRIDE:-${GWW_IMAGE_TAG_DEFAULT}} user: root container_name: {{.Smartnode.ProjectName}}_addon_gww restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl b/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl index 2c2ef02ca..517cefda6 100644 --- a/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl +++ b/shared/services/rocketpool/assets/install/templates/alertmanager.tmpl @@ -7,7 +7,7 @@ services: alertmanager: - image: ${RP_IMAGE_ALERTMANAGER} + image: ${ALERTMANAGER_IMAGE_TAG_OVERRIDE:-${ALERTMANAGER_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_alertmanager restart: unless-stopped command: diff --git a/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl b/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl index 9e3c65d13..6187f4cd6 100644 --- a/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl +++ b/shared/services/rocketpool/assets/install/templates/commit-boost.tmpl @@ -7,7 +7,7 @@ services: commit-boost: - image: {{.GetCommitBoostImageEnvRef}} + image: ${COMMIT_BOOST_IMAGE_TAG_OVERRIDE:-${COMMIT_BOOST_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_commit-boost restart: unless-stopped ports: [{{.GetCommitBoostOpenPorts}}] diff --git a/shared/services/rocketpool/assets/install/templates/eth1.tmpl b/shared/services/rocketpool/assets/install/templates/eth1.tmpl index 6f0514dfb..eaa120e18 100644 --- a/shared/services/rocketpool/assets/install/templates/eth1.tmpl +++ b/shared/services/rocketpool/assets/install/templates/eth1.tmpl @@ -7,7 +7,7 @@ services: eth1: - image: {{.GetECImageEnvRef}} + image: ${EC_IMAGE_TAG_OVERRIDE:-${EC_IMAGE_TAG_DEFAULT}} user: root container_name: {{.Smartnode.ProjectName}}_eth1 restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/eth2.tmpl b/shared/services/rocketpool/assets/install/templates/eth2.tmpl index 714c12c32..8d9c50c0b 100644 --- a/shared/services/rocketpool/assets/install/templates/eth2.tmpl +++ b/shared/services/rocketpool/assets/install/templates/eth2.tmpl @@ -7,7 +7,7 @@ {{define "GENESIS_DL"}} eth2_genesis_downloader: - image: ${RP_IMAGE_CURL} + image: ${CURL_IMAGE_TAG_OVERRIDE:-${CURL_IMAGE_TAG_DEFAULT}} user: root container_name: {{.Smartnode.ProjectName}}_genesis_downloader volumes: @@ -26,7 +26,7 @@ services: eth2: - image: {{.GetBeaconImageEnvRef}} + image: ${BN_IMAGE_TAG_OVERRIDE:-${BN_IMAGE_TAG_DEFAULT}} user: root container_name: {{.Smartnode.ProjectName}}_eth2 restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/exporter.tmpl b/shared/services/rocketpool/assets/install/templates/exporter.tmpl index 8655ac249..8c0284b24 100644 --- a/shared/services/rocketpool/assets/install/templates/exporter.tmpl +++ b/shared/services/rocketpool/assets/install/templates/exporter.tmpl @@ -7,7 +7,7 @@ services: node-exporter: - image: ${RP_IMAGE_EXPORTER} + image: ${EXPORTER_IMAGE_TAG_OVERRIDE:-${EXPORTER_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_exporter cap_drop: - ALL diff --git a/shared/services/rocketpool/assets/install/templates/grafana.tmpl b/shared/services/rocketpool/assets/install/templates/grafana.tmpl index dd3a9f311..4705a6306 100644 --- a/shared/services/rocketpool/assets/install/templates/grafana.tmpl +++ b/shared/services/rocketpool/assets/install/templates/grafana.tmpl @@ -8,7 +8,7 @@ {{$port := (or .Grafana.Port.Value 3100) -}} services: grafana: - image: ${RP_IMAGE_GRAFANA} + image: ${GRAFANA_IMAGE_TAG_OVERRIDE:-${GRAFANA_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_grafana restart: unless-stopped environment: diff --git a/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl b/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl index 5bb4d39c8..2d27af0fb 100644 --- a/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl +++ b/shared/services/rocketpool/assets/install/templates/mev-boost.tmpl @@ -7,7 +7,7 @@ services: mev-boost: - image: {{.GetMevBoostImageEnvRef}} + image: ${MEV_BOOST_IMAGE_TAG_OVERRIDE:-${MEV_BOOST_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_mev-boost restart: unless-stopped ports: [{{.GetMevBoostOpenPorts}}] diff --git a/shared/services/rocketpool/assets/install/templates/node.tmpl b/shared/services/rocketpool/assets/install/templates/node.tmpl index 5713796fe..8beb416a3 100644 --- a/shared/services/rocketpool/assets/install/templates/node.tmpl +++ b/shared/services/rocketpool/assets/install/templates/node.tmpl @@ -7,7 +7,7 @@ services: node: - image: ${RP_IMAGE_SMARTNODE} + image: ${SMARTNODE_IMAGE_TAG_OVERRIDE:-${SMARTNODE_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_node restart: unless-stopped tty: true diff --git a/shared/services/rocketpool/assets/install/templates/prometheus.tmpl b/shared/services/rocketpool/assets/install/templates/prometheus.tmpl index a55c918a6..3cc91dbf0 100644 --- a/shared/services/rocketpool/assets/install/templates/prometheus.tmpl +++ b/shared/services/rocketpool/assets/install/templates/prometheus.tmpl @@ -7,7 +7,7 @@ services: prometheus: - image: ${RP_IMAGE_PROMETHEUS} + image: ${PROMETHEUS_IMAGE_TAG_OVERRIDE:-${PROMETHEUS_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_prometheus restart: unless-stopped command: diff --git a/shared/services/rocketpool/assets/install/templates/validator.tmpl b/shared/services/rocketpool/assets/install/templates/validator.tmpl index 134b6dd44..24f8c6746 100644 --- a/shared/services/rocketpool/assets/install/templates/validator.tmpl +++ b/shared/services/rocketpool/assets/install/templates/validator.tmpl @@ -7,7 +7,7 @@ services: validator: - image: {{.GetVCImageEnvRef}} + image: ${VC_IMAGE_TAG_OVERRIDE:-${VC_IMAGE_TAG_DEFAULT}} user: root container_name: {{.Smartnode.ProjectName}}_validator restart: unless-stopped diff --git a/shared/services/rocketpool/assets/install/templates/watchtower.tmpl b/shared/services/rocketpool/assets/install/templates/watchtower.tmpl index bc4a31243..cfce37509 100644 --- a/shared/services/rocketpool/assets/install/templates/watchtower.tmpl +++ b/shared/services/rocketpool/assets/install/templates/watchtower.tmpl @@ -7,7 +7,7 @@ services: watchtower: - image: ${RP_IMAGE_SMARTNODE} + image: ${SMARTNODE_IMAGE_TAG_OVERRIDE:-${SMARTNODE_IMAGE_TAG_DEFAULT}} container_name: {{.Smartnode.ProjectName}}_watchtower restart: unless-stopped tty: true diff --git a/shared/services/rocketpool/assets/install/testnet.env b/shared/services/rocketpool/assets/install/testnet.env new file mode 100644 index 000000000..c32085042 --- /dev/null +++ b/shared/services/rocketpool/assets/install/testnet.env @@ -0,0 +1,7 @@ +# Optional image overrides for the testnet network. +# Uncomment or add KEY=value lines to override mainnet.env for this network only. +# Keys not listed here inherit the mainnet pin. +# +# Example: +# RP_IMAGE_GETH=ethereum/client-go:v1.17.4@sha256:... +RP_IMAGE_GETH=ethereum/client-go:v1.17.4 \ No newline at end of file diff --git a/shared/services/rocketpool/assets/scripts/install.sh b/shared/services/rocketpool/assets/scripts/install.sh index c101897e2..6ad6d9dd5 100755 --- a/shared/services/rocketpool/assets/scripts/install.sh +++ b/shared/services/rocketpool/assets/scripts/install.sh @@ -411,7 +411,9 @@ progress 7 "Copying package files to Rocket Pool user data directory..." { cp -r "$PACKAGE_FILES_PATH/templates" "$RP_PATH" || fail "Could not copy templates folder to the Rocket Pool user data directory."; } >&2 { cp -r "$PACKAGE_FILES_PATH/alerting" "$RP_PATH" || fail "Could not copy alerting folder to the Rocket Pool user data directory."; } >&2 { cp -r "$PACKAGE_FILES_PATH/devnet" "$RP_PATH" || fail "Could not copy devnet folder to the Rocket Pool user data directory."; } >&2 -{ cp "$PACKAGE_FILES_PATH/images.env" "$RP_PATH/images.env" || fail "Could not copy images.env to the Rocket Pool user data directory."; } >&2 +{ cp "$PACKAGE_FILES_PATH/mainnet.env" "$RP_PATH/mainnet.env" || fail "Could not copy mainnet.env to the Rocket Pool user data directory."; } >&2 +{ cp "$PACKAGE_FILES_PATH/testnet.env" "$RP_PATH/testnet.env" || fail "Could not copy testnet.env to the Rocket Pool user data directory."; } >&2 +{ cp "$PACKAGE_FILES_PATH/devnet.env" "$RP_PATH/devnet.env" || fail "Could not copy devnet.env to the Rocket Pool user data directory."; } >&2 { cp \ "$PACKAGE_FILES_PATH/grafana-prometheus-datasource.yml" \ "$PACKAGE_FILES_PATH/prometheus.tmpl" \ @@ -424,6 +426,7 @@ progress 7 "Copying package files to Rocket Pool user data directory..." # Clean up unnecessary files from old installations progress 8 "Cleaning up obsolete files from previous installs..." { rm -rf "$DATA_PATH/fr-default" || echo "NOTE: Could not remove '$DATA_PATH/fr-default' which is no longer needed."; } >&2 +{ rm -f "$RP_PATH/images.env" || true; } >&2 # Install shell completion for rocketpool CLI progress 9 "Installing shell completion for rocketpool CLI..." diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index e74aba46d..8387668fa 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -16,6 +16,7 @@ import ( "os/exec" "path/filepath" "slices" + "sort" "strconv" "strings" "sync" @@ -1178,38 +1179,104 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { return "", fmt.Errorf("error deploying Docker templates: %w", err) } - // Include all of the relevant docker compose definition files - composeFileFlags := []string{} - for _, container := range deployedContainers { - composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(container))) + if err := ensureImageEnvFiles(expandedConfigPath); err != nil { + return "", err + } + + tagsPath := filepath.Join(expandedConfigPath, config.ImageTagsEnvFile) + if err := config.WriteEnvFile(tagsPath, cfg.ComposeImageDefaults()); err != nil { + return "", fmt.Errorf("error writing %s: %w", tagsPath, err) + } + + composeMain := filepath.Join(expandedConfigPath, runtimeDir, config.ComposeMainFile) + envFiles := append(cfg.ImagesEnvFiles(), config.ImageTagsEnvFile) + if err := writeComposeMain(composeMain, expandedConfigPath, envFiles, deployedContainers); err != nil { + return "", err + } + + composeFileFlags := []string{ + fmt.Sprintf("-f %s", shellescape.Quote(composeMain)), } for _, container := range composeFiles { composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(container))) } - imagesEnv := filepath.Join(expandedConfigPath, config.ImagesEnvFile) - if err := ensureImagesEnv(imagesEnv); err != nil { - return "", err + // Compose interpolates the process environment ahead of include env_file. + envPrefix := []string{ + fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", cfg.Smartnode.ProjectName.Value.(string)), + } + overrides := cfg.ComposeEnvOverrides() + overrideKeys := make([]string, 0, len(overrides)) + for key := range overrides { + overrideKeys = append(overrideKeys, key) + } + sort.Strings(overrideKeys) + for _, key := range overrideKeys { + envPrefix = append(envPrefix, fmt.Sprintf("%s=%s", key, shellescape.Quote(overrides[key]))) } - envFileFlag := fmt.Sprintf("--env-file %s", shellescape.Quote(imagesEnv)) - // Return command - return fmt.Sprintf("COMPOSE_PROJECT_NAME=%s docker compose %s --project-directory %s %s %s", cfg.Smartnode.ProjectName.Value.(string), envFileFlag, shellescape.Quote(expandedConfigPath), strings.Join(composeFileFlags, " "), args), nil + parts := append(envPrefix, "docker compose", + "--project-directory", shellescape.Quote(expandedConfigPath), + strings.Join(composeFileFlags, " "), + args, + ) + return strings.Join(parts, " "), nil } -func ensureImagesEnv(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } else if !os.IsNotExist(err) { - return fmt.Errorf("could not stat %s: %w", path, err) - } - if err := os.WriteFile(path, assets.ImagesEnv(), 0664); err != nil { - return fmt.Errorf("could not write %s: %w", path, err) +func ensureImageEnvFiles(dir string) error { + official := []string{config.ImagesMainnetFile, config.ImagesTestnetFile, config.ImagesDevnetFile} + for _, name := range official { + data, ok := assets.EmbeddedNetworkEnv(name) + if !ok && name == config.ImagesMainnetFile { + data = assets.ImagesMainnetEnv() + ok = true + } + if !ok { + continue + } + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + continue + } else if !os.IsNotExist(err) { + return fmt.Errorf("could not stat %s: %w", path, err) + } + if err := os.WriteFile(path, data, 0664); err != nil { + return fmt.Errorf("could not write %s: %w", path, err) + } } return nil } +// writeComposeMain writes runtime/compose.yml with include + env_file so catalog +// interpolation happens in Compose. Later env files override earlier ones +// (mainnet.env then {network}.env). TUI overrides are set in the process environment. +func writeComposeMain(composeMain, projectDir string, envFiles []string, deployed []string) error { + var b strings.Builder + b.WriteString("# Autogenerated - DO NOT MODIFY THIS FILE DIRECTLY\n") + b.WriteString("include:\n") + envAbs := make([]string, 0, len(envFiles)) + for _, name := range envFiles { + envAbs = append(envAbs, filepath.Join(projectDir, name)) + } + for _, path := range deployed { + b.WriteString(" - path: ") + b.WriteString(path) + b.WriteByte('\n') + rel, err := filepath.Rel(projectDir, path) + if err == nil && (rel == overrideDir || strings.HasPrefix(rel, overrideDir+string(filepath.Separator))) { + continue + } + b.WriteString(" env_file:\n") + for _, envPath := range envAbs { + b.WriteString(" - ") + b.WriteString(envPath) + b.WriteByte('\n') + } + } + return os.WriteFile(composeMain, []byte(b.String()), 0664) +} + // Deploys all of the appropriate docker compose template files and provisions them based on the provided configuration func (c *Client) deployTemplates(cfg *config.RocketPoolConfig, rocketpoolDir string) ([]string, error) { From 07ed9b49db306e83e10346fc48b0879c1742e0db Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:12:48 -0300 Subject: [PATCH 5/8] Add /scripts/* --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ba7615e21..e34cbb460 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ .DS_Store vendor node_modules -/scripts/config.js -/scripts/pin-images.sh +/scripts/* /rocketpool/rocketpool /rocketpool-cli/rocketpool-cli-darwin-amd64 /rocketpool-cli/rocketpool-cli-linux-amd64 From 72ec9878736c3e3d24424fe90c32bd2d657a7087 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:17:38 -0300 Subject: [PATCH 6/8] Add compose.tmpl --- .../service/config/standard-layout.go | 7 ++ shared/services/config/images.go | 49 ++++++++++- shared/services/config/images_test.go | 42 ++++++++++ .../assets/install/override/compose.yml | 8 ++ .../assets/install/templates/compose.tmpl | 10 +++ shared/services/rocketpool/client.go | 84 +++++++++---------- 6 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 shared/services/rocketpool/assets/install/override/compose.yml create mode 100644 shared/services/rocketpool/assets/install/templates/compose.tmpl diff --git a/rocketpool-cli/service/config/standard-layout.go b/rocketpool-cli/service/config/standard-layout.go index e7fae17b2..7609efd7c 100644 --- a/rocketpool-cli/service/config/standard-layout.go +++ b/rocketpool-cli/service/config/standard-layout.go @@ -96,6 +96,13 @@ func (layout *standardLayout) createForm(networkParam *cfgtypes.Parameter, title param := layout.parameters[formItem].parameter defaultValue, _ := param.GetDefault(networkParam.Value.(cfgtypes.Network)) descriptionText := fmt.Sprintf("Default: %v\n\n%s", defaultValue, param.Description) + if param.ID == "containerTag" { + if fmt.Sprint(param.Value) == fmt.Sprint(defaultValue) { + descriptionText = "This is the default version of this service for the current Smart Node release.\n\n" + param.Description + } else { + descriptionText = fmt.Sprintf("The default version for this service is %v.\n\n%s", defaultValue, param.Description) + } + } layout.descriptionBox.SetText(descriptionText) layout.descriptionBox.ScrollToBeginning() } diff --git a/shared/services/config/images.go b/shared/services/config/images.go index 931304b91..2f475c5d0 100644 --- a/shared/services/config/images.go +++ b/shared/services/config/images.go @@ -379,7 +379,8 @@ func (cfg *RocketPoolConfig) overlayFileName() string { return catalogFileName(info.ID()) } -// ImagesEnvFiles is the Compose env_file list: mainnet.env, then the network overlay if it exists. +// ImagesEnvFiles is the on-disk catalog list (mainnet.env, then {network}.env). +// Compose interpolates runtime/image-tags.env, which is generated from these. func (cfg *RocketPoolConfig) ImagesEnvFiles() []string { files := []string{ImagesMainnetFile} if name := cfg.overlayFileName(); name != "" { @@ -666,6 +667,52 @@ func (cfg *RocketPoolConfig) ComposeEnvOverrides() map[string]string { return out } +func validateEnvAssignment(key, value string) error { + if strings.ContainsAny(key, " \t\n\r") { + return fmt.Errorf("image env name %q contains whitespace", key) + } + if strings.ContainsAny(value, " \t\n\r") { + return fmt.Errorf("image env %s value %q contains whitespace", key, value) + } + return nil +} + +// ComposeImageEnv is the single env map Compose interpolates: service +// *_DEFAULT pins (mainnet + network overlay) plus *_OVERRIDE when the TUI +// customized a tag. +func (cfg *RocketPoolConfig) ComposeImageEnv() (map[string]string, error) { + out := cfg.ComposeImageDefaults() + for key, value := range cfg.ComposeEnvOverrides() { + out[key] = value + } + for key, value := range out { + if err := validateEnvAssignment(key, value); err != nil { + return nil, err + } + } + return out, nil +} + +// ComposeEnvAssignments returns KEY=value strings for TUI image overrides. +// Keys are sorted so command logs and diffs are reproducible. +func (cfg *RocketPoolConfig) ComposeEnvAssignments() ([]string, error) { + overrides := cfg.ComposeEnvOverrides() + keys := make([]string, 0, len(overrides)) + for key := range overrides { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, key := range keys { + value := overrides[key] + if err := validateEnvAssignment(key, value); err != nil { + return nil, err + } + out = append(out, key+"="+value) + } + return out, nil +} + func (cfg *RocketPoolConfig) resolveParamImage(param *config.Parameter, key string) string { if param == nil { return "" diff --git a/shared/services/config/images_test.go b/shared/services/config/images_test.go index 5d348a9be..496b9c1a6 100644 --- a/shared/services/config/images_test.go +++ b/shared/services/config/images_test.go @@ -192,6 +192,48 @@ func TestComposeEnvOverrides(t *testing.T) { if defaults[ECImageTagDefault] == "" || defaults[ECImageTagDefault] == "my/geth:custom" { t.Fatalf("default should stay the catalog pin, got %q", defaults[ECImageTagDefault]) } + env, err := cfg.ComposeImageEnv() + if err != nil { + t.Fatal(err) + } + if env[ECImageTagDefault] != defaults[ECImageTagDefault] { + t.Fatalf("merged default %q", env[ECImageTagDefault]) + } + if env[ECImageTagOverride] != "my/geth:custom" { + t.Fatalf("merged override %q", env[ECImageTagOverride]) + } +} + +func TestComposeEnvAssignmentsSorted(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.Smartnode.Network.Value = config.Network("mainnet") + cfg.ExecutionClientMode.Value = config.Mode_Local + cfg.ExecutionClient.Value = config.ExecutionClient_Geth + cfg.Geth.ContainerTag.Value = "my/geth:custom" + cfg.Prometheus.ContainerTag.Value = "my/prom:custom" + got, err := cfg.ComposeEnvAssignments() + if err != nil { + t.Fatal(err) + } + if len(got) < 2 { + t.Fatalf("expected multiple assignments, got %v", got) + } + for i := 1; i < len(got); i++ { + if got[i-1] >= got[i] { + t.Fatalf("assignments not sorted: %v", got) + } + } +} + +func TestComposeEnvAssignmentsRejectsWhitespace(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.Smartnode.Network.Value = config.Network("mainnet") + cfg.ExecutionClientMode.Value = config.Mode_Local + cfg.ExecutionClient.Value = config.ExecutionClient_Geth + cfg.Geth.ContainerTag.Value = "my/geth custom" + if _, err := cfg.ComposeEnvAssignments(); err == nil { + t.Fatal("expected error for whitespace in override value") + } } func TestContainerTagsAreSerialized(t *testing.T) { diff --git a/shared/services/rocketpool/assets/install/override/compose.yml b/shared/services/rocketpool/assets/install/override/compose.yml new file mode 100644 index 000000000..48251825e --- /dev/null +++ b/shared/services/rocketpool/assets/install/override/compose.yml @@ -0,0 +1,8 @@ +# Global compose overrides. These persist across upgrades and are merged after +# runtime/compose.yml, so they can add includes or override any service. +# +# Example: +# include: +# - path: extra.yml +# env_file: +# - extra.env diff --git a/shared/services/rocketpool/assets/install/templates/compose.tmpl b/shared/services/rocketpool/assets/install/templates/compose.tmpl new file mode 100644 index 000000000..b2a0b6826 --- /dev/null +++ b/shared/services/rocketpool/assets/install/templates/compose.tmpl @@ -0,0 +1,10 @@ +# Autogenerated - DO NOT MODIFY THIS FILE DIRECTLY +# Global overrides belong in override/compose.yml (merged after this file). +include: +{{- range .Includes }} + - path: {{.Path}} +{{- if .EnvFile }} + env_file: + - {{.EnvFile}} +{{- end }} +{{- end }} diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index 8387668fa..6fa1b0c07 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -16,7 +16,6 @@ import ( "os/exec" "path/filepath" "slices" - "sort" "strconv" "strings" "sync" @@ -1183,43 +1182,42 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { return "", err } + imageEnv, err := cfg.ComposeImageEnv() + if err != nil { + return "", err + } tagsPath := filepath.Join(expandedConfigPath, config.ImageTagsEnvFile) - if err := config.WriteEnvFile(tagsPath, cfg.ComposeImageDefaults()); err != nil { + if err := config.WriteEnvFile(tagsPath, imageEnv); err != nil { return "", fmt.Errorf("error writing %s: %w", tagsPath, err) } - composeMain := filepath.Join(expandedConfigPath, runtimeDir, config.ComposeMainFile) - envFiles := append(cfg.ImagesEnvFiles(), config.ImageTagsEnvFile) - if err := writeComposeMain(composeMain, expandedConfigPath, envFiles, deployedContainers); err != nil { - return "", err + composePaths := template.ComposePaths{ + RuntimePath: filepath.Join(expandedConfigPath, runtimeDir), + TemplatePath: filepath.Join(expandedConfigPath, templatesDir), + OverridePath: filepath.Join(expandedConfigPath, overrideDir), + } + composePair, err := composePaths.File("compose").Write(composeTemplateData{ + Includes: composeIncludes(expandedConfigPath, config.ImageTagsEnvFile, deployedContainers), + }) + if err != nil { + return "", fmt.Errorf("error writing compose.yml: %w", err) } - composeFileFlags := []string{ - fmt.Sprintf("-f %s", shellescape.Quote(composeMain)), + composeFileFlags := []string{} + for _, path := range composePair { + composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(path))) } for _, container := range composeFiles { composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(container))) } - // Compose interpolates the process environment ahead of include env_file. - envPrefix := []string{ + parts := []string{ fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", cfg.Smartnode.ProjectName.Value.(string)), - } - overrides := cfg.ComposeEnvOverrides() - overrideKeys := make([]string, 0, len(overrides)) - for key := range overrides { - overrideKeys = append(overrideKeys, key) - } - sort.Strings(overrideKeys) - for _, key := range overrideKeys { - envPrefix = append(envPrefix, fmt.Sprintf("%s=%s", key, shellescape.Quote(overrides[key]))) - } - - parts := append(envPrefix, "docker compose", + "docker compose", "--project-directory", shellescape.Quote(expandedConfigPath), strings.Join(composeFileFlags, " "), args, - ) + } return strings.Join(parts, " "), nil } @@ -1248,33 +1246,27 @@ func ensureImageEnvFiles(dir string) error { return nil } -// writeComposeMain writes runtime/compose.yml with include + env_file so catalog -// interpolation happens in Compose. Later env files override earlier ones -// (mainnet.env then {network}.env). TUI overrides are set in the process environment. -func writeComposeMain(composeMain, projectDir string, envFiles []string, deployed []string) error { - var b strings.Builder - b.WriteString("# Autogenerated - DO NOT MODIFY THIS FILE DIRECTLY\n") - b.WriteString("include:\n") - envAbs := make([]string, 0, len(envFiles)) - for _, name := range envFiles { - envAbs = append(envAbs, filepath.Join(projectDir, name)) - } +type composeInclude struct { + Path string + EnvFile string +} + +type composeTemplateData struct { + Includes []composeInclude +} + +func composeIncludes(projectDir, envFile string, deployed []string) []composeInclude { + envAbs := filepath.Join(projectDir, envFile) + out := make([]composeInclude, 0, len(deployed)) for _, path := range deployed { - b.WriteString(" - path: ") - b.WriteString(path) - b.WriteByte('\n') + entry := composeInclude{Path: path} rel, err := filepath.Rel(projectDir, path) - if err == nil && (rel == overrideDir || strings.HasPrefix(rel, overrideDir+string(filepath.Separator))) { - continue - } - b.WriteString(" env_file:\n") - for _, envPath := range envAbs { - b.WriteString(" - ") - b.WriteString(envPath) - b.WriteByte('\n') + if err != nil || (rel != overrideDir && !strings.HasPrefix(rel, overrideDir+string(filepath.Separator))) { + entry.EnvFile = envAbs } + out = append(out, entry) } - return os.WriteFile(composeMain, []byte(b.String()), 0664) + return out } // Deploys all of the appropriate docker compose template files and provisions them based on the provided configuration From 15db6407d75b4b9802b63c9e8446360526015456 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:20:10 -0300 Subject: [PATCH 7/8] Pin Lodestar v1.48.0 from master in mainnet.env --- shared/services/rocketpool/assets/install/mainnet.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/services/rocketpool/assets/install/mainnet.env b/shared/services/rocketpool/assets/install/mainnet.env index 26eb7dc91..5ca1f6d69 100644 --- a/shared/services/rocketpool/assets/install/mainnet.env +++ b/shared/services/rocketpool/assets/install/mainnet.env @@ -13,7 +13,7 @@ RP_IMAGE_RETH=ghcr.io/paradigmxyz/reth:v2.5.2@sha256:68e76b32ad9ac1a3de54984d6f1 RP_IMAGE_ERIGON=erigontech/erigon:v3.6.1@sha256:8cd3fbcbb35d8b16768225ff7494a0672b7b06c9bf13d466850aabb920ac0de7 RP_IMAGE_LIGHTHOUSE=sigp/lighthouse:v8.2.2@sha256:9a62bb8705455136e1cf96613460960f80dc2faa9d75b5c16a3bfcc9210dcdd1 -RP_IMAGE_LODESTAR=chainsafe/lodestar:v1.47.0@sha256:5593f6e97912d6aa47114f99850c516062902c7eafd502d79ea0acaa3d38ba30 +RP_IMAGE_LODESTAR=chainsafe/lodestar:v1.48.0@sha256:d717e4193699a67c1ee38568652827e4d33d243cabb51d5137938721aefd2886 RP_IMAGE_NIMBUS_BN=statusim/nimbus-eth2:multiarch-v26.8.0@sha256:6ccd9d382885115d9d3b6d9c565e3da38caf1bdea3c9e9cadab0e879eca3b769 RP_IMAGE_NIMBUS_VC=statusim/nimbus-validator-client:multiarch-v26.8.0@sha256:2a8af2c1c1be69cd47426130780ca2027cafea43363e6485372ab9ffad5514b1 RP_IMAGE_TEKU=consensys/teku:26.8.0@sha256:3a4f5761ae1c441ea26ac1f32b774e15e06e49a9861185a07e29ca7a6c3e434e From 769596054533288842d5e94ef83a2d0c6642f157 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:59:55 -0300 Subject: [PATCH 8/8] Add TestnetOnly --- shared/services/config/images.go | 64 ++++++++++++--- shared/services/config/images_test.go | 20 +++++ .../assets/install/templates/compose.tmpl | 10 ++- shared/services/rocketpool/client.go | 80 ++++++++++++++----- 4 files changed, 140 insertions(+), 34 deletions(-) diff --git a/shared/services/config/images.go b/shared/services/config/images.go index 2f475c5d0..893914b9f 100644 --- a/shared/services/config/images.go +++ b/shared/services/config/images.go @@ -70,8 +70,6 @@ const ( GWWImageTagOverride = "GWW_IMAGE_TAG_OVERRIDE" CurlImageTagDefault = "CURL_IMAGE_TAG_DEFAULT" CurlImageTagOverride = "CURL_IMAGE_TAG_OVERRIDE" - - ImageTagsEnvFile = "runtime/image-tags.env" ) func ImageTagRef(overrideKey, defaultKey string) string { @@ -379,8 +377,50 @@ func (cfg *RocketPoolConfig) overlayFileName() string { return catalogFileName(info.ID()) } +func (cfg *RocketPoolConfig) IsTestnet() bool { + info := cfg.GetNetworkInfo() + return info != nil && string(info.ID()) == "testnet" +} + +func (cfg *RocketPoolConfig) IsDevnet() bool { + info := cfg.GetNetworkInfo() + return info != nil && string(info.ID()) == "devnet" +} + +// TestnetOnly returns "" on testnet so a compose.tmpl line is active, otherwise "#". +func (cfg *RocketPoolConfig) TestnetOnly() string { + if cfg.IsTestnet() { + return "" + } + return "#" +} + +// DevnetOnly returns "" on devnet so a compose.tmpl line is active, otherwise "#". +func (cfg *RocketPoolConfig) DevnetOnly() string { + if cfg.IsDevnet() { + return "" + } + return "#" +} + // ImagesEnvFiles is the on-disk catalog list (mainnet.env, then {network}.env). // Compose interpolates runtime/image-tags.env, which is generated from these. +func (cfg *RocketPoolConfig) OverlayNetworkNames() []string { + if cfg.networks == nil { + return nil + } + var names []string + for _, n := range cfg.networks.AllNetworks() { + id := string(n.ID()) + if id == "" || id == "mainnet" || id == "testnet" || id == "devnet" { + continue + } + names = append(names, id) + } + sort.Strings(names) + return names +} + func (cfg *RocketPoolConfig) ImagesEnvFiles() []string { files := []string{ImagesMainnetFile} if name := cfg.overlayFileName(); name != "" { @@ -693,22 +733,22 @@ func (cfg *RocketPoolConfig) ComposeImageEnv() (map[string]string, error) { return out, nil } -// ComposeEnvAssignments returns KEY=value strings for TUI image overrides. -// Keys are sorted so command logs and diffs are reproducible. +// ComposeEnvAssignments returns KEY=value strings for Compose interpolation: +// *_IMAGE_TAG_DEFAULT from the catalog and *_IMAGE_TAG_OVERRIDE when the TUI +// customized a tag. Keys are sorted so command logs and diffs are reproducible. func (cfg *RocketPoolConfig) ComposeEnvAssignments() ([]string, error) { - overrides := cfg.ComposeEnvOverrides() - keys := make([]string, 0, len(overrides)) - for key := range overrides { + env, err := cfg.ComposeImageEnv() + if err != nil { + return nil, err + } + keys := make([]string, 0, len(env)) + for key := range env { keys = append(keys, key) } sort.Strings(keys) out := make([]string, 0, len(keys)) for _, key := range keys { - value := overrides[key] - if err := validateEnvAssignment(key, value); err != nil { - return nil, err - } - out = append(out, key+"="+value) + out = append(out, key+"="+env[key]) } return out, nil } diff --git a/shared/services/config/images_test.go b/shared/services/config/images_test.go index 496b9c1a6..0df3fa43e 100644 --- a/shared/services/config/images_test.go +++ b/shared/services/config/images_test.go @@ -168,6 +168,12 @@ func TestClientImageEnvRefs(t *testing.T) { if ref != ImageTagRef(ECImageTagOverride, ECImageTagDefault) { t.Fatalf("ec test ref %s", ref) } + if !cfg.IsTestnet() { + t.Fatal("expected IsTestnet") + } + if cfg.TestnetOnly() != "" { + t.Fatalf("TestnetOnly on testnet should be empty, got %q", cfg.TestnetOnly()) + } files := cfg.ImagesEnvFiles() if len(files) < 1 || files[0] != ImagesMainnetFile { t.Fatalf("expected mainnet.env first, got %v", files) @@ -236,6 +242,20 @@ func TestComposeEnvAssignmentsRejectsWhitespace(t *testing.T) { } } +func TestTestnetOnlyComment(t *testing.T) { + cfg := mustNewRocketPoolConfig(t, "", false) + cfg.Smartnode.Network.Value = config.Network("mainnet") + if cfg.IsTestnet() { + t.Fatal("mainnet should not be testnet") + } + if cfg.TestnetOnly() != "#" { + t.Fatalf("TestnetOnly on mainnet should be #, got %q", cfg.TestnetOnly()) + } + if cfg.DevnetOnly() != "#" { + t.Fatalf("DevnetOnly on mainnet should be #, got %q", cfg.DevnetOnly()) + } +} + func TestContainerTagsAreSerialized(t *testing.T) { cfg := mustNewRocketPoolConfig(t, "", false) cfg.Geth.ContainerTag.Value = "my/geth:custom" diff --git a/shared/services/rocketpool/assets/install/templates/compose.tmpl b/shared/services/rocketpool/assets/install/templates/compose.tmpl index b2a0b6826..d8f81aaa3 100644 --- a/shared/services/rocketpool/assets/install/templates/compose.tmpl +++ b/shared/services/rocketpool/assets/install/templates/compose.tmpl @@ -1,10 +1,16 @@ # Autogenerated - DO NOT MODIFY THIS FILE DIRECTLY # Global overrides belong in override/compose.yml (merged after this file). +# Network overlay env files are commented out unless that network is selected. include: {{- range .Includes }} - path: {{.Path}} -{{- if .EnvFile }} +{{- if .WithEnv }} env_file: - - {{.EnvFile}} + - {{$.MainnetEnv}} + {{$.TestnetOnly}}- {{$.TestnetEnv}} + {{$.DevnetOnly}}- {{$.DevnetEnv}} +{{- range $.ExtraNetworks }} + {{$.CommentUnless .}}- {{$.EnvFile .}} +{{- end }} {{- end }} {{- end }} diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index 6fa1b0c07..6744bd57f 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -1182,22 +1182,16 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { return "", err } - imageEnv, err := cfg.ComposeImageEnv() - if err != nil { - return "", err - } - tagsPath := filepath.Join(expandedConfigPath, config.ImageTagsEnvFile) - if err := config.WriteEnvFile(tagsPath, imageEnv); err != nil { - return "", fmt.Errorf("error writing %s: %w", tagsPath, err) - } - composePaths := template.ComposePaths{ RuntimePath: filepath.Join(expandedConfigPath, runtimeDir), TemplatePath: filepath.Join(expandedConfigPath, templatesDir), OverridePath: filepath.Join(expandedConfigPath, overrideDir), } composePair, err := composePaths.File("compose").Write(composeTemplateData{ - Includes: composeIncludes(expandedConfigPath, config.ImageTagsEnvFile, deployedContainers), + Includes: composeIncludes(expandedConfigPath, deployedContainers), + ProjectDir: expandedConfigPath, + Network: string(cfg.GetNetwork()), + ExtraNetworks: cfg.OverlayNetworkNames(), }) if err != nil { return "", fmt.Errorf("error writing compose.yml: %w", err) @@ -1211,13 +1205,20 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { composeFileFlags = append(composeFileFlags, fmt.Sprintf("-f %s", shellescape.Quote(container))) } - parts := []string{ + envPrefix := []string{ fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", cfg.Smartnode.ProjectName.Value.(string)), - "docker compose", + } + assignments, err := cfg.ComposeEnvAssignments() + if err != nil { + return "", err + } + envPrefix = append(envPrefix, assignments...) + + parts := append(envPrefix, "docker compose", "--project-directory", shellescape.Quote(expandedConfigPath), strings.Join(composeFileFlags, " "), args, - } + ) return strings.Join(parts, " "), nil } @@ -1248,21 +1249,60 @@ func ensureImageEnvFiles(dir string) error { type composeInclude struct { Path string - EnvFile string + WithEnv bool } type composeTemplateData struct { - Includes []composeInclude + Includes []composeInclude + ProjectDir string + Network string + ExtraNetworks []string +} + +func (d composeTemplateData) TestnetOnly() string { + if d.Network == "testnet" { + return "" + } + return "#" +} + +func (d composeTemplateData) DevnetOnly() string { + if d.Network == "devnet" { + return "" + } + return "#" +} + +func (d composeTemplateData) CommentUnless(network string) string { + if d.Network == network { + return "" + } + return "#" +} + +func (d composeTemplateData) MainnetEnv() string { + return filepath.Join(d.ProjectDir, config.ImagesMainnetFile) +} + +func (d composeTemplateData) TestnetEnv() string { + return filepath.Join(d.ProjectDir, config.ImagesTestnetFile) +} + +func (d composeTemplateData) DevnetEnv() string { + return filepath.Join(d.ProjectDir, config.ImagesDevnetFile) +} + +func (d composeTemplateData) EnvFile(network string) string { + return filepath.Join(d.ProjectDir, network+".env") } -func composeIncludes(projectDir, envFile string, deployed []string) []composeInclude { - envAbs := filepath.Join(projectDir, envFile) +func composeIncludes(projectDir string, deployed []string) []composeInclude { out := make([]composeInclude, 0, len(deployed)) for _, path := range deployed { - entry := composeInclude{Path: path} + entry := composeInclude{Path: path, WithEnv: true} rel, err := filepath.Rel(projectDir, path) - if err != nil || (rel != overrideDir && !strings.HasPrefix(rel, overrideDir+string(filepath.Separator))) { - entry.EnvFile = envAbs + if err == nil && (rel == overrideDir || strings.HasPrefix(rel, overrideDir+string(filepath.Separator))) { + entry.WithEnv = false } out = append(out, entry) }