Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
strategy:
fail-fast: false
matrix:
product: [elasticsearch, opensearch]
product: [elasticsearch, opensearch, opensearch-compat]
runs-on: ubuntu-24.04
timeout-minutes: 50
env:
Expand Down Expand Up @@ -82,7 +82,7 @@ jobs:
strategy:
fail-fast: false
matrix:
product: [elasticsearch, opensearch]
product: [elasticsearch, opensearch, opensearch-compat]
runs-on: ubuntu-24.04
timeout-minutes: 25
env:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ See [Performance](docs/performance.md) for the full results.
| Go | 1.27.3, to build from source |
| Operating system | Linux or macOS, amd64 or arm64 |
| MongoDB | A nonsharded replica set with the client `bulkWrite` command (MongoDB 8.0 or later). Tested with 8.0.32. Lua transforms also require transactions. |
| Search | Tested with Elasticsearch 8.19.22 and OpenSearch 3.3.0 |
| Search | Tested with Elasticsearch 8.19.22, OpenSearch 3.3.0, and OpenSearch 2.17.1 with `compatibility.override_main_response_version` |
| Collections and indices | Must exist before typed reads and mutations. Native commands can create them. |
| Network | The application listener uses plaintext gRPC. Run it on an isolated network. Backend authentication stays local. |

Expand Down
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ userinfo, path, query or fragment. Authentication or a custom CA requires HTTPS.
Idle backend connections close after 30s. Each adapter invocation qualifies its
distinct indexes without retaining metadata between invocations.

At startup Weir identifies the product from `GET /`: Elasticsearch by build
flavor `default` with no distribution, and OpenSearch by distribution
`opensearch`. OpenSearch with `compatibility.override_main_response_version`,
which AWS OpenSearch Service domains often enable, reports version 7.10.2
without either field; Weir recognizes it by that version and the OpenSearch
tagline. Any other product stops startup.

Both adapters make up to five attempts per transaction or compare-and-swap
after a confirmed conflict or abort. Possible writes and ambiguous commits are
never replayed. MongoDB may retry confirmation of the same commit separately.
Expand Down
15 changes: 9 additions & 6 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ fixtures:
`mongod` on `127.0.0.1:27028`. `WEIR_MONGOD` selects another binary and
`WEIR_MONGO_BIND` adds comma-separated literal, non-wildcard IP bind
addresses. A running fixture is reused only when its bind matches.
- `scripts/search-local.sh start elasticsearch|opensearch normal|overload` runs
Elasticsearch 8.19.22 on `127.0.0.1:19200` or OpenSearch 3.3.0 on
`127.0.0.1:19202` in Docker. Use `normal` capacity for ordinary runs; the
default `overload` profile shrinks the write thread pool for the Search
- `scripts/search-local.sh start elasticsearch|opensearch|opensearch-compat normal|overload`
runs Elasticsearch 8.19.22 on `127.0.0.1:19200`, OpenSearch 3.3.0 on
`127.0.0.1:19202`, or OpenSearch 2.17.1 with
`compatibility.override_main_response_version=true` on `127.0.0.1:19203` in
Docker. The last matches AWS OpenSearch Service domains whose root reports
Elasticsearch 7.10.2 to every client. Use `normal` capacity for ordinary
runs; the default `overload` profile shrinks the write thread pool for the Search
capacity-rejection tests. `start` waits until the cluster is green and the
write thread pool has stayed idle for 5 seconds, so startup system-index
writes do not compete with tests. Each fixture container runs a 1 GiB heap
Expand All @@ -37,8 +40,8 @@ fixtures:
The TLS and replica-set tests start their own `mongod` processes from the same
binary. The HTTPS Search tests start their own 1.5 GiB container from the image
`scripts/search-local.sh` pulled, so the whole suite needs about 3.5 GiB of
Docker memory. `WEIR_SEARCH_PRODUCT` selects `elasticsearch` (the default) or
`opensearch`.
Docker memory. `WEIR_SEARCH_PRODUCT` selects `elasticsearch` (the default),
`opensearch` or `opensearch-compat`.
Run packages one at a time, because MongoDB fault injection uses a server-global
failpoint:

Expand Down
6 changes: 1 addition & 5 deletions internal/app/cli_container_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ func containerHostAddress(t *testing.T, address string) string {
// container so a CLI container reaches Search without a host round trip.
func searchContainerURL(t *testing.T, backend *testsearch.Backend) string {
t.Helper()
name := "weir-local-elasticsearch"
if backend.Product == "opensearch" {
name = "weir-local-opensearch-3.3"
}
raw := runDocker(t, "inspect", "--type", "container", "--format", `{{index .Config.Labels "weir.owner"}} {{(index .NetworkSettings.Networks "bridge").IPAddress}} {{.State.Running}}`, name)
raw := runDocker(t, "inspect", "--type", "container", "--format", `{{index .Config.Labels "weir.owner"}} {{(index .NetworkSettings.Networks "bridge").IPAddress}} {{.State.Running}}`, backend.Container)
fields := strings.Fields(raw)
if len(fields) != 3 || fields[0] != "weir-search-local" || fields[2] != "true" {
t.Fatal("local Search fixture container is not running", raw)
Expand Down
11 changes: 11 additions & 0 deletions internal/backend/search/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
const ElasticsearchProduct = "elasticsearch"
const OpenSearchProduct = "opensearch"

const openSearchTagline = "The OpenSearch Project: https://opensearch.org/"

// Config is static backend client configuration; credentials are never caller
// identity. HTTPS without a CAFile uses the system trust roots.
type Config struct {
Expand Down Expand Up @@ -126,7 +128,11 @@ func (a *Adapter) qualify(ctx context.Context) error {
call := exchange{path: "/"}
status, raw, err := a.request(ctx, call)
var info struct {
Tagline string
Version struct {
// Only compatibility mode is identified by its number, so other
// products may report any type.
Number any
Distribution string
BuildFlavor string `json:"build_flavor"`
}
Expand All @@ -139,6 +145,11 @@ func (a *Adapter) qualify(ctx context.Context) error {
a.dialect = ElasticsearchProduct
case info.Version.Distribution == "opensearch":
a.dialect = OpenSearchProduct
// compatibility.override_main_response_version, which AWS OpenSearch
// Service domains often enable, makes OpenSearch report Elasticsearch
// 7.10.2 without a distribution. Its tagline still names OpenSearch.
case info.Version.Number == "7.10.2" && info.Version.Distribution == "" && info.Version.BuildFlavor == "" && info.Tagline == openSearchTagline:
a.dialect = OpenSearchProduct
default:
return errors.New("unsupported Search server product")
}
Expand Down
10 changes: 10 additions & 0 deletions internal/backend/search/product_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ func TestSearchProductDetection(t *testing.T) {
{name: "wrong-flavor-type", metadata: `{"version":{"build_flavor":123}}`, failure: "search product identification failed"},
{name: "wrong-distribution-type", metadata: `{"version":{"distribution":{}}}`, failure: "search product identification failed"},
{name: "wrong-version-type", metadata: `{"version":[]}`, failure: "search product identification failed"},
{name: "os-compatibility-mode", metadata: `{"name":"node","cluster_name":"123456789012:domain","version":{"number":"7.10.2","build_type":"tar","build_hash":"unknown","build_snapshot":false,"lucene_version":"9.11.1","minimum_wire_compatibility_version":"7.10.0","minimum_index_compatibility_version":"7.0.0"},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, product: OpenSearchProduct},
{name: "es-oss-tagline", metadata: `{"version":{"number":"7.10.2","build_flavor":"oss"},"tagline":"You Know, for Search"}`, failure: "unsupported Search server product"},
{name: "es-tagline-without-flavor", metadata: `{"version":{"number":"7.10.2"},"tagline":"You Know, for Search"}`, failure: "unsupported Search server product"},
{name: "os-tagline-with-flavor", metadata: `{"version":{"number":"7.10.2","build_flavor":"oss"},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, failure: "unsupported Search server product"},
{name: "os-tagline-unknown-distribution", metadata: `{"version":{"number":"7.10.2","distribution":"other"},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, failure: "unsupported Search server product"},
{name: "os-tagline-variant", metadata: `{"version":{"number":"7.10.2"},"tagline":"The OpenSearch Project"}`, failure: "unsupported Search server product"},
{name: "os-tagline-without-version", metadata: `{"tagline":"The OpenSearch Project: https://opensearch.org/"}`, failure: "unsupported Search server product"},
{name: "os-tagline-null-version", metadata: `{"version":null,"tagline":"The OpenSearch Project: https://opensearch.org/"}`, failure: "unsupported Search server product"},
{name: "os-tagline-other-number", metadata: `{"version":{"number":"8.19.22","build_flavor":""},"tagline":"The OpenSearch Project: https://opensearch.org/"}`, failure: "unsupported Search server product"},
{name: "wrong-tagline-type", metadata: `{"version":{},"tagline":1}`, failure: "search product identification failed"},
{name: "malformed", metadata: `{"version":`, failure: "search product identification failed"},
{name: "unauthorized", metadata: `{"error":"metadata-secret-sentinel"}`, status: http.StatusUnauthorized, failure: "search product identification failed"},
{name: "unavailable", metadata: `{"error":"metadata-secret-sentinel"}`, status: http.StatusServiceUnavailable, failure: "search product identification failed"},
Expand Down
4 changes: 2 additions & 2 deletions internal/backend/search/secure_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ func secureReplyFault(t *testing.T, fixture *testsearch.SecureFixture, operation
switch b.Profile {
case "elasticsearch-8.19.22":
wantOutcome, wantCode = pb.MutationOutcome_NOT_APPLIED, pb.FailureCode_UNAUTHENTICATED
case "opensearch-3.3.0":
// This fixture returns an unstructured 401, so the adapter cannot prove non-application.
case "opensearch-3.3.0", "opensearch-2.17.1-compat":
// These fixtures return an unstructured 401, so the adapter cannot prove non-application.
wantOutcome, wantCode = pb.MutationOutcome_UNKNOWN, pb.FailureCode_UNAVAILABLE
default:
t.Fatal("unqualified native authentication response profile", b.Profile)
Expand Down
6 changes: 6 additions & 0 deletions internal/backend/search/security_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ func TestSearchSecurityInputBoundary(t *testing.T) {
t.Log("new native ES min_hash cap rejects 10001 before filter construction; no OOM input")
return
}
if b.Profile == "opensearch-2.17.1-compat" {
// The limit fixes CVE-2025-9624, a query_string denial of service,
// and first shipped in OpenSearch 2.19.4. The profile matches the
// 2.17 production domain, so it is a known-unpatched build.
t.Skip("OpenSearch 2.17 predates search.query.max_query_string_length (CVE-2025-9624); this probes the backend, not Weir")
}
// This disposable cluster has no external users. Restore the temporary limit
// independently of the test result; production Weir never changes settings.
t.Cleanup(func() {
Expand Down
123 changes: 91 additions & 32 deletions internal/testutil/testsearch/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,67 @@ var sequence atomic.Uint64
type Backend struct {
URL, Product, Index string
// Profile names the fixture product and version.
Profile string
Profile string
// Container names the scripts/search-local.sh container.
Container string
Username, Password, CAFile string
Client *http.Client
}

// searchProduct returns WEIR_SEARCH_PRODUCT, which defaults to elasticsearch.
func searchProduct(t *testing.T) string {
// fixture describes one backend that scripts/search-local.sh runs.
type fixture struct {
// product is the Search dialect Weir must detect.
product, profile, container, url string
// images are the pinned images by GOARCH. They match the images
// scripts/search-local.sh pulls; the secure fixture runs them unpulled.
images map[string]string
// compatibility enables OpenSearch's Elasticsearch 7.10 main response.
compatibility bool
}

var fixtures = map[string]fixture{
"elasticsearch": {
product: "elasticsearch", profile: "elasticsearch-8.19.22", container: "weir-local-elasticsearch", url: "http://127.0.0.1:19200",
images: map[string]string{
"amd64": "docker.elastic.co/elasticsearch/elasticsearch@sha256:f1e3d88299cb0db2131fc2ab9060faefccd88fd684d11a49256ae00d7dc3c857",
"arm64": "docker.elastic.co/elasticsearch/elasticsearch@sha256:c2a3ed5f968be6d59c960aa0c60cfdaee667b6bc8211142021a41d0e85b43237",
},
},
"opensearch": {
product: "opensearch", profile: "opensearch-3.3.0", container: "weir-local-opensearch-3.3", url: "http://127.0.0.1:19202",
images: map[string]string{
"amd64": "opensearchproject/opensearch@sha256:59e9e49ac00f19c2af9094e367c8e6586f7d07e6c06465c3f4c43b40934309f2",
"arm64": "opensearchproject/opensearch@sha256:d96afaf6cbd2a6a3695aeb2f1d48c9a16ad5c8918eb849e5cbf43475f0f8e146",
},
},
"opensearch-compat": {
product: "opensearch", profile: "opensearch-2.17.1-compat", container: "weir-local-opensearch-2.17-compat", url: "http://127.0.0.1:19203",
images: map[string]string{
"amd64": "opensearchproject/opensearch@sha256:ab6db8c14062060cda5f0428364e76ffe2a6f03ad265589b70182c0cecb7f9d7",
"arm64": "opensearchproject/opensearch@sha256:7d961ff222c267093b7b95fc2e397d31a06a42b6f3c42ee67fc5788417a274bf",
},
compatibility: true,
},
}

// selectedFixture returns the fixture WEIR_SEARCH_PRODUCT names, which
// defaults to elasticsearch.
func selectedFixture(t *testing.T) fixture {
t.Helper()
product := os.Getenv("WEIR_SEARCH_PRODUCT")
switch product {
case "":
return "elasticsearch"
case "elasticsearch", "opensearch":
return product
}
t.Fatalf("WEIR_SEARCH_PRODUCT=%q; want elasticsearch or opensearch", product)
return ""
name := os.Getenv("WEIR_SEARCH_PRODUCT")
if name == "" {
name = "elasticsearch"
}
selected, ok := fixtures[name]
if !ok {
t.Fatalf("WEIR_SEARCH_PRODUCT=%q; want elasticsearch, opensearch or opensearch-compat", name)
}
return selected
}

func Open(t *testing.T) *Backend {
t.Helper()
var endpoint, profile string
product := searchProduct(t)
switch product {
case "elasticsearch":
endpoint, profile = "http://127.0.0.1:19200", "elasticsearch-8.19.22"
case "opensearch":
endpoint, profile = "http://127.0.0.1:19202", "opensearch-3.3.0"
}
selected := selectedFixture(t)
transport := &http.Transport{Proxy: nil, MaxConnsPerHost: 8, MaxIdleConnsPerHost: 8, DisableCompression: true}
client := &http.Client{
Transport: transport,
Expand All @@ -58,42 +90,69 @@ func Open(t *testing.T) *Backend {
}
index := fmt.Sprintf("weir_search_%d_%d", os.Getpid(), sequence.Add(1))
b := &Backend{
URL: endpoint,
Product: product,
Profile: profile,
Index: index,
Client: client,
URL: selected.url,
Product: selected.product,
Profile: selected.profile,
Container: selected.container,
Index: index,
Client: client,
}
status, raw := b.Do(t, "GET", "/", "")
if status != 200 {
t.Fatal("wrong isolated backend", status)
}
cluster := "weir-local-" + product
if profile == "opensearch-3.3.0" {
cluster = "weir-local-opensearch-3.3"
verifyVersion(t, raw, selected.profile, selected.container)
if selected.compatibility {
verifyNodeVersion(t, b, compatibilityVersion)
}
verifyVersion(t, raw, profile, cluster)
t.Cleanup(transport.CloseIdleConnections)
b.Create(t, index, `{"settings":{"number_of_shards":1,"number_of_replicas":0},"mappings":{"properties":{"n":{"type":"long"}}}}`)
return b
}

// compatibilityVersion is the OpenSearch release behind the compatibility
// profile, whose root reports 7.10.2 instead.
const compatibilityVersion = "2.17.1"

// verifyNodeVersion checks the release the node itself reports, which
// compatibility mode does not rewrite.
func verifyNodeVersion(t *testing.T, b *Backend, want string) {
t.Helper()
status, raw := b.Do(t, "GET", "/_nodes/_local?filter_path=nodes.*.version", "")
var nodes struct {
Nodes map[string]struct{ Version string }
}
if status != 200 || json.Unmarshal(raw, &nodes) != nil || len(nodes.Nodes) != 1 {
t.Fatal("isolated backend node version unavailable", status)
}
for _, node := range nodes.Nodes {
if node.Version != want {
t.Fatalf("isolated backend runs OpenSearch %q, want %s", node.Version, want)
}
}
}

func verifyVersion(t *testing.T, raw []byte, profile, cluster string) {
t.Helper()
var root struct {
ClusterName string `json:"cluster_name"`
Tagline string
Version struct {
Number, Distribution string
BuildFlavor string `json:"build_flavor"`
BuildHash string `json:"build_hash"`
BuildFlavor *string `json:"build_flavor"`
BuildHash string `json:"build_hash"`
}
}
if json.Unmarshal(raw, &root) != nil || root.ClusterName != cluster || root.Version.BuildHash == "" {
t.Fatal("wrong isolated backend identity")
}
v := root.Version
valid := profile == "elasticsearch-8.19.22" && v.Number == "8.19.22" && v.Distribution == "" && v.BuildFlavor == "default"
valid := profile == "elasticsearch-8.19.22" && v.Number == "8.19.22" && v.Distribution == "" && v.BuildFlavor != nil && *v.BuildFlavor == "default"
valid = valid || profile == "opensearch-3.3.0" && v.Number == "3.3.0" && v.Distribution == "opensearch"
// Compatibility mode reports the Elasticsearch 7.10.2 shape; only the
// tagline still names OpenSearch.
valid = valid || profile == "opensearch-2.17.1-compat" && v.Number == "7.10.2" && v.Distribution == "" && v.BuildFlavor == nil &&
root.Tagline == "The OpenSearch Project: https://opensearch.org/"
if !valid {
t.Fatal("wrong isolated backend version/distribution")
}
Expand Down
Loading