From 4a6ff92913589633fe92b678eae51de319b40a7c Mon Sep 17 00:00:00 2001 From: Peter Jaap Blaakmeer Date: Mon, 6 Jul 2026 14:50:52 +0200 Subject: [PATCH] Use one shared OpenSearch and one Elasticsearch container for all projects Previously the global docker-compose was generated with one search container per requested version, giving each version its own container, container name, volumes, and version-derived host port (e.g. 9259 for OpenSearch 2.19). Now MageBox runs at most one OpenSearch and one Elasticsearch container for the whole machine, shared across every project: - Fixed host ports: OpenSearch 9200, Elasticsearch 9500 (distinct so both engines can run at once). - Unversioned service names (opensearch/elasticsearch), container names (magebox-opensearch/magebox-elasticsearch), and volumes (opensearch_data, opensearch_plugins, ...). - When projects request different versions, the global default version wins, otherwise the highest requested version is used. The container is provisioned with the largest memory any project requests. GetOpenSearchPort/GetElasticsearchPort now return the fixed ports and drop the version argument; the version->port helpers are removed. Status, service-name matching, `magebox new`, and `magebox check` are updated to the fixed names/ports. Docs (ports, config-options, search guide, opensearch service) updated to match. Claude-Session: https://claude.ai/code/session_01TysGzdAeGf96J3WvTKFrht --- cmd/magebox/check.go | 2 +- cmd/magebox/new.go | 6 +- internal/docker/compose.go | 233 +++++++++------------ internal/docker/compose_test.go | 291 ++++++++++++++++++-------- internal/project/lifecycle.go | 14 +- vitepress/guide/search.md | 12 +- vitepress/reference/config-options.md | 2 +- vitepress/reference/ports.md | 70 ++----- vitepress/services/opensearch.md | 14 +- 9 files changed, 350 insertions(+), 294 deletions(-) diff --git a/cmd/magebox/check.go b/cmd/magebox/check.go index 97226fc..cdc23f0 100644 --- a/cmd/magebox/check.go +++ b/cmd/magebox/check.go @@ -284,7 +284,7 @@ func runCheck(cmd *cobra.Command, args []string) error { // Check project-specific services if cfg != nil { if cfg.Services.HasOpenSearch() { - osPort := docker.GetOpenSearchPort(cfg.Services.OpenSearch.Version) + osPort := docker.GetOpenSearchPort() if dockerCtrl.IsServiceRunning("opensearch") { results = append(results, checkResult{ name: "OpenSearch", diff --git a/cmd/magebox/new.go b/cmd/magebox/new.go index cba6847..d393ea3 100644 --- a/cmd/magebox/new.go +++ b/cmd/magebox/new.go @@ -807,7 +807,7 @@ commands: // Add search engine config if searchEngine == "opensearch" { - searchPort := docker.GetOpenSearchPort(searchVersion) + searchPort := docker.GetOpenSearchPort() installCmd += fmt.Sprintf(` \ --search-engine=opensearch \ --opensearch-host=127.0.0.1 \ @@ -815,7 +815,7 @@ commands: --opensearch-index-prefix=%s \ --opensearch-timeout=15`, searchPort, projectName) } else if searchEngine == "elasticsearch" { - searchPort := docker.GetElasticsearchPort(searchVersion) + searchPort := docker.GetElasticsearchPort() installCmd += fmt.Sprintf(` \ --search-engine=elasticsearch7 \ --elasticsearch-host=127.0.0.1 \ @@ -1125,7 +1125,7 @@ commands: fmt.Println() cli.PrintInfo("Waiting for OpenSearch to be ready...") dbPort := "33080" // MySQL 8.0 default port - opensearchPort := docker.GetOpenSearchPort(searchVersion) + opensearchPort := docker.GetOpenSearchPort() opensearchURL := fmt.Sprintf("http://127.0.0.1:%d", opensearchPort) for i := 0; i < OpenSearchReadinessMaxRetries; i++ { checkCmd := exec.Command("curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", opensearchURL) diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 47989d0..316f95e 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -103,9 +103,13 @@ const ( // StandardDBPort is the standard MySQL/MariaDB port (3306) that is additionally // exposed for the default database service, alongside its version-specific port. StandardDBPort = 3306 - // StandardSearchPort is the standard OpenSearch/Elasticsearch port (9200) that is - // additionally exposed for the default search service, alongside its version-specific port. + // StandardSearchPort is the fixed host port for the single shared OpenSearch + // container (also the container-internal port for both search engines). StandardSearchPort = 9200 + // StandardElasticsearchPort is the fixed host port for the single shared + // Elasticsearch container. It differs from StandardSearchPort so that an + // OpenSearch and an Elasticsearch container can run simultaneously. + StandardElasticsearchPort = 9500 ) // Default RabbitMQ credentials @@ -219,7 +223,6 @@ func (g *ComposeGenerator) GenerateGlobalServices(configs []*config.Config) erro // Only the global default DB version gets the standard port 3306. // MySQL default takes precedence over MariaDB default if both are configured. - totalSearchServices := len(requiredServices.opensearch) + len(requiredServices.elasticsearch) // Add MySQL services for version, svcCfg := range requiredServices.mysql { @@ -244,22 +247,19 @@ func (g *ComposeGenerator) GenerateGlobalServices(configs []*config.Config) erro compose.Services["redis"] = g.getRedisService() } - // Add OpenSearch services - for version, svcCfg := range requiredServices.opensearch { - serviceName := fmt.Sprintf("opensearch%s", strings.ReplaceAll(version, ".", "")) - addStdPort := (totalSearchServices == 1) || (version == defaultOpenSearch) - compose.Services[serviceName] = g.getOpenSearchService(svcCfg, addStdPort) - compose.Volumes[fmt.Sprintf("opensearch%s_data", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} - compose.Volumes[fmt.Sprintf("opensearch%s_plugins", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} + // Add a single shared OpenSearch container for all projects. When projects + // request different versions, the global default wins, else the highest. + if _, osCfg := selectSearchService(requiredServices.opensearch, defaultOpenSearch); osCfg != nil { + compose.Services["opensearch"] = g.getOpenSearchService(osCfg) + compose.Volumes["opensearch_data"] = ComposeVolume{} + compose.Volumes["opensearch_plugins"] = ComposeVolume{} } - // Add Elasticsearch services - for version, svcCfg := range requiredServices.elasticsearch { - serviceName := fmt.Sprintf("elasticsearch%s", strings.ReplaceAll(version, ".", "")) - addStdPort := len(requiredServices.opensearch) == 0 && ((totalSearchServices == 1) || (version == defaultElasticsearch)) - compose.Services[serviceName] = g.getElasticsearchService(svcCfg, addStdPort) - compose.Volumes[fmt.Sprintf("elasticsearch%s_data", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} - compose.Volumes[fmt.Sprintf("elasticsearch%s_plugins", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} + // Add a single shared Elasticsearch container for all projects. + if _, esCfg := selectSearchService(requiredServices.elasticsearch, defaultElasticsearch); esCfg != nil { + compose.Services["elasticsearch"] = g.getElasticsearchService(esCfg) + compose.Volumes["elasticsearch_data"] = ComposeVolume{} + compose.Volumes["elasticsearch_plugins"] = ComposeVolume{} } // Add RabbitMQ if needed @@ -393,6 +393,60 @@ func (g *ComposeGenerator) collectRequiredServices(configs []*config.Config) req return rs } +// selectSearchService picks the single version/config to run for one search engine +// (OpenSearch or Elasticsearch) when different projects request different versions. +// The global default version wins if set; otherwise the highest requested version is +// used. The container is provisioned with the largest memory any project requests, so +// the shared container satisfies the most demanding project. Returns ("", nil) when no +// project needs the engine. +func selectSearchService(requested map[string]*config.ServiceConfig, defaultVersion string) (string, *config.ServiceConfig) { + if len(requested) == 0 { + return "", nil + } + + version := defaultVersion + if version == "" { + for v := range requested { + if version == "" || compareVersionStrings(v, version) > 0 { + version = v + } + } + } + + // Provision for the most demanding project across all requested configs. + memory := "" + for _, c := range requested { + if c != nil && parseMemoryToBytes(c.Memory) > parseMemoryToBytes(memory) { + memory = c.Memory + } + } + + return version, &config.ServiceConfig{Enabled: true, Version: version, Memory: memory} +} + +// parseMemoryToBytes converts a memory string like "2g", "1024m" or "512k" to bytes. +// An empty or unparseable string returns 0. +func parseMemoryToBytes(s string) int64 { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0 + } + mult := int64(1) + switch { + case strings.HasSuffix(s, "g"): + mult, s = 1<<30, strings.TrimSuffix(s, "g") + case strings.HasSuffix(s, "m"): + mult, s = 1<<20, strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "k"): + mult, s = 1<<10, strings.TrimSuffix(s, "k") + } + n, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0 + } + return int64(n * float64(mult)) +} + // getMySQLService returns a MySQL service configuration func (g *ComposeGenerator) getMySQLService(svcCfg *config.ServiceConfig, addStandardPort bool) ComposeService { version := svcCfg.Version @@ -520,11 +574,10 @@ func (g *ComposeGenerator) getValkeyService() ComposeService { } } -// getOpenSearchService returns an OpenSearch service configuration -func (g *ComposeGenerator) getOpenSearchService(svcCfg *config.ServiceConfig, addStandardPort bool) ComposeService { - version := svcCfg.Version - imageVersion := ResolveOpenSearchVersion(version) - port := GetOpenSearchPort(imageVersion) +// getOpenSearchService returns the configuration for the single shared OpenSearch +// container used by all projects. The host port is fixed at StandardSearchPort. +func (g *ComposeGenerator) getOpenSearchService(svcCfg *config.ServiceConfig) ComposeService { + imageVersion := ResolveOpenSearchVersion(svcCfg.Version) // Default to 1GB if not specified memory := "1g" @@ -534,15 +587,10 @@ func (g *ComposeGenerator) getOpenSearchService(svcCfg *config.ServiceConfig, ad heapSize := fmt.Sprintf("-Xms%s -Xmx%s", memory, memory) - ports := []string{fmt.Sprintf("%d:9200", port)} - if addStandardPort && port != StandardSearchPort { - ports = append(ports, fmt.Sprintf("%d:9200", StandardSearchPort)) - } - return ComposeService{ - ContainerName: fmt.Sprintf("magebox-opensearch-%s", version), + ContainerName: "magebox-opensearch", Image: fmt.Sprintf("opensearchproject/opensearch:%s", imageVersion), - Ports: ports, + Ports: []string{fmt.Sprintf("%d:9200", StandardSearchPort)}, Environment: map[string]string{ "discovery.type": "single-node", "DISABLE_SECURITY_PLUGIN": "true", @@ -550,8 +598,8 @@ func (g *ComposeGenerator) getOpenSearchService(svcCfg *config.ServiceConfig, ad "cluster.routing.allocation.disk.threshold_enabled": "false", }, Volumes: []string{ - fmt.Sprintf("opensearch%s_data:/usr/share/opensearch/data", strings.ReplaceAll(version, ".", "")), - fmt.Sprintf("opensearch%s_plugins:/usr/share/opensearch/plugins", strings.ReplaceAll(version, ".", "")), + "opensearch_data:/usr/share/opensearch/data", + "opensearch_plugins:/usr/share/opensearch/plugins", }, Networks: []string{"magebox"}, Restart: "unless-stopped", @@ -559,11 +607,11 @@ func (g *ComposeGenerator) getOpenSearchService(svcCfg *config.ServiceConfig, ad } } -// getElasticsearchService returns an Elasticsearch service configuration -func (g *ComposeGenerator) getElasticsearchService(svcCfg *config.ServiceConfig, addStandardPort bool) ComposeService { - version := svcCfg.Version - imageVersion := ResolveElasticsearchVersion(version) - port := GetElasticsearchPort(imageVersion) +// getElasticsearchService returns the configuration for the single shared +// Elasticsearch container used by all projects. The host port is fixed at +// StandardElasticsearchPort. +func (g *ComposeGenerator) getElasticsearchService(svcCfg *config.ServiceConfig) ComposeService { + imageVersion := ResolveElasticsearchVersion(svcCfg.Version) // Default to 1GB if not specified memory := "1g" @@ -573,23 +621,18 @@ func (g *ComposeGenerator) getElasticsearchService(svcCfg *config.ServiceConfig, heapSize := fmt.Sprintf("-Xms%s -Xmx%s", memory, memory) - ports := []string{fmt.Sprintf("%d:9200", port)} - if addStandardPort && port != StandardSearchPort { - ports = append(ports, fmt.Sprintf("%d:9200", StandardSearchPort)) - } - return ComposeService{ - ContainerName: fmt.Sprintf("magebox-elasticsearch-%s", version), + ContainerName: "magebox-elasticsearch", Image: fmt.Sprintf("elasticsearch:%s", imageVersion), - Ports: ports, + Ports: []string{fmt.Sprintf("%d:9200", StandardElasticsearchPort)}, Environment: map[string]string{ "discovery.type": "single-node", "xpack.security.enabled": "false", "ES_JAVA_OPTS": heapSize, }, Volumes: []string{ - fmt.Sprintf("elasticsearch%s_data:/usr/share/elasticsearch/data", strings.ReplaceAll(version, ".", "")), - fmt.Sprintf("elasticsearch%s_plugins:/usr/share/elasticsearch/plugins", strings.ReplaceAll(version, ".", "")), + "elasticsearch_data:/usr/share/elasticsearch/data", + "elasticsearch_plugins:/usr/share/elasticsearch/plugins", }, Networks: []string{"magebox"}, Restart: "unless-stopped", @@ -753,87 +796,17 @@ func (g *ComposeGenerator) getMariaDBPort(version string) int { return 33106 // default } -// normalizeSearchVersion extracts major.minor from a version string like "2.19.4". -// If the input does not contain at least two dot-separated parts, it is returned unchanged. -func normalizeSearchVersion(version string) string { - parts := strings.SplitN(version, ".", 3) - if len(parts) >= 2 { - return parts[0] + "." + parts[1] - } - return version -} - -// resolveSearchPortVersion normalizes a search version for port selection. When the -// user provides a major-only version, it first resolves the latest available image -// tag for that major so the selected port matches the resolved major.minor series. -func resolveSearchPortVersion(version string, resolver func(string) string) string { - if !strings.Contains(version, ".") { - return normalizeSearchVersion(resolver(version)) - } - return normalizeSearchVersion(version) -} - -// computeSearchPort calculates a port from base + major*20 + minor using a -// caller-supplied major.minor version string. If the input cannot be parsed -// numerically, it falls back to basePort. OpenSearch uses base 9200, -// Elasticsearch uses base 9500 to avoid range overlap. -func computeSearchPort(basePort int, majorMinorVersion string) int { - parts := strings.SplitN(majorMinorVersion, ".", 2) - if len(parts) == 2 { - major, err1 := strconv.Atoi(parts[0]) - minor, err2 := strconv.Atoi(parts[1]) - if err1 == nil && err2 == nil { - return basePort + major*20 + minor - } - } - return basePort -} - -// GetOpenSearchPort returns the host port for an OpenSearch version. -// Port convention: 9200 + major*20 + minor (e.g., OS 2.19 → 9259, OS 3.3 → 9263). -func GetOpenSearchPort(version string) int { - normalized := resolveSearchPortVersion(version, ResolveOpenSearchVersion) - ports := map[string]int{ - "1.3": 9223, - "2.5": 9245, - "2.10": 9250, - "2.11": 9251, - "2.12": 9252, - "2.13": 9253, - "2.15": 9255, - "2.17": 9257, - "2.19": 9259, - "3.0": 9260, - "3.3": 9263, - } - if port, ok := ports[normalized]; ok { - return port - } - return computeSearchPort(9200, normalized) +// GetOpenSearchPort returns the fixed host port for the single shared OpenSearch +// container. All projects that use OpenSearch share this container and port. +func GetOpenSearchPort() int { + return StandardSearchPort } -// GetElasticsearchPort returns the host port for an Elasticsearch version. -// Port convention: 9500 + major*20 + minor (e.g., ES 7.17 → 9657, ES 8.11 → 9671). -func GetElasticsearchPort(version string) int { - normalized := resolveSearchPortVersion(version, ResolveElasticsearchVersion) - ports := map[string]int{ - "7.6": 9646, - "7.9": 9649, - "7.10": 9650, - "7.16": 9656, - "7.17": 9657, - "8.0": 9660, - "8.4": 9664, - "8.7": 9667, - "8.11": 9671, - "8.14": 9674, - "8.15": 9675, - "8.17": 9677, - } - if port, ok := ports[normalized]; ok { - return port - } - return computeSearchPort(9500, normalized) +// GetElasticsearchPort returns the fixed host port for the single shared +// Elasticsearch container. All projects that use Elasticsearch share this +// container and port. +func GetElasticsearchPort() int { + return StandardElasticsearchPort } // ResolveElasticsearchVersion resolves a major.minor version string to the latest available full @@ -1095,17 +1068,15 @@ func (g *ComposeGenerator) GenerateDefaultServices(globalCfg *config.GlobalConfi compose.Services["redis"] = g.getRedisService() } - // Add OpenSearch if configured + // Add the shared OpenSearch container if configured if globalCfg.DefaultServices.OpenSearch != "" { - version := globalCfg.DefaultServices.OpenSearch - serviceName := fmt.Sprintf("opensearch%s", strings.ReplaceAll(version, ".", "")) svcCfg := &config.ServiceConfig{ Enabled: true, - Version: version, + Version: globalCfg.DefaultServices.OpenSearch, } - compose.Services[serviceName] = g.getOpenSearchService(svcCfg, true) - compose.Volumes[fmt.Sprintf("opensearch%s_data", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} - compose.Volumes[fmt.Sprintf("opensearch%s_plugins", strings.ReplaceAll(version, ".", ""))] = ComposeVolume{} + compose.Services["opensearch"] = g.getOpenSearchService(svcCfg) + compose.Volumes["opensearch_data"] = ComposeVolume{} + compose.Volumes["opensearch_plugins"] = ComposeVolume{} } // Add Mailpit (useful for all projects) diff --git a/internal/docker/compose_test.go b/internal/docker/compose_test.go index a49d0cf..2f438e0 100644 --- a/internal/docker/compose_test.go +++ b/internal/docker/compose_test.go @@ -226,7 +226,7 @@ func TestComposeGenerator_GenerateAllServices(t *testing.T) { t.Fatalf("Failed to parse compose file: %v", err) } - expectedServices := []string{"mysql80", "redis", "opensearch212", "rabbitmq", "mailpit"} + expectedServices := []string{"mysql80", "redis", "opensearch", "rabbitmq", "mailpit"} for _, svc := range expectedServices { if _, ok := compose.Services[svc]; !ok { t.Errorf("Compose should contain %s service", svc) @@ -428,13 +428,16 @@ func TestComposeService_OpenSearch(t *testing.T) { Version: "2.12", Memory: "2g", } - svc := g.getOpenSearchService(svcCfg, false) + svc := g.getOpenSearchService(svcCfg) if !strings.Contains(svc.Image, "opensearch") { t.Errorf("Image = %v, should contain opensearch", svc.Image) } - if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9252:9200") { - t.Errorf("Ports = %v, want [9252:9200]", svc.Ports) + if svc.ContainerName != "magebox-opensearch" { + t.Errorf("ContainerName = %v, want magebox-opensearch", svc.ContainerName) + } + if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9200:9200") { + t.Errorf("Ports = %v, want [9200:9200]", svc.Ports) } if svc.Environment["DISABLE_SECURITY_PLUGIN"] != "true" { t.Error("DISABLE_SECURITY_PLUGIN should be true") @@ -450,106 +453,65 @@ func TestComposeService_OpenSearch(t *testing.T) { } } -func TestComposeService_OpenSearch_WithStandardPort(t *testing.T) { +func TestComposeService_OpenSearch_FixedPortAndVolumes(t *testing.T) { g, _ := setupTestComposeGenerator(t) svcCfg := &config.ServiceConfig{ Enabled: true, Version: "2.19.4", } - svc := g.getOpenSearchService(svcCfg, true) + svc := g.getOpenSearchService(svcCfg) - if len(svc.Ports) != 2 { - t.Fatalf("Ports = %v, want 2 port mappings (version-specific + standard)", svc.Ports) + // The single shared container always exposes the fixed standard port. + if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9200:9200") { + t.Errorf("Ports = %v, want [9200:9200]", svc.Ports) } - if !strings.Contains(svc.Ports[0], "9259:9200") { - t.Errorf("Ports[0] = %v, want 9259:9200", svc.Ports[0]) + // Volumes are unversioned so all projects share the same data. + wantVolumes := []string{ + "opensearch_data:/usr/share/opensearch/data", + "opensearch_plugins:/usr/share/opensearch/plugins", } - if !strings.Contains(svc.Ports[1], "9200:9200") { - t.Errorf("Ports[1] = %v, want 9200:9200", svc.Ports[1]) + for i, want := range wantVolumes { + if i >= len(svc.Volumes) || svc.Volumes[i] != want { + t.Errorf("Volumes = %v, want %v", svc.Volumes, wantVolumes) + break + } } } -func TestComposeService_Elasticsearch_WithStandardPort(t *testing.T) { +func TestComposeService_Elasticsearch_FixedPortAndVolumes(t *testing.T) { g, _ := setupTestComposeGenerator(t) svcCfg := &config.ServiceConfig{ Enabled: true, Version: "7.17", } - svc := g.getElasticsearchService(svcCfg, true) + svc := g.getElasticsearchService(svcCfg) - if len(svc.Ports) != 2 { - t.Fatalf("Ports = %v, want 2 port mappings (version-specific + standard)", svc.Ports) - } - if !strings.Contains(svc.Ports[0], "9657:9200") { - t.Errorf("Ports[0] = %v, want 9657:9200", svc.Ports[0]) + // Elasticsearch uses a distinct fixed port so it can coexist with OpenSearch. + if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9500:9200") { + t.Errorf("Ports = %v, want [9500:9200]", svc.Ports) } - if !strings.Contains(svc.Ports[1], "9200:9200") { - t.Errorf("Ports[1] = %v, want 9200:9200", svc.Ports[1]) + if svc.ContainerName != "magebox-elasticsearch" { + t.Errorf("ContainerName = %v, want magebox-elasticsearch", svc.ContainerName) } } func TestGetOpenSearchPort(t *testing.T) { - cleanup := setupMockDockerHub(t, map[string][]string{ - "opensearchproject/opensearch:2": {"2.19.1", "2.19.2", "2.5.0"}, - }) - defer cleanup() - - tests := []struct { - version string - expected int - }{ - {"2", 9259}, // major-only shorthand resolves to latest available minor - {"1.3", 9223}, - {"2.5", 9245}, - {"2.11", 9251}, - {"2.12", 9252}, - {"2.19", 9259}, - {"2.19.4", 9259}, // patch version should be stripped - {"3.0", 9260}, - {"3.3", 9263}, - {"4.0", 9280}, // unknown version uses formula - {"1.0", 9220}, // unknown version uses formula - } - - for _, tt := range tests { - t.Run(tt.version, func(t *testing.T) { - if got := GetOpenSearchPort(tt.version); got != tt.expected { - t.Errorf("GetOpenSearchPort(%v) = %v, want %v", tt.version, got, tt.expected) - } - }) + // All projects share one OpenSearch container on a fixed port. + if got := GetOpenSearchPort(); got != StandardSearchPort { + t.Errorf("GetOpenSearchPort() = %v, want %v", got, StandardSearchPort) } } func TestGetElasticsearchPort(t *testing.T) { - cleanup := setupMockDockerHub(t, map[string][]string{ - "library/elasticsearch:7": {"7.17.27", "7.17.28", "7.6.2"}, - "library/elasticsearch:8": {"8.17.4", "8.11.4", "8.0.0"}, - }) - defer cleanup() - - tests := []struct { - version string - expected int - }{ - {"7", 9657}, // major-only shorthand resolves to latest available minor - {"7.6", 9646}, - {"7.17", 9657}, - {"8", 9677}, // major-only shorthand resolves to latest available minor - {"8.0", 9660}, - {"8.11", 9671}, - {"8.17", 9677}, - {"8.11.3", 9671}, // patch version should be stripped - {"9.0", 9680}, // unknown version uses formula + // All projects share one Elasticsearch container on a fixed port, + // distinct from OpenSearch so both can run at once. + if got := GetElasticsearchPort(); got != StandardElasticsearchPort { + t.Errorf("GetElasticsearchPort() = %v, want %v", got, StandardElasticsearchPort) } - - for _, tt := range tests { - t.Run(tt.version, func(t *testing.T) { - if got := GetElasticsearchPort(tt.version); got != tt.expected { - t.Errorf("GetElasticsearchPort(%v) = %v, want %v", tt.version, got, tt.expected) - } - }) + if StandardElasticsearchPort == StandardSearchPort { + t.Error("Elasticsearch and OpenSearch fixed ports must differ") } } @@ -650,14 +612,14 @@ func TestComposeService_Elasticsearch_ImageResolvesVersion(t *testing.T) { Enabled: true, Version: "7.17", } - svc := g.getElasticsearchService(svcCfg, false) + svc := g.getElasticsearchService(svcCfg) if svc.Image != "elasticsearch:7.17.28" { t.Errorf("Image = %v, want elasticsearch:7.17.28 (resolved full version)", svc.Image) } - // Container name should still use the user-specified version - if svc.ContainerName != "magebox-elasticsearch-7.17" { - t.Errorf("ContainerName = %v, want magebox-elasticsearch-7.17", svc.ContainerName) + // A single shared container is used regardless of version. + if svc.ContainerName != "magebox-elasticsearch" { + t.Errorf("ContainerName = %v, want magebox-elasticsearch", svc.ContainerName) } } @@ -673,16 +635,16 @@ func TestComposeService_Elasticsearch_MajorVersionResolvesImageAndPort(t *testin Enabled: true, Version: "7", } - svc := g.getElasticsearchService(svcCfg, false) + svc := g.getElasticsearchService(svcCfg) if svc.Image != "elasticsearch:7.17.28" { t.Errorf("Image = %v, want elasticsearch:7.17.28", svc.Image) } - if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9657:9200") { - t.Errorf("Ports = %v, want [9657:9200]", svc.Ports) + if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9500:9200") { + t.Errorf("Ports = %v, want [9500:9200]", svc.Ports) } - if svc.ContainerName != "magebox-elasticsearch-7" { - t.Errorf("ContainerName = %v, want magebox-elasticsearch-7", svc.ContainerName) + if svc.ContainerName != "magebox-elasticsearch" { + t.Errorf("ContainerName = %v, want magebox-elasticsearch", svc.ContainerName) } } @@ -699,14 +661,14 @@ func TestComposeService_OpenSearch_ImageResolvesVersion(t *testing.T) { Enabled: true, Version: "2.19", } - svc := g.getOpenSearchService(svcCfg, false) + svc := g.getOpenSearchService(svcCfg) if svc.Image != "opensearchproject/opensearch:2.19.2" { t.Errorf("Image = %v, want opensearchproject/opensearch:2.19.2 (resolved full version)", svc.Image) } - // Container name should still use the user-specified version - if svc.ContainerName != "magebox-opensearch-2.19" { - t.Errorf("ContainerName = %v, want magebox-opensearch-2.19", svc.ContainerName) + // A single shared container is used regardless of version. + if svc.ContainerName != "magebox-opensearch" { + t.Errorf("ContainerName = %v, want magebox-opensearch", svc.ContainerName) } } @@ -722,16 +684,16 @@ func TestComposeService_OpenSearch_MajorVersionResolvesImageAndPort(t *testing.T Enabled: true, Version: "2", } - svc := g.getOpenSearchService(svcCfg, false) + svc := g.getOpenSearchService(svcCfg) if svc.Image != "opensearchproject/opensearch:2.19.2" { t.Errorf("Image = %v, want opensearchproject/opensearch:2.19.2", svc.Image) } - if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9259:9200") { - t.Errorf("Ports = %v, want [9259:9200]", svc.Ports) + if len(svc.Ports) != 1 || !strings.Contains(svc.Ports[0], "9200:9200") { + t.Errorf("Ports = %v, want [9200:9200]", svc.Ports) } - if svc.ContainerName != "magebox-opensearch-2" { - t.Errorf("ContainerName = %v, want magebox-opensearch-2", svc.ContainerName) + if svc.ContainerName != "magebox-opensearch" { + t.Errorf("ContainerName = %v, want magebox-opensearch", svc.ContainerName) } } @@ -1282,3 +1244,146 @@ func TestComposeGenerator_GenerateGlobalServices_NoRedisWhenNotRequired(t *testi t.Error("Compose should not contain valkey service when no project requires it") } } + +func TestSelectSearchService(t *testing.T) { + tests := []struct { + name string + requested map[string]*config.ServiceConfig + defaultVersion string + wantVersion string + wantMemory string + }{ + { + name: "none requested", + requested: map[string]*config.ServiceConfig{}, + wantVersion: "", + }, + { + name: "single version", + requested: map[string]*config.ServiceConfig{ + "2.19": {Enabled: true, Version: "2.19"}, + }, + wantVersion: "2.19", + }, + { + name: "highest wins when no default", + requested: map[string]*config.ServiceConfig{ + "2.19": {Enabled: true, Version: "2.19"}, + "3.0": {Enabled: true, Version: "3.0"}, + "2.5": {Enabled: true, Version: "2.5"}, + }, + wantVersion: "3.0", + }, + { + name: "global default wins over highest", + requested: map[string]*config.ServiceConfig{ + "2.19": {Enabled: true, Version: "2.19"}, + "3.0": {Enabled: true, Version: "3.0"}, + }, + defaultVersion: "2.19", + wantVersion: "2.19", + }, + { + name: "largest requested memory provisioned", + requested: map[string]*config.ServiceConfig{ + "2.19": {Enabled: true, Version: "2.19", Memory: "1g"}, + "3.0": {Enabled: true, Version: "3.0", Memory: "512m"}, + }, + wantVersion: "3.0", + wantMemory: "1g", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, cfg := selectSearchService(tt.requested, tt.defaultVersion) + if version != tt.wantVersion { + t.Errorf("version = %q, want %q", version, tt.wantVersion) + } + if tt.wantVersion == "" { + if cfg != nil { + t.Errorf("cfg = %v, want nil", cfg) + } + return + } + if cfg == nil { + t.Fatal("cfg = nil, want non-nil") + } + if cfg.Version != tt.wantVersion { + t.Errorf("cfg.Version = %q, want %q", cfg.Version, tt.wantVersion) + } + if cfg.Memory != tt.wantMemory { + t.Errorf("cfg.Memory = %q, want %q", cfg.Memory, tt.wantMemory) + } + }) + } +} + +// TestGenerateGlobalServices_SharedSearchContainer verifies that projects on +// different search versions collapse into a single shared container per engine. +func TestGenerateGlobalServices_SharedSearchContainer(t *testing.T) { + g, _ := setupTestComposeGenerator(t) + + configs := []*config.Config{ + { + Name: "project1", + Services: config.Services{ + OpenSearch: &config.ServiceConfig{Enabled: true, Version: "2.19"}, + }, + }, + { + Name: "project2", + Services: config.Services{ + OpenSearch: &config.ServiceConfig{Enabled: true, Version: "3.0"}, + }, + }, + { + Name: "project3", + Services: config.Services{ + Elasticsearch: &config.ServiceConfig{Enabled: true, Version: "8.11"}, + }, + }, + } + + if err := g.GenerateGlobalServices(configs); err != nil { + t.Fatalf("GenerateGlobalServices failed: %v", err) + } + + content, err := os.ReadFile(g.ComposeFilePath()) + if err != nil { + t.Fatalf("Failed to read compose file: %v", err) + } + var compose ComposeConfig + if err := yaml.Unmarshal(content, &compose); err != nil { + t.Fatalf("Failed to parse compose file: %v", err) + } + + // Exactly one shared container per engine, keyed by unversioned service name. + searchServices := 0 + for name := range compose.Services { + if strings.HasPrefix(name, "opensearch") || strings.HasPrefix(name, "elasticsearch") { + searchServices++ + } + } + if searchServices != 2 { + t.Errorf("got %d search services, want 2 (one opensearch, one elasticsearch)", searchServices) + } + os, ok := compose.Services["opensearch"] + if !ok { + t.Fatal("compose should contain a single 'opensearch' service") + } + if len(os.Ports) != 1 || !strings.Contains(os.Ports[0], "9200:9200") { + t.Errorf("opensearch Ports = %v, want [9200:9200]", os.Ports) + } + // Highest requested version wins (no global default configured in test). + if !strings.Contains(os.Image, "opensearch:3") { + t.Errorf("opensearch Image = %v, want the highest requested (3.x) version", os.Image) + } + es, ok := compose.Services["elasticsearch"] + if !ok { + t.Fatal("compose should contain a single 'elasticsearch' service") + } + if len(es.Ports) != 1 || !strings.Contains(es.Ports[0], "9500:9200") { + t.Errorf("elasticsearch Ports = %v, want [9500:9200]", es.Ports) + } +} diff --git a/internal/project/lifecycle.go b/internal/project/lifecycle.go index 78c6662..4721eb9 100644 --- a/internal/project/lifecycle.go +++ b/internal/project/lifecycle.go @@ -312,19 +312,17 @@ func (m *Manager) Status(projectPath string) (*ProjectStatus, error) { } } if cfg.Services.HasOpenSearch() { - // Service name in docker-compose removes dots from version (e.g., opensearch2194) - serviceName := fmt.Sprintf("opensearch%s", strings.ReplaceAll(cfg.Services.OpenSearch.Version, ".", "")) + // A single shared OpenSearch container serves all projects. status.Services["opensearch"] = ServiceStatus{ Name: fmt.Sprintf("OpenSearch %s", cfg.Services.OpenSearch.Version), - IsRunning: dockerController.IsServiceRunning(serviceName), + IsRunning: dockerController.IsServiceRunning("opensearch"), } } if cfg.Services.HasElasticsearch() { - // Service name in docker-compose removes dots from version (e.g., elasticsearch8170) - serviceName := fmt.Sprintf("elasticsearch%s", strings.ReplaceAll(cfg.Services.Elasticsearch.Version, ".", "")) + // A single shared Elasticsearch container serves all projects. status.Services["elasticsearch"] = ServiceStatus{ Name: fmt.Sprintf("Elasticsearch %s", cfg.Services.Elasticsearch.Version), - IsRunning: dockerController.IsServiceRunning(serviceName), + IsRunning: dockerController.IsServiceRunning("elasticsearch"), } } } else { @@ -450,10 +448,10 @@ func projectComposeServiceNames(cfg *config.Config) []string { names = append(names, cfg.Services.GetCacheServiceName()) } if cfg.Services.HasOpenSearch() { - names = append(names, fmt.Sprintf("opensearch%s", strings.ReplaceAll(cfg.Services.OpenSearch.Version, ".", ""))) + names = append(names, "opensearch") } if cfg.Services.HasElasticsearch() { - names = append(names, fmt.Sprintf("elasticsearch%s", strings.ReplaceAll(cfg.Services.Elasticsearch.Version, ".", ""))) + names = append(names, "elasticsearch") } if cfg.Services.HasRabbitMQ() { names = append(names, "rabbitmq") diff --git a/vitepress/guide/search.md b/vitepress/guide/search.md index 6b4c338..c5c8265 100644 --- a/vitepress/guide/search.md +++ b/vitepress/guide/search.md @@ -68,13 +68,17 @@ services: | Property | Value | |----------|-------| | Host | 127.0.0.1 | -| Port | 9200 | +| Port (OpenSearch) | 9200 | +| Port (Elasticsearch) | 9500 | | Protocol | http | +Each engine runs as a single shared container on a fixed port. OpenSearch and +Elasticsearch use different ports so both can run at the same time. + ## Magento Configuration ::: warning Use your project name as index prefix -MageBox projects share a single OpenSearch/Elasticsearch Docker instance on port 9200. Using the same prefix (e.g. `magento2`) across projects causes index collisions — one project's reindex will overwrite another's data. Always set the prefix to your project name. +MageBox projects share a single OpenSearch/Elasticsearch Docker instance (OpenSearch on port 9200, Elasticsearch on port 9500). Using the same prefix (e.g. `magento2`) across projects causes index collisions — one project's reindex will overwrite another's data. Always set the prefix to your project name. ::: ### OpenSearch @@ -116,7 +120,7 @@ Or in `app/etc/env.php`: 'search' => [ 'engine' => 'elasticsearch7', 'elasticsearch7_server_hostname' => '127.0.0.1', - 'elasticsearch7_server_port' => '9200', + 'elasticsearch7_server_port' => '9500', 'elasticsearch7_index_prefix' => 'myproject', 'elasticsearch7_enable_auth' => '0', 'elasticsearch7_server_timeout' => '15' @@ -135,7 +139,7 @@ Or in `app/etc/env.php`: 'search' => [ 'engine' => 'elasticsearch8', 'elasticsearch8_server_hostname' => '127.0.0.1', - 'elasticsearch8_server_port' => '9200', + 'elasticsearch8_server_port' => '9500', 'elasticsearch8_index_prefix' => 'myproject', 'elasticsearch8_enable_auth' => '0', 'elasticsearch8_server_timeout' => '15' diff --git a/vitepress/reference/config-options.md b/vitepress/reference/config-options.md index a70db7c..3386fb2 100644 --- a/vitepress/reference/config-options.md +++ b/vitepress/reference/config-options.md @@ -125,7 +125,7 @@ Use either `mysql` OR `mariadb`, not both. | `redis` | boolean | 6379 | In-memory cache/session | | `valkey` | boolean | 6379 | In-memory cache/session (Redis alternative) | | `opensearch` | string/boolean | 9200 | Catalog search | -| `elasticsearch` | string/boolean | 9200 | Catalog search (alternative) | +| `elasticsearch` | string/boolean | 9500 | Catalog search (alternative) | | `rabbitmq` | boolean | 5672, 15672 | Message queue | | `mailpit` | boolean | 1025, 8025 | Email testing | | `varnish` | boolean | 6081 | HTTP cache | diff --git a/vitepress/reference/ports.md b/vitepress/reference/ports.md index df56086..ff7bde7 100644 --- a/vitepress/reference/ports.md +++ b/vitepress/reference/ports.md @@ -15,11 +15,8 @@ Reference for all service ports used by MageBox. | MariaDB 10.6 | 33106 | TCP | Database | | MariaDB 11.4 | 33114 | TCP | Database | | Redis/Valkey | 6379 | TCP | Cache/Sessions | -| OpenSearch 1.3 | 9223 | HTTP | Search | -| OpenSearch 2.19 | 9259 | HTTP | Search | -| OpenSearch 3.3 | 9263 | HTTP | Search | -| Elasticsearch 7.17 | 9657 | HTTP | Search | -| Elasticsearch 8.11 | 9671 | HTTP | Search | +| OpenSearch | 9200 | HTTP | Search | +| Elasticsearch | 9500 | HTTP | Search | | RabbitMQ AMQP | 5672 | AMQP | Message queue | | RabbitMQ Management | 15672 | HTTP | Management UI | | Mailpit SMTP | 1025 | SMTP | Email capture | @@ -105,55 +102,32 @@ redis-cli -h 127.0.0.1 -p 6379 ### OpenSearch -Port convention: `9200 + major*20 + minor` - -| Version | Calculation | Port | -|---------|-------------|------| -| OpenSearch 1.3 | 9200 + 1×20 + 3 | 9223 | -| OpenSearch 2.5 | 9200 + 2×20 + 5 | 9245 | -| OpenSearch 2.11 | 9200 + 2×20 + 11 | 9251 | -| OpenSearch 2.12 | 9200 + 2×20 + 12 | 9252 | -| OpenSearch 2.19 | 9200 + 2×20 + 19 | 9259 | -| OpenSearch 3.0 | 9200 + 3×20 + 0 | 9260 | -| OpenSearch 3.3 | 9200 + 3×20 + 3 | 9263 | +All projects share a single OpenSearch container on the fixed port **9200**, +regardless of the version configured. ```bash -# OpenSearch 2.19 -curl http://127.0.0.1:9259 - -# OpenSearch 3.3 -curl http://127.0.0.1:9263 +curl http://127.0.0.1:9200 ``` ### Elasticsearch -Port convention: `9500 + major*20 + minor` - -| Version | Calculation | Port | -|---------|-------------|------| -| Elasticsearch 7.6 | 9500 + 7×20 + 6 | 9646 | -| Elasticsearch 7.17 | 9500 + 7×20 + 17 | 9657 | -| Elasticsearch 8.0 | 9500 + 8×20 + 0 | 9660 | -| Elasticsearch 8.11 | 9500 + 8×20 + 11 | 9671 | -| Elasticsearch 8.17 | 9500 + 8×20 + 17 | 9677 | +All projects share a single Elasticsearch container on the fixed port **9500**. +It differs from OpenSearch's port so both engines can run at the same time. ```bash -# Elasticsearch 7.17 -curl http://127.0.0.1:9657 - -# Elasticsearch 8.11 -curl http://127.0.0.1:9671 +curl http://127.0.0.1:9500 ``` -### Why Different Search Ports? +### One Shared Search Container -Using unique ports per version allows: -- Running multiple search engine versions simultaneously -- Different projects with different OpenSearch/Elasticsearch versions -- No port conflicts +MageBox runs at most one OpenSearch and one Elasticsearch container for the whole +machine — not one per project or per version. When projects request different +versions, the version from your global config wins (`magebox config`), otherwise the +highest requested version is used. The container is provisioned with the largest +`memory` any project requests. -When only one search service is configured (or the version matches the global default), -it also gets exposed on the standard port 9200 for backward compatibility. +This keeps the search port stable and predictable, and avoids spinning up a separate +search container for every project. ## Message Queue Ports @@ -229,7 +203,7 @@ Access Web UI: http://localhost:8025 'search' => [ 'engine' => 'opensearch', 'opensearch_server_hostname' => '127.0.0.1', - 'opensearch_server_port' => '9259' // Adjust for your OpenSearch version + 'opensearch_server_port' => '9200' ] ] ] @@ -268,10 +242,10 @@ Access Web UI: http://localhost:8025 ```bash # All MageBox-related ports -netstat -tlnp | grep -E "33(057|080|084|104|106|114)|6379|92[2-9][0-9]|94[4-8][0-9]|5672|15672|1025|8025|6081" +netstat -tlnp | grep -E "33(057|080|084|104|106|114)|6379|9200|9500|5672|15672|1025|8025|6081" # Or with lsof -lsof -i -P -n | grep LISTEN | grep -E "33|6379|92[2-9]|94[4-8]|5672|15672|1025|8025|6081" +lsof -i -P -n | grep LISTEN | grep -E "33|6379|9200|9500|5672|15672|1025|8025|6081" ``` ### Check Specific Port @@ -304,7 +278,7 @@ sudo systemctl stop mysql sudo systemctl stop apache2 ``` -2. MageBox uses version-specific ports to avoid conflicts between projects +2. MageBox uses version-specific ports for databases to avoid conflicts between projects. Search engines instead use a single shared container per engine on a fixed port (OpenSearch 9200, Elasticsearch 9500). ## Docker Port Mapping @@ -313,8 +287,8 @@ Services bind to localhost only: ``` 127.0.0.1:33080 → container:3306 (MySQL 8.0) 127.0.0.1:6379 → container:6379 (Redis) -127.0.0.1:9259 → container:9200 (OpenSearch 2.19) -127.0.0.1:9657 → container:9200 (Elasticsearch 7.17) +127.0.0.1:9200 → container:9200 (OpenSearch, shared) +127.0.0.1:9500 → container:9200 (Elasticsearch, shared) ``` This means services are only accessible from your machine, not from the network. diff --git a/vitepress/services/opensearch.md b/vitepress/services/opensearch.md index 1830835..d8fa60e 100644 --- a/vitepress/services/opensearch.md +++ b/vitepress/services/opensearch.md @@ -24,8 +24,8 @@ Search engines power Magento's catalog search, providing: | Version | Port | Magento Compatibility | |---------|------|----------------------| -| Elasticsearch 7.17 | 9200 | Magento 2.4.4 - 2.4.5 | -| Elasticsearch 8.x | 9200 | Magento 2.4.6+ | +| Elasticsearch 7.17 | 9500 | Magento 2.4.4 - 2.4.5 | +| Elasticsearch 8.x | 9500 | Magento 2.4.6+ | ::: tip OpenSearch is recommended for new projects. It's a community-driven fork of Elasticsearch with full Magento compatibility. @@ -80,15 +80,19 @@ These plugins enable: | Setting | Value | |---------|-------| | Host | `127.0.0.1` | -| Port | `9200` | +| Port (OpenSearch) | `9200` | +| Port (Elasticsearch) | `9500` | | Protocol | HTTP | +MageBox runs a single shared container per engine, on a fixed port. OpenSearch and +Elasticsearch use different ports so both can run simultaneously. + ## Magento Configuration ### Via Install Command ::: warning Use your project name as index prefix -MageBox projects share a single OpenSearch/Elasticsearch Docker instance on port 9200. Using the same prefix (e.g. `magento2`) across projects causes index collisions — one project's reindex will overwrite another's data. Always set the prefix to your project name. +MageBox projects share a single OpenSearch/Elasticsearch Docker instance (OpenSearch on port 9200, Elasticsearch on port 9500). Using the same prefix (e.g. `magento2`) across projects causes index collisions — one project's reindex will overwrite another's data. Always set the prefix to your project name. ::: ```bash @@ -107,7 +111,7 @@ For Elasticsearch: php bin/magento setup:install \ --search-engine=elasticsearch8 \ --elasticsearch-host=127.0.0.1 \ - --elasticsearch-port=9200 \ + --elasticsearch-port=9500 \ --elasticsearch-index-prefix=myproject \ --elasticsearch-timeout=15 \ # ... other options