diff --git a/shared/services/config/images.go b/shared/services/config/images.go index 893914b9f..d22e1c759 100644 --- a/shared/services/config/images.go +++ b/shared/services/config/images.go @@ -9,15 +9,17 @@ import ( "sort" "strings" + "github.com/alessio/shellescape" "github.com/rocket-pool/smartnode/shared/services/rocketpool/assets" "github.com/rocket-pool/smartnode/shared/types/config" ) const ( - ImagesMainnetFile = "mainnet.env" - ImagesTestnetFile = "testnet.env" - ImagesDevnetFile = "devnet.env" - ComposeMainFile = "compose.yml" + ImagesMainnetFile = "mainnet.env" + ImagesTestnetFile = "testnet.env" + ImagesDevnetFile = "devnet.env" + ComposeMainFile = "compose.yml" + ComposeImageEnvFile = "image-tags.env" ImageSmartnode = "RP_IMAGE_SMARTNODE" @@ -403,8 +405,7 @@ func (cfg *RocketPoolConfig) DevnetOnly() string { return "#" } -// ImagesEnvFiles is the on-disk catalog list (mainnet.env, then {network}.env). -// Compose interpolates runtime/image-tags.env, which is generated from these. +// OverlayNetworkNames lists custom networks that may have image overlays. func (cfg *RocketPoolConfig) OverlayNetworkNames() []string { if cfg.networks == nil { return nil @@ -421,6 +422,8 @@ func (cfg *RocketPoolConfig) OverlayNetworkNames() []string { return names } +// ImagesEnvFiles returns mainnet.env followed by the selected network's env +// file, if present. A network without an overlay inherits the mainnet pins. func (cfg *RocketPoolConfig) ImagesEnvFiles() []string { files := []string{ImagesMainnetFile} if name := cfg.overlayFileName(); name != "" { @@ -636,28 +639,46 @@ func (cfg *RocketPoolConfig) tuiOverride(param *config.Parameter) (string, bool) return value, true } -// ComposeImageDefaults maps service-level *_IMAGE_TAG_DEFAULT vars to the -// catalog pin for the selected client/network. -func (cfg *RocketPoolConfig) ComposeImageDefaults() map[string]string { +// composeImageKeys maps each service to its selected client's catalog key. +func (cfg *RocketPoolConfig) composeImageKeys() 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), + SmartnodeImageTagDefault: ImageSmartnode, + PrometheusImageTagDefault: ImagePrometheus, + GrafanaImageTagDefault: ImageGrafana, + ExporterImageTagDefault: ImageExporter, + AlertmanagerImageTagDefault: ImageAlertmanager, + GWWImageTagDefault: ImageGWW, + CurlImageTagDefault: ImageCurl, + MevBoostImageTagDefault: ImageMevBoost, + CommitBoostImageTagDefault: ImageCommitBoost, } if key, err := cfg.selectedECCatalogKey(); err == nil { - out[ECImageTagDefault] = cfg.ResolvedImage(key) + out[ECImageTagDefault] = key } if key, err := cfg.selectedBNCatalogKey(); err == nil { - out[BNImageTagDefault] = cfg.ResolvedImage(key) + out[BNImageTagDefault] = key } if key, err := cfg.selectedVCCatalogKey(); err == nil { - out[VCImageTagDefault] = cfg.ResolvedImage(key) + out[VCImageTagDefault] = key + } + return out +} + +// ComposeImageDefaultRefs generates dotenv aliases, evaluated by Compose after +// loading mainnet.env and the selected network overlay. +func (cfg *RocketPoolConfig) ComposeImageDefaultRefs() map[string]string { + out := cfg.composeImageKeys() + for name, key := range out { + out[name] = EnvRef(key) + } + return out +} + +// ComposeImageDefaults resolves the same aliases for Go callers. +func (cfg *RocketPoolConfig) ComposeImageDefaults() map[string]string { + out := cfg.composeImageKeys() + for name, key := range out { + out[name] = cfg.ResolvedImage(key) } return out } @@ -733,13 +754,15 @@ func (cfg *RocketPoolConfig) ComposeImageEnv() (map[string]string, error) { return out, nil } -// 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. +// ComposeEnvAssignments returns shell-quoted TUI overrides. Defaults come from +// Compose's env_file list so network overlays can replace mainnet values. +// Keys are sorted so command logs and diffs are reproducible. func (cfg *RocketPoolConfig) ComposeEnvAssignments() ([]string, error) { - env, err := cfg.ComposeImageEnv() - if err != nil { - return nil, err + env := cfg.ComposeEnvOverrides() + for key, value := range env { + if err := validateEnvAssignment(key, value); err != nil { + return nil, err + } } keys := make([]string, 0, len(env)) for key := range env { @@ -748,7 +771,7 @@ func (cfg *RocketPoolConfig) ComposeEnvAssignments() ([]string, error) { sort.Strings(keys) out := make([]string, 0, len(keys)) for _, key := range keys { - out = append(out, key+"="+env[key]) + out = append(out, key+"="+shellescape.Quote(env[key])) } return out, nil } diff --git a/shared/services/rocketpool/assets/install/mainnet.env b/shared/services/rocketpool/assets/install/mainnet.env index b37ab324d..f44a0e4f6 100644 --- a/shared/services/rocketpool/assets/install/mainnet.env +++ b/shared/services/rocketpool/assets/install/mainnet.env @@ -4,7 +4,7 @@ # 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.1@sha256:707f1e11c4822f6aacee0d96dc453ee0d47a4f750981095b7af2328053612252 +RP_IMAGE_SMARTNODE=rocketpool/smartnode:v1.24.2@sha256:0e94df463f2fb461e16f9b45e8eaae38caa639e714167d6f28b2c8f6570bc7a5 RP_IMAGE_GETH=ethereum/client-go:v1.17.5@sha256:523d3ba26623a619e912019068dc2784f02934070ac46bdae4d5b9df0d917814 RP_IMAGE_NETHERMIND=nethermind/nethermind:1.39.3@sha256:1b6b01419de4ff75ed3d61995904bccc2fdcc2865fee6dae07d88c14a0758e40 diff --git a/shared/services/rocketpool/assets/install/templates/compose.tmpl b/shared/services/rocketpool/assets/install/templates/compose.tmpl index d8f81aaa3..30df2cc88 100644 --- a/shared/services/rocketpool/assets/install/templates/compose.tmpl +++ b/shared/services/rocketpool/assets/install/templates/compose.tmpl @@ -1,16 +1,13 @@ # 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. +# Merge all generated files and overrides in order, as with ordered -f flags. +# Keep one include: separate includes do not preserve override precedence. include: -{{- range .Includes }} - - path: {{.Path}} -{{- if .WithEnv }} - env_file: - - {{$.MainnetEnv}} - {{$.TestnetOnly}}- {{$.TestnetEnv}} - {{$.DevnetOnly}}- {{$.DevnetEnv}} -{{- range $.ExtraNetworks }} - {{$.CommentUnless .}}- {{$.EnvFile .}} -{{- end }} + - path: +{{- range .Paths }} + - {{printf "%q" .}} {{- end }} + project_directory: {{printf "%q" .ProjectDir}} + env_file: +{{- range .EnvFiles }} + - {{printf "%q" .}} {{- end }} diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index 6744bd57f..8bf2a3675 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -1149,6 +1149,10 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { if err != nil { return "", err } + expandedConfigPath, err = filepath.Abs(expandedConfigPath) + if err != nil { + return "", err + } // Load config cfg, isNew, err := c.LoadConfig() @@ -1178,35 +1182,13 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { return "", fmt.Errorf("error deploying Docker templates: %w", err) } - if err := ensureImageEnvFiles(expandedConfigPath); 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, deployedContainers), - ProjectDir: expandedConfigPath, - Network: string(cfg.GetNetwork()), - ExtraNetworks: cfg.OverlayNetworkNames(), - }) + composePath, err := writeComposeFile(cfg, expandedConfigPath, deployedContainers, composeFiles) if err != nil { return "", fmt.Errorf("error writing compose.yml: %w", err) } - 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))) - } - envPrefix := []string{ - fmt.Sprintf("COMPOSE_PROJECT_NAME=%s", cfg.Smartnode.ProjectName.Value.(string)), + "COMPOSE_PROJECT_NAME=" + shellescape.Quote(cfg.Smartnode.ProjectName.Value.(string)), } assignments, err := cfg.ComposeEnvAssignments() if err != nil { @@ -1216,7 +1198,7 @@ func (c *Client) compose(composeFiles []string, args string) (string, error) { parts := append(envPrefix, "docker compose", "--project-directory", shellescape.Quote(expandedConfigPath), - strings.Join(composeFileFlags, " "), + "-f", shellescape.Quote(composePath), args, ) return strings.Join(parts, " "), nil @@ -1247,66 +1229,48 @@ func ensureImageEnvFiles(dir string) error { return nil } -type composeInclude struct { - Path string - WithEnv bool -} - type composeTemplateData struct { - Includes []composeInclude - ProjectDir string - Network string - ExtraNetworks []string + Paths []string + ProjectDir string + EnvFiles []string } -func (d composeTemplateData) TestnetOnly() string { - if d.Network == "testnet" { - return "" +// Merge the entire application in one include path list. Splitting it into +// includes changes precedence for services and shared networks/volumes. +func writeComposeFile(cfg *config.RocketPoolConfig, projectDir string, deployed, extra []string) (string, error) { + if err := ensureImageEnvFiles(projectDir); err != nil { + return "", err } - return "#" -} - -func (d composeTemplateData) DevnetOnly() string { - if d.Network == "devnet" { - return "" + data := composeTemplateData{ + Paths: append(slices.Clone(deployed), filepath.Join(projectDir, overrideDir, config.ComposeMainFile)), + ProjectDir: projectDir, } - 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 string, deployed []string) []composeInclude { - out := make([]composeInclude, 0, len(deployed)) - for _, path := range deployed { - 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.WithEnv = false + for _, path := range extra { + // CLI -f paths are relative to the working directory, not the include. + absolute, err := filepath.Abs(path) + if err != nil { + return "", err } - out = append(out, entry) + data.Paths = append(data.Paths, absolute) + } + for _, name := range cfg.ImagesEnvFiles() { + data.EnvFiles = append(data.EnvFiles, filepath.Join(projectDir, name)) + } + // Resolve the selected client aliases after mainnet and the network overlay + // have been loaded by Compose. Only TUI overrides go in the shell environment. + imageEnv := filepath.Join(projectDir, runtimeDir, config.ComposeImageEnvFile) + if err := config.WriteEnvFile(imageEnv, cfg.ComposeImageDefaultRefs()); err != nil { + return "", fmt.Errorf("could not write image aliases: %w", err) + } + data.EnvFiles = append(data.EnvFiles, imageEnv) + tmpl := template.Template{ + Src: filepath.Join(projectDir, templatesDir, "compose.tmpl"), + Dst: filepath.Join(projectDir, runtimeDir, config.ComposeMainFile), + } + if err := tmpl.Write(data); err != nil { + return "", err } - return out + return tmpl.Dst, nil } // Deploys all of the appropriate docker compose template files and provisions them based on the provided configuration diff --git a/shared/services/rocketpool/compose_includes_test.go b/shared/services/rocketpool/compose_includes_test.go new file mode 100644 index 000000000..8673eb87b --- /dev/null +++ b/shared/services/rocketpool/compose_includes_test.go @@ -0,0 +1,359 @@ +package rocketpool + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/alessio/shellescape" + "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/services/rocketpool/assets" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" + "gopkg.in/yaml.v2" +) + +func writeComposeTestFile(t *testing.T, project, name, content string) string { + t.Helper() + path := filepath.Join(project, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func newComposeTestProject(t *testing.T, network string, overlay bool) (*config.RocketPoolConfig, string) { + t.Helper() + project := filepath.Join(t.TempDir(), "rocket pool") + src, err := os.ReadFile(filepath.Join("assets", "install", "templates", "compose.tmpl")) + if err != nil { + t.Fatal(err) + } + writeComposeTestFile(t, project, "templates/compose.tmpl", string(src)) + writeComposeTestFile(t, project, "mainnet.env", string(assets.ImagesMainnetEnv())) + writeComposeTestFile(t, project, "override/compose.yml", "{}\n") + if err := os.MkdirAll(filepath.Join(project, runtimeDir), 0755); err != nil { + t.Fatal(err) + } + if network == "custom" { + writeComposeTestFile(t, project, "networks-extra.yml", `version: 1 +networks: + - name: custom + label: Custom + description: Test network + chainID: 12345 + beaconNetwork: custom + clientTagSet: test +`) + } + if overlay { + writeComposeTestFile(t, project, network+".env", + "RP_IMAGE_GETH="+testImage("geth-network")+"\nRP_IMAGE_NIMBUS_VC="+testImage("vc-network")+"\n") + } + cfg, err := config.NewRocketPoolConfig(project, false) + if err != nil { + t.Fatal(err) + } + cfg.Smartnode.Network.Value = cfgtypes.Network(network) + cfg.ExecutionClientMode.Value = cfgtypes.Mode_Local + cfg.ExecutionClient.Value = cfgtypes.ExecutionClient_Geth + cfg.ConsensusClientMode.Value = cfgtypes.Mode_Local + cfg.ConsensusClient.Value = cfgtypes.ConsensusClient_Nimbus + // Select the network defaults, as the TUI does when switching networks. + if err := cfg.Geth.ContainerTag.SetToDefault(cfg.GetNetwork()); err != nil { + t.Fatal(err) + } + if err := cfg.Nimbus.BnContainerTag.SetToDefault(cfg.GetNetwork()); err != nil { + t.Fatal(err) + } + if err := cfg.Nimbus.VcContainerTag.SetToDefault(cfg.GetNetwork()); err != nil { + t.Fatal(err) + } + return cfg, project +} + +func testImage(name string) string { + return "example.com/" + name + ":test@sha256:" + strings.Repeat("a", 64) +} + +type testComposeInclude struct { + Paths []string `yaml:"path"` + ProjectDir string `yaml:"project_directory"` + EnvFiles []string `yaml:"env_file"` +} + +func readComposeInclude(t *testing.T, path string) testComposeInclude { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var model struct { + Includes []testComposeInclude `yaml:"include"` + } + if err := yaml.Unmarshal(b, &model); err != nil { + t.Fatal(err) + } + if len(model.Includes) != 1 { + t.Fatalf("need one merge scope, got %d includes", len(model.Includes)) + } + return model.Includes[0] +} + +func requireDockerCompose(t *testing.T) { + t.Helper() + if out, err := exec.Command("docker", "compose", "version").CombinedOutput(); err != nil { + t.Skipf("Docker Compose unavailable: %v\n%s", err, out) + } +} + +func renderComposeTest(t *testing.T, cfg *config.RocketPoolConfig, args ...string) map[string]interface{} { + t.Helper() + assignments, err := cfg.ComposeEnvAssignments() + if err != nil { + t.Fatal(err) + } + command := append([]string{"docker", "compose", "--project-directory", cfg.RocketPoolDirectory, "--project-name", "rocketpool"}, args...) + command = append(command, "config", "--format", "json") + for i, arg := range command { + command[i] = shellescape.Quote(arg) + } + cmd := exec.Command("sh", "-c", strings.Join(append(assignments, strings.Join(command, " ")), " ")) + // Make interpolation independent of the developer's shell and Compose setup. + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if !strings.HasPrefix(key, "COMPOSE_") && !strings.HasPrefix(key, "RP_IMAGE_") && !strings.Contains(key, "_IMAGE_TAG_") { + cmd.Env = append(cmd.Env, entry) + } + } + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + t.Fatalf("compose config: %v\n%s", err, exitErr.Stderr) + } + t.Fatal(err) + } + var model map[string]interface{} + if err := json.Unmarshal(out, &model); err != nil { + t.Fatalf("decode config: %v\n%s", err, out) + } + return model +} + +// This runs without Docker and verifies file ordering, optional overlays, and +// CLI paths relative to the caller rather than the include's project directory. +func TestComposeFileOrderAndEnvFiles(t *testing.T) { + for _, network := range []string{"mainnet", "testnet", "devnet", "custom"} { + t.Run(network, func(t *testing.T) { + cfg, project := newComposeTestProject(t, network, false) + deployed := []string{ + filepath.Join(project, "runtime/node.yml"), filepath.Join(project, "override/node.yml"), + filepath.Join(project, "runtime/eth1.yml"), filepath.Join(project, "override/eth1.yml"), + filepath.Join(project, "runtime/addons/gww/addon_gww.yml"), filepath.Join(project, "override/addons/gww/addon_gww.yml"), + } + path, err := writeComposeFile(cfg, project, deployed, []string{"custom override.yml"}) + if err != nil { + t.Fatal(err) + } + got := readComposeInclude(t, path) + extra, err := filepath.Abs("custom override.yml") + if err != nil { + t.Fatal(err) + } + wantPaths := append(append([]string{}, deployed...), filepath.Join(project, "override/compose.yml"), extra) + wantEnv := []string{filepath.Join(project, "mainnet.env")} + if network == "testnet" || network == "devnet" { + wantEnv = append(wantEnv, filepath.Join(project, network+".env")) + } + wantEnv = append(wantEnv, filepath.Join(project, "runtime", config.ComposeImageEnvFile)) + want := testComposeInclude{Paths: wantPaths, ProjectDir: project, EnvFiles: wantEnv} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v\nwant %#v", got, want) + } + }) + } +} + +func TestComposeImageEnvPrecedence(t *testing.T) { + requireDockerCompose(t) + for _, network := range []string{"mainnet", "testnet", "devnet", "custom", "custom-no-overlay"} { + for _, tui := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/tui=%t", network, tui), func(t *testing.T) { + selected := strings.TrimSuffix(network, "-no-overlay") + overlay := network != "mainnet" && network != "custom-no-overlay" + cfg, project := newComposeTestProject(t, selected, overlay) + base := writeComposeTestFile(t, project, "runtime/clients.yml", `services: + eth1: + image: ${EC_IMAGE_TAG_OVERRIDE:-${EC_IMAGE_TAG_DEFAULT}} + eth2: + image: ${BN_IMAGE_TAG_OVERRIDE:-${BN_IMAGE_TAG_DEFAULT}} + validator: + image: ${VC_IMAGE_TAG_OVERRIDE:-${VC_IMAGE_TAG_DEFAULT}} + grafana: + image: ${GRAFANA_IMAGE_TAG_OVERRIDE:-${GRAFANA_IMAGE_TAG_DEFAULT}} +`) + wantEC := cfg.ResolvedImage(config.ImageGeth) + wantVC := cfg.ResolvedImage(config.ImageNimbusVc) + if tui { + // The TUI must also beat an explicit *_OVERRIDE in the env files. + envName := "mainnet.env" + if overlay { + envName = selected + ".env" + } + content, err := os.ReadFile(filepath.Join(project, envName)) + if err != nil { + t.Fatal(err) + } + writeComposeTestFile(t, project, envName, string(content)+"\nVC_IMAGE_TAG_OVERRIDE="+testImage("vc-env-override")+"\n") + wantEC, wantVC = testImage("geth-tui"), testImage("vc-tui") + cfg.Geth.ContainerTag.Value, cfg.Nimbus.VcContainerTag.Value = wantEC, wantVC + } + path, err := writeComposeFile(cfg, project, []string{base}, nil) + if err != nil { + t.Fatal(err) + } + got := renderComposeTest(t, cfg, "-f", path)["services"].(map[string]interface{}) + expected := map[string]string{ + "eth1": wantEC, "validator": wantVC, + "eth2": cfg.ResolvedImage(config.ImageNimbusBn), "grafana": cfg.ResolvedImage(config.ImageGrafana), + } + for service, image := range expected { + if actual := got[service].(map[string]interface{})["image"]; actual != image { + t.Errorf("%s image = %v, want %s", service, actual, image) + } + } + if !tui { + // Prove Compose reads the env files, instead of values frozen in Go's + // process environment when the configuration was loaded. + writeComposeTestFile(t, project, "mainnet.env", string(assets.ImagesMainnetEnv())+"\nRP_IMAGE_GRAFANA="+testImage("grafana-updated")+"\n") + updated := renderComposeTest(t, cfg, "-f", path)["services"].(map[string]interface{}) + if image := updated["grafana"].(map[string]interface{})["image"]; image != testImage("grafana-updated") { + t.Fatalf("env_file edit did not take effect: %v", image) + } + } + }) + } + } +} + +func TestComposeOverridesMatchOrderedFiles(t *testing.T) { + requireDockerCompose(t) + cfg, project := newComposeTestProject(t, "mainnet", false) + node := writeComposeTestFile(t, project, "runtime/node.yml", `services: + node: + image: ${SMARTNODE_IMAGE_TAG_OVERRIDE:-${SMARTNODE_IMAGE_TAG_DEFAULT}} + labels: {example: original} + networks: [net] +networks: + net: + driver_opts: {com.docker.network.driver.mtu: "1500"} +`) + nodeOverride := writeComposeTestFile(t, project, "override/node.yml", "{}\n") + base := writeComposeTestFile(t, project, "runtime/eth1.yml", `services: + eth1: + image: ${EC_IMAGE_TAG_OVERRIDE:-${EC_IMAGE_TAG_DEFAULT}} + user: root + restart: unless-stopped + command: ["original"] + entrypoint: ["original-entry"] + environment: {KEY: original} + labels: {example: original} + healthcheck: + test: ["CMD", "original"] + interval: 30s + ports: + - target: 8545 + published: "8545" + name: original + volumes: + - eth1clientdata:/ethclient + - ./scripts:/setup:ro + secrets: [{source: oldsecret, target: item}] + configs: [{source: oldconfig, target: /etc/item}] + networks: [net] +networks: + net: {} +volumes: + eth1clientdata: {} +secrets: + oldsecret: {external: true} + newsecret: {external: true} +configs: + oldconfig: {external: true} + newconfig: {external: true} +`) + cases := []struct{ name, body string }{ + {"image", " image: example.com/custom:tag\n"}, + {"user", " user: '1000:1000'\n"}, + {"restart", " restart: always\n"}, + {"command", " command: ['custom']\n"}, + {"entrypoint", " entrypoint: ['custom-entry']\n"}, + {"environment", " environment: {KEY: custom, NEW_KEY: added}\n"}, + {"labels", " labels: {example: custom, added: value}\n"}, + {"healthcheck", " healthcheck: {test: ['CMD', 'custom'], interval: 60s}\n"}, + {"volumes-short", " volumes: ['/tmp/custom:/ethclient', './ancient:/ethclient/geth/chaindata/ancient']\n"}, + {"volumes-long", " volumes:\n - {type: bind, source: /tmp/custom, target: /ethclient}\n - {type: bind, source: ./ancient, target: /ethclient/geth/chaindata/ancient}\n"}, + {"ports-same-key", " ports: [{target: 8545, published: '8545', name: custom}]\n"}, + {"ports-additive", " ports: ['9545:8545']\n"}, + {"secrets", " secrets: [{source: newsecret, target: item}]\n"}, + {"configs", " configs: [{source: newconfig, target: /etc/item}]\n"}, + {"reset-ports", " ports: !reset []\n"}, + {"replace-ports", " ports: !override ['9545:8545']\n"}, + {"cross-service-and-network", " labels: {example: custom}\n node:\n labels: {example: custom}\nnetworks:\n net:\n driver_opts: {com.docker.network.driver.mtu: '1400'}\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + override := writeComposeTestFile(t, project, "override/eth1.yml", "services:\n eth1:\n"+tc.body) + deployed := []string{node, nodeOverride, base, override} + path, err := writeComposeFile(cfg, project, deployed, nil) + if err != nil { + t.Fatal(err) + } + assertComposeMatchesFiles(t, cfg, path) + }) + } + t.Run("global-and-cli-precedence", func(t *testing.T) { + override := writeComposeTestFile(t, project, "override/eth1.yml", "services:\n eth1:\n environment: {KEY: service, SERVICE_ONLY: kept}\n") + writeComposeTestFile(t, project, "override/compose.yml", "services:\n eth1:\n environment: {KEY: global, GLOBAL_ONLY: kept}\n ports: !reset []\n") + extra := writeComposeTestFile(t, t.TempDir(), "cli override.yml", "services:\n eth1:\n environment: {KEY: cli}\n command: ['cli']\n") + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(cwd, extra) + if err != nil { + t.Fatal(err) + } + path, err := writeComposeFile(cfg, project, []string{node, nodeOverride, base, override}, []string{relative}) + if err != nil { + t.Fatal(err) + } + assertComposeMatchesFiles(t, cfg, path) + }) +} + +func assertComposeMatchesFiles(t *testing.T, cfg *config.RocketPoolConfig, path string) { + t.Helper() + include := readComposeInclude(t, path) + var args []string + for _, env := range include.EnvFiles { + args = append(args, "--env-file", env) + } + for _, file := range include.Paths { + args = append(args, "-f", file) + } + got := renderComposeTest(t, cfg, "-f", path) + want := renderComposeTest(t, cfg, args...) + if !reflect.DeepEqual(got, want) { + actual, _ := json.MarshalIndent(got, "", " ") + expected, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("single include differs from ordered -f merge:\ngot: %s\nwant: %s", actual, expected) + } +} diff --git a/shared/services/rocketpool/template/compose-file.go b/shared/services/rocketpool/template/compose-file.go index ef7f35d00..b053bcc41 100644 --- a/shared/services/rocketpool/template/compose-file.go +++ b/shared/services/rocketpool/template/compose-file.go @@ -29,8 +29,9 @@ func (c *ComposePaths) File(name string) *ComposeFile { } // Given a ComposeFile returned by ComposePaths.File, find and parse the .tmpl -// from the TemplatePath, populate and save to the RuntimePath, and return a -// slice of compose definitions pertaining to the container (including the override). +// from the TemplatePath, populate and save to the RuntimePath, and return the +// generated compose file followed by its override. Preserve this order when +// merging with -f flags or a single include path list. func (c *ComposeFile) Write(data interface{}) ([]string, error) { composePath := filepath.Join(c.paths.RuntimePath, c.name+composeFileSuffix) tmpl := Template{ diff --git a/shared/version.txt b/shared/version.txt index f9e8384bb..e4a973f91 100644 --- a/shared/version.txt +++ b/shared/version.txt @@ -1 +1 @@ -1.24.1 +1.24.2