diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index a24e6a242d..5ba0135a21 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -164,6 +164,27 @@ jobs: helm template ci helm/hugegraph \ --set "${component}.probes.readiness.successThreshold=2" > /dev/null done + # These deployment inputs must serialize unchanged through the properties reader. + must_fail --set-string server.serviceId=café + must_fail --set-string server.serviceId=😀 + must_fail --set-string "$(printf 'server.serviceId=ops\tteam')" + # Match the Server API graph-space names, including its 48-character limit. + must_fail --set-string server.graphSpace=TeamA + must_fail --set-string server.graphSpace=team-a + must_fail --set-string server.graphSpace=1team + graph_space_max=$(printf 'a%.0s' {1..48}) + helm template ci helm/hugegraph --set-string "server.graphSpace=${graph_space_max}" > /dev/null + must_fail --set-string "server.graphSpace=${graph_space_max}a" + # The hook queries an explicit, fully qualified existing graph binding. + must_fail --set-string server.testGraphAlias= + must_fail --set-string server.testGraphAlias=team_a + must_fail --set-string server.testGraphAlias=team_a-demo-name + must_fail --set-string "server.testGraphAlias=team_a-demo'" + graph_name_max=$(printf 'g%.0s' {1..48}) + helm template ci helm/hugegraph \ + --set-string "server.testGraphAlias=${graph_space_max}-${graph_name_max}" > /dev/null + must_fail --set-string "server.testGraphAlias=${graph_space_max}a-demo" + must_fail --set-string "server.testGraphAlias=team_a-${graph_name_max}g" must_fail --set pd.replicas=100 must_fail --set pd.pdb.minAvailable=3 must_fail --set server.hpa.enabled=true @@ -350,7 +371,7 @@ jobs: printf '#!/bin/sh\nfor ip in ${STUB_IPS}; do printf "%%s STREAM host\\n" "$ip"; done\n' > /tmp/stub/getent chmod +x /tmp/stub/getent cat > /tmp/fixture.py <<'PY' - import http.server, os, sys, threading, time + import http.server, json, os, sys, threading, time calls = {} class H(http.server.BaseHTTPRequestHandler): def _send(self, code, body): @@ -362,7 +383,9 @@ jobs: def do_GET(self): self._send(200, b'{"versions":{}}') def do_POST(self): - self.rfile.read(int(self.headers.get('Content-Length', 0))) + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0)))) + expected_alias = ('team_a-demo' if os.path.exists('/tmp/selected-graph') + else 'DEFAULT-hugegraph') ip = self.server.server_address[0] # broken-: every Gremlin call fails. flip-odd- / # flip-even-: the odd or even calls fail, counted from @@ -372,6 +395,7 @@ jobs: even = os.path.exists('/tmp/flip-even-' + ip) n = calls[ip] = (calls.get(ip, 0) + 1) if (odd or even) else 0 broken = (os.path.exists('/tmp/broken-' + ip) + or body.get('aliases', {}).get('graph') != expected_alias or (odd and n % 2 == 1) or (even and n % 2 == 0)) if broken: @@ -389,7 +413,7 @@ jobs: sleep 1 run_hook() { PATH="/tmp/stub:${PATH}" PASSWORD=ci-password HG_TEST_DEADLINE_S=8 \ - STUB_IPS="$1" sh /tmp/hook.sh + STUB_IPS="$1" sh "${2:-/tmp/hook.sh}" } # Healthy: three Pods, all Gremlin-bound. run_hook "127.0.0.1 127.0.0.2 127.0.0.3" @@ -424,6 +448,26 @@ jobs: exit 1 fi run_hook "127.0.0.1 127.0.0.2 127.0.0.3 ::1 ::2 ::3" + # A non-default graph space must query the explicitly selected graph. + # A missing alias still fails rather than weakening the per-Pod sweep. + touch /tmp/selected-graph + for alias in team_a-demo team_a-missing; do + helm template ci helm/hugegraph -s templates/tests/test-connection.yaml \ + --set-string server.graphSpace=team_a \ + --set-string "server.testGraphAlias=${alias}" > "/tmp/hook-${alias}.yaml" + done + python3 - <<'PY' + import yaml + for alias in ('team_a-demo', 'team_a-missing'): + d = yaml.safe_load(open('/tmp/hook-' + alias + '.yaml')) + open('/tmp/hook-' + alias + '.sh', 'w').write(d['spec']['containers'][0]['command'][2]) + PY + run_hook "127.0.0.1 127.0.0.2 127.0.0.3" /tmp/hook-team_a-demo.sh + if run_hook "127.0.0.1 127.0.0.2 127.0.0.3" /tmp/hook-team_a-missing.sh; then + echo "expected the hook to fail on a missing selected graph" >&2 + exit 1 + fi + rm /tmp/selected-graph # The auth-off render must carry no credentials. if helm template ci helm/hugegraph -s templates/tests/test-connection.yaml \ --set hubble.enabled=false --set server.auth.enabled=false | grep -q 'Authorization'; then diff --git a/docker/README.md b/docker/README.md index 869b5d819e..233cd4ef71 100644 --- a/docker/README.md +++ b/docker/README.md @@ -269,6 +269,49 @@ Standalone stores RocksDB data at `/hugegraph-server/rocksdb-data`. The HStore t `docker compose down` keeps named-volume data. `docker compose down -v` intentionally deletes it. +### Server service identity + +Server image builds containing this configuration mapping accept +`HG_SERVER_SERVICE_ID` and `HG_SERVER_GRAPHSPACE`, which write `server.service_id` and `server.graphspace` +in `conf/rest-server.properties`. A property keeps its existing value when both +its `HG_SERVER_*` variable and its older name are unset or empty; the shipped +defaults are both `DEFAULT`. +The older `SERVICE_ID` and `GRAPH_SPACE` variables emitted by the Kubernetes driver +are also accepted. A non-empty `HG_SERVER_*` value takes precedence over its older name. +These container identity inputs accept printable ASCII (bytes 0x20–0x7E); +spaces and backslashes in service IDs round-trip unchanged. Non-ASCII and control +characters are rejected before the entrypoint writes configuration, because the +Server reads properties using ISO-8859-1. This is the container input's +serialization limit; it does not change the core service-name contract. +The full service ID, including `/` and `-`, is retained when service metadata is +reloaded or its graph space is cleared. +`HG_SERVER_GRAPHSPACE` and a migrated `GRAPH_SPACE` must also match the Server +API graph-space rule: `DEFAULT`, or up to 48 lowercase letters, digits and +underscores, starting with a letter. Invalid environment values are rejected +before writing configuration; existing mounted properties are preserved. + +For a PD-backed Server, add the variables to the Server container's environment, +for example `HG_SERVER_SERVICE_ID=analytics` and `HG_SERVER_GRAPHSPACE=team_a`. +Create `team_a` in the existing cluster before starting the assigned Server; +these settings do not create graph spaces. The Compose files do not pass these +variables through from the host, so add explicit `environment:` entries to a +local Compose override. The Helm chart exposes the same settings as +[`server.serviceId` and `server.graphSpace`](../helm/hugegraph/README.md#server-service-identity). + +Use a chart and Server image from the same source version containing these +mappings. Older release images without them do not apply these environment +values; select the matching image build explicitly. +The Server's existing `SERVICE_ID` normalization (`GraphManager.serviceId()`) is +unchanged and can map different raw names to the same identifier. Select +`GRAPHSPACE` and the complete raw `SERVICE_NAME` in discovery queries; +identifier-only lookup does not provide a uniqueness or isolation guarantee. + +This selects the Server's PD service metadata. It does not change +`server.path_graphspace` for REST paths that omit `/graphspaces/`, or +configure Hubble's chosen service. Actual non-default service discovery, +authentication and graph-space isolation still require verification with a +running Linux cluster. + ### Server logs and crash files The Server container owns its foreground launcher and returns the JVM's exit status after startup, diff --git a/helm/hugegraph/README.md b/helm/hugegraph/README.md index 5284603071..a65039558f 100644 --- a/helm/hugegraph/README.md +++ b/helm/hugegraph/README.md @@ -95,6 +95,46 @@ to use `emptyDir`. Component storage flags override that global setting. PVC failures never cause an automatic fallback. Do not switch initialized PD/Store data between persistent and temporary storage. +### Server service identity + +`server.serviceId` and `server.graphSpace` select the Server's PD service metadata. +Both default to `DEFAULT`, which preserves the image configuration. Non-default +values inject `HG_SERVER_SERVICE_ID` and `HG_SERVER_GRAPHSPACE` for the image +entrypoint. Service IDs accept printable ASCII, including spaces and backslashes; +the full name, including `/` and `-`, survives metadata reload and graph-space +clearing. Dollar signs remain literal through Kubernetes environment expansion. +Graph spaces must be `DEFAULT` or up to 48 lowercase letters, digits and +underscores, starting with a letter. Create the target space before starting its +assigned Server; these settings do not create it. + +```bash +helm upgrade --install hugegraph ./helm/hugegraph -n hugegraph \ + --set-string server.serviceId=analytics \ + --set-string server.graphSpace=team_a \ + --set-string server.testGraphAlias=team_a-demo +``` + +Use a chart and Server image build from the same source version containing these +mappings. Older release images without them ignore these environment settings; +select a matching image build explicitly. The chart does not detect old images. +The Server's existing `SERVICE_ID` normalization (`GraphManager.serviceId()`) is +unchanged: select `GRAPHSPACE` and the complete raw `SERVICE_NAME` in discovery +queries. Identifier-only lookup has no uniqueness or isolation guarantee. + +`server.testGraphAlias` selects an existing Gremlin binding for every Server Pod, +defaulting to `DEFAULT-hugegraph`. It does not create a graph or infer its binding +from `server.graphSpace`. Match the binding's case: `Demo.properties` can register +`DEFAULT-Demo`, while REST-created graph names are lowercase. A successful query +of the default local binding does not verify a graph in `team_a`. Before replacing +a Pod after `Could not rebind`, verify that the graph exists and the selected alias +matches its binding; restarting cannot create a missing graph or correct an alias. + +These settings do not change the REST default path graph space or Hubble's +selected service. Use explicit `/graphspaces/team_a/...` paths and the matching +Hubble selection. Non-default discovery, authentication and graph-space isolation +still need verification with a running Linux cluster. See the +[container identity contract](../../docker/README.md#server-service-identity). + ## Modes and entry points `mode: easy` is the default. `mode: strict` changes Server/Hubble to ClusterIP @@ -146,6 +186,8 @@ with `-f`; see [values.yaml](values.yaml) for all fields and their comments. | `pd.storage.enabled` / `store.storage.enabled` / `hubble.persistence.enabled` | Explicit component storage override | `null` (inherits) | | `*.storageClassName` | Cluster default when empty; `"-"` means no class | `""` | | `pd.storage.size` / `store.storage.size` / `hubble.persistence.size` | Volume capacities | `10Gi` / `50Gi` / `1Gi` | +| `server.serviceId` / `server.graphSpace` | PD service name and existing graph space | `DEFAULT` / `DEFAULT` | +| `server.testGraphAlias` | Existing Gremlin binding checked by `helm test` | `DEFAULT-hugegraph` | | `server.storageWaitTimeoutSeconds` | Storage-ready deadline | `300` | | `networkPolicy.enabled` | Explicit policy override; null inherits mode | resolved `false` | diff --git a/helm/hugegraph/templates/_helpers.tpl b/helm/hugegraph/templates/_helpers.tpl index 911a1bd84e..cacf31face 100644 --- a/helm/hugegraph/templates/_helpers.tpl +++ b/helm/hugegraph/templates/_helpers.tpl @@ -946,7 +946,7 @@ start-hugegraph-pd.sh, start-hugegraph-store.sh, and hugegraph-server.sh). {{- $reservedEnv := dict "pd" (list "HG_PD_GRPC_HOST" "HG_PD_GRPC_PORT" "HG_PD_REST_PORT" "HG_PD_RAFT_ADDRESS" "HG_PD_RAFT_PEERS_LIST" "HG_PD_INITIAL_STORE_LIST" "HG_PD_INITIAL_STORE_COUNT" "HG_PD_DATA_PATH" "HG_PD_AUTH_SECRET_KEY" "JAVA_OPTS" "JAVA_OPTIONS") "store" (list "HG_STORE_PD_ADDRESS" "HG_STORE_GRPC_HOST" "HG_STORE_GRPC_PORT" "HG_STORE_REST_PORT" "HG_STORE_RAFT_ADDRESS" "HG_STORE_DATA_PATH" "JAVA_OPTS" "JAVA_OPTIONS") - "server" (list "POD_IP" "HG_SERVER_BACKEND" "HG_SERVER_PD_PEERS" "HG_SERVER_PD_REST_ENDPOINT" "STORE_REST" "HG_SERVER_INIT_STORE_ENABLED" "HG_SERVER_URLS_TO_PD" "HG_SERVER_STARTUP_TIMEOUT_S" "WAIT_STORAGE_TIMEOUT_S" "PD_AUTH_PASSWORD" "PASSWORD" "HG_SERVER_AUTH_TOKEN_SECRET" "JAVA_OPTS" "JAVA_OPTIONS") + "server" (list "POD_IP" "HG_SERVER_SERVICE_ID" "HG_SERVER_GRAPHSPACE" "SERVICE_ID" "GRAPH_SPACE" "HG_SERVER_BACKEND" "HG_SERVER_PD_PEERS" "HG_SERVER_PD_REST_ENDPOINT" "STORE_REST" "HG_SERVER_INIT_STORE_ENABLED" "HG_SERVER_URLS_TO_PD" "HG_SERVER_STARTUP_TIMEOUT_S" "WAIT_STORAGE_TIMEOUT_S" "PD_AUTH_PASSWORD" "PASSWORD" "HG_SERVER_AUTH_TOKEN_SECRET" "JAVA_OPTS" "JAVA_OPTIONS") "hubble" (list "HG_HUBBLE_PD_PEERS" "HG_HUBBLE_PD_SERVER" "HG_HUBBLE_PD_PASSWORD" "HG_HUBBLE_STORE_TARGETS" "HG_HUBBLE_SERVER_URL" "SPRING_DATASOURCE_URL") -}} {{- range $component, $reserved := $reservedEnv -}} {{- $componentValues := get $.Values $component | default dict -}} diff --git a/helm/hugegraph/templates/server-deployment.yaml b/helm/hugegraph/templates/server-deployment.yaml index fb51578da0..6c10a8d33a 100644 --- a/helm/hugegraph/templates/server-deployment.yaml +++ b/helm/hugegraph/templates/server-deployment.yaml @@ -259,6 +259,14 @@ spec: fieldPath: status.podIP - name: HG_SERVER_BACKEND value: {{ .Values.server.backend | quote }} + {{- if ne (.Values.server.serviceId | default "DEFAULT") "DEFAULT" }} + - name: HG_SERVER_SERVICE_ID + value: {{ .Values.server.serviceId | replace "$" "$$" | quote }} + {{- end }} + {{- if ne (.Values.server.graphSpace | default "DEFAULT") "DEFAULT" }} + - name: HG_SERVER_GRAPHSPACE + value: {{ .Values.server.graphSpace | quote }} + {{- end }} - name: HG_SERVER_PD_PEERS value: {{ include "hugegraph.pd.grpcPeersList" . | quote }} - name: HG_SERVER_PD_REST_ENDPOINT diff --git a/helm/hugegraph/templates/tests/test-connection.yaml b/helm/hugegraph/templates/tests/test-connection.yaml index b683b0d4c3..08b7501c13 100644 --- a/helm/hugegraph/templates/tests/test-connection.yaml +++ b/helm/hugegraph/templates/tests/test-connection.yaml @@ -78,7 +78,7 @@ spec: PORT="{{ .Values.server.port }}" FLOOR="{{ include "hugegraph.server.replicaFloor" . }}" DEADLINE_S="${HG_TEST_DEADLINE_S:-150}" - BODY='{"gremlin":"graph.traversal().V().limit(1).count()","aliases":{"graph":"DEFAULT-hugegraph"}}' + BODY={{ dict "gremlin" "graph.traversal().V().limit(1).count()" "aliases" (dict "graph" (.Values.server.testGraphAlias | default "DEFAULT-hugegraph")) | toJson | squote }} {{- if .Values.server.auth.enabled }} AUTH_HEADER=$(printf 'admin:%s' "${PASSWORD}" | base64 | tr -d '\r\n') request() { diff --git a/helm/hugegraph/tests/server_discovery_test.yaml b/helm/hugegraph/tests/server_discovery_test.yaml index 63aca4db3d..92d5adfaaa 100644 --- a/helm/hugegraph/tests/server_discovery_test.yaml +++ b/helm/hugegraph/tests/server_discovery_test.yaml @@ -47,3 +47,75 @@ tests: asserts: - failedTemplate: errorPattern: "POD_IP" + + - it: preserves the image service identity by default + asserts: + - notExists: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")] + - notExists: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_GRAPHSPACE")] + + - it: selects a non-default service identity + set: + server.serviceId: analytics + server.graphSpace: team_a + asserts: + - equal: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value + value: analytics + - equal: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_GRAPHSPACE")].value + value: team_a + + - it: refuses an extraEnv that shadows the service ID + set: + server.extraEnv: + - name: HG_SERVER_SERVICE_ID + value: shadow + asserts: + - failedTemplate: + errorPattern: "HG_SERVER_SERVICE_ID" + + - it: refuses an extraEnv that shadows the graph space + set: + server.extraEnv: + - name: HG_SERVER_GRAPHSPACE + value: shadow + asserts: + - failedTemplate: + errorPattern: "HG_SERVER_GRAPHSPACE" + + - it: retains a service name containing spaces + set: + server.serviceId: ops team + server.graphSpace: team_a + asserts: + - equal: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value + value: ops team + + - it: refuses an extraEnv that shadows the legacy service ID + set: + server.extraEnv: + - name: SERVICE_ID + value: shadow + asserts: + - failedTemplate: + errorPattern: "SERVICE_ID" + + - it: refuses an extraEnv that shadows the legacy graph space + set: + server.extraEnv: + - name: GRAPH_SPACE + value: shadow + asserts: + - failedTemplate: + errorPattern: "GRAPH_SPACE" + + - it: escapes all dollars so Kubernetes retains a literal service ID + set: + server.serviceId: 'x-$(POD_IP)-$$(POD_IP)-$(UNKNOWN)-$name-$' + asserts: + - equal: + path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value + value: 'x-$$(POD_IP)-$$$$(POD_IP)-$$(UNKNOWN)-$$name-$$' diff --git a/helm/hugegraph/tests/test_hook_gremlin_test.yaml b/helm/hugegraph/tests/test_hook_gremlin_test.yaml index c35ef51de8..abe2e97606 100644 --- a/helm/hugegraph/tests/test_hook_gremlin_test.yaml +++ b/helm/hugegraph/tests/test_hook_gremlin_test.yaml @@ -60,6 +60,37 @@ tests: path: spec.containers[0].command[2] pattern: "FLOOR=\"3\"" + - it: checks the explicitly selected graph in a non-default graph space + template: tests/test-connection.yaml + set: + server.graphSpace: team_a + server.testGraphAlias: team_a-demo + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: '"graph":"team_a-demo"' + - notMatchRegex: + path: spec.containers[0].command[2] + pattern: "DEFAULT-hugegraph" + + - it: preserves the default binding when graphSpace changes without a selected graph + template: tests/test-connection.yaml + set: + server.graphSpace: team_a + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: '"graph":"DEFAULT-hugegraph"' + + - it: preserves a static graph binding with an uppercase local file name + template: tests/test-connection.yaml + set: + server.testGraphAlias: DEFAULT-Demo + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: '"graph":"DEFAULT-Demo"' + - it: floors the per-Pod count at the HPA minimum when HPA is enabled template: tests/test-connection.yaml set: diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 409a8bdda0..3d5248e3d8 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -827,6 +827,23 @@ "probes" ], "properties": { + "serviceId": { + "type": "string", + "minLength": 1, + "pattern": "^[ -~]+$", + "description": "Printable ASCII PD service name (bytes 0x20-0x7E), including spaces and backslashes. DEFAULT preserves the image config." + }, + "graphSpace": { + "type": "string", + "minLength": 1, + "pattern": "^(DEFAULT|[a-z][a-z0-9_]{0,47})$", + "description": "Existing PD graph space: DEFAULT or up to 48 lowercase letters, digits and underscores, starting with a letter. DEFAULT preserves the image config." + }, + "testGraphAlias": { + "type": "string", + "pattern": "^(DEFAULT|[a-z][a-z0-9_]{0,47})-[A-Za-z][A-Za-z0-9_]{0,47}$", + "description": "Existing Gremlin graph binding checked by helm test, as -. Choose the target graph explicitly; server.graphSpace does not create or infer this binding." + }, "replicas": { "type": "integer", "minimum": 1 diff --git a/helm/hugegraph/values.yaml b/helm/hugegraph/values.yaml index daa4df2f3c..e029446cf6 100644 --- a/helm/hugegraph/values.yaml +++ b/helm/hugegraph/values.yaml @@ -284,6 +284,11 @@ store: server: replicas: 3 + # DEFAULT preserves the image config; non-default values select PD service metadata. + # serviceId accepts printable ASCII, including spaces and backslashes. + # Create a non-default graph space before starting a Server assigned to it. + serviceId: DEFAULT + graphSpace: DEFAULT image: repository: hugegraph/server # TODO: leave tag empty to inherit Chart.appVersion after the 1.8.0 @@ -341,6 +346,9 @@ server: minAvailable: 2 # Optional Helm test image override; empty reuses the Server image. waitImage: "" + # Existing Gremlin graph binding checked on every Server Pod. Select the + # target graph explicitly; graphSpace does not create or infer this binding. + testGraphAlias: DEFAULT-hugegraph # Resources for the Helm test hook container. Bounded by default so the hook # cannot run unlimited on a restricted or quota-managed namespace. testResources: diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java index c7d821bf38..800ba6ebed 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java @@ -1124,7 +1124,7 @@ public void clearGraphSpace(String name) { // Clear all services for (String key : this.services.keySet()) { if (key.startsWith(name + DELIMITER)) { - String[] parts = key.split(DELIMITER); + String[] parts = key.split(DELIMITER, 2); this.dropService(parts[0], parts[1]); } } @@ -2173,7 +2173,7 @@ public Set getServiceUrls(String graphSpace, String service, PdRegisterType registerType) { Map configs = new HashMap<>(); if (StringUtils.isNotEmpty(graphSpace)) { - configs.put(PdRegisterLabel.REGISTER_TYPE.name(), graphSpace); + configs.put(PdRegisterLabel.GRAPHSPACE.name(), graphSpace); } if (StringUtils.isNotEmpty(service)) { configs.put(PdRegisterLabel.SERVICE_NAME.name(), service); diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/managers/ServiceMetaManager.java b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/managers/ServiceMetaManager.java index 3c03be3433..14a453aef8 100644 --- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/managers/ServiceMetaManager.java +++ b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/meta/managers/ServiceMetaManager.java @@ -50,12 +50,11 @@ private static String serviceName(String graphSpace, String name) { public Map serviceConfigs(String graphSpace) { Map serviceMap = new HashMap<>(); - Map keyValues = this.metaDriver.scanWithPrefix( - this.serviceConfPrefix(graphSpace)); + String prefix = this.serviceConfPrefix(graphSpace); + Map keyValues = this.metaDriver.scanWithPrefix(prefix); for (Map.Entry entry : keyValues.entrySet()) { String key = entry.getKey(); - String[] parts = key.split(META_PATH_DELIMITER); - serviceMap.put(parts[parts.length - 1], + serviceMap.put(key.substring(prefix.length()), JsonUtil.fromJson(entry.getValue(), Service.class)); } return serviceMap; diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index 1cfa7915a6..38336599b7 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -72,10 +72,17 @@ chmod +x "${TEST_HOME}/bin/"*.sh if [[ -n "${HG_CONFIG_TEST_CLASSPATH:-}" ]]; then javac -cp "${HG_CONFIG_TEST_CLASSPATH}" -d "${TEST_HOME}/docker" \ "${SCRIPT_DIR}/../../hugegraph-core/src/main/java/org/apache/hugegraph/config/AuthOptions.java" \ - "${SCRIPT_DIR}/test/AuthConfigReadback.java" + "${SCRIPT_DIR}/test/AuthConfigReadback.java" \ + "${SCRIPT_DIR}/test/ConfigReadback.java" else - echo "SKIP: native auth readback (HG_CONFIG_TEST_CLASSPATH is unset)" + echo "SKIP: native auth and service readback (HG_CONFIG_TEST_CLASSPATH is unset)" fi +assert_service_readback() { + if [[ -n "${HG_CONFIG_TEST_CLASSPATH:-}" ]]; then + java -cp "${TEST_HOME}/docker:${HG_CONFIG_TEST_CLASSPATH}" ConfigReadback \ + "${TEST_HOME}/conf/rest-server.properties" "$1" + fi +} assert_auth_readback() { if [[ -n "${HG_CONFIG_TEST_CLASSPATH:-}" ]]; then local secret_base64 admin_base64 @@ -353,6 +360,116 @@ done ) assert_start_timeout 120 +# Identity mappings apply on every start and leave mounted config alone when unset. +identity_conf="${TEST_HOME}/conf/rest-server.properties" +# Earlier secret-escaping fixtures leave escaped trailing whitespace in this file. +# Start identity cases with their own parseable configuration. +printf 'restserver.url=http://127.0.0.1:8080\nserver.service_id=mounted-service\nserver.graphspace=mounted_space\n' \ + > "${identity_conf}" +(cd "${TEST_HOME}"; bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=mounted-service' "${identity_conf}" +grep -qx 'server.graphspace=mounted_space' "${identity_conf}" +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID=DEFAULT HG_SERVER_GRAPHSPACE=DEFAULT \ + bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=DEFAULT' "${identity_conf}" +grep -qx 'server.graphspace=DEFAULT' "${identity_conf}" +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID=service-a HG_SERVER_GRAPHSPACE=space_a bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=service-a' "${identity_conf}" +grep -qx 'server.graphspace=space_a' "${identity_conf}" +(cd "${TEST_HOME}"; SERVICE_ID=legacy-service GRAPH_SPACE=legacy_space bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=legacy-service' "${identity_conf}" +grep -qx 'server.graphspace=legacy_space' "${identity_conf}" +(cd "${TEST_HOME}"; SERVICE_ID=legacy-service GRAPH_SPACE=legacy_space \ + HG_SERVER_SERVICE_ID=service-b HG_SERVER_GRAPHSPACE=space_b bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=service-b' "${identity_conf}" +grep -qx 'server.graphspace=space_b' "${identity_conf}" +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID= HG_SERVER_GRAPHSPACE= bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=service-b' "${identity_conf}" +grep -qx 'server.graphspace=space_b' "${identity_conf}" + +# Empty new variables still allow the legacy driver values to apply. +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID= HG_SERVER_GRAPHSPACE= \ + SERVICE_ID=legacy-empty GRAPH_SPACE=legacy_empty bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=legacy-empty' "${identity_conf}" +grep -qx 'server.graphspace=legacy_empty' "${identity_conf}" + +# Service permits spaces; Unicode escapes round-trip through the Server parser. +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID='ops team' bash ./docker-entrypoint.sh) +grep -Fxq 'server.service_id=ops\u0020team' "${identity_conf}" +assert_service_readback 'ops team' +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID=' ops team ' bash ./docker-entrypoint.sh) +grep -Fxq 'server.service_id=\u0020ops\u0020team\u0020' "${identity_conf}" +assert_service_readback ' ops team ' +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID='ops\ team' bash ./docker-entrypoint.sh) +grep -Fxq 'server.service_id=ops\\\u0020team' "${identity_conf}" +assert_service_readback 'ops\ team' + +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID=' ops\ team ' bash ./docker-entrypoint.sh) +assert_service_readback ' ops\ team ' +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID='ops\u0020team' bash ./docker-entrypoint.sh) +assert_service_readback 'ops\u0020team' +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID='x-$(POD_IP)-$$(POD_IP)-$name-$' bash ./docker-entrypoint.sh) +assert_service_readback 'x-$(POD_IP)-$$(POD_IP)-$name-$' + +# Unsupported effective identity input must fail before changing mounted config. +cp "${identity_conf}" "${identity_conf}.before-invalid-identity" +for identity_name in HG_SERVER_SERVICE_ID HG_SERVER_GRAPHSPACE SERVICE_ID GRAPH_SPACE; do + for identity_value in 'café' '😀' $'ops\tteam' $'ops\nteam'; do + if (cd "${TEST_HOME}"; env "${identity_name}=${identity_value}" \ + bash ./docker-entrypoint.sh > ./docker/invalid-identity.log 2>&1); then + echo "unsupported ${identity_name} unexpectedly succeeded" >&2 + exit 1 + fi + grep -q 'must be printable ASCII' "${TEST_HOME}/docker/invalid-identity.log" + cmp "${identity_conf}.before-invalid-identity" "${identity_conf}" + done +done +# Ignored legacy input does not override a supported non-empty new value. +(cd "${TEST_HOME}"; HG_SERVER_SERVICE_ID=analytics SERVICE_ID='café' \ + HG_SERVER_GRAPHSPACE=team_a GRAPH_SPACE='😀' bash ./docker-entrypoint.sh) +grep -qx 'server.service_id=analytics' "${identity_conf}" +grep -qx 'server.graphspace=team_a' "${identity_conf}" + +# Apply the existing graph-space naming rule to effective new and legacy input. +graph_space_max=$(printf 'a%.0s' {1..48}) +cp "${identity_conf}" "${identity_conf}.before-invalid-graphspace" +for graphspace_env in HG_SERVER_GRAPHSPACE GRAPH_SPACE; do + for graphspace in TeamA 1team team-a "${graph_space_max}a"; do + if (cd "${TEST_HOME}"; env "${graphspace_env}=${graphspace}" \ + bash ./docker-entrypoint.sh > ./docker/invalid-graphspace.log 2>&1); then + echo "invalid ${graphspace_env} unexpectedly succeeded" >&2 + exit 1 + fi + grep -q 'HG_SERVER_GRAPHSPACE must be DEFAULT' "${TEST_HOME}/docker/invalid-graphspace.log" + cmp "${identity_conf}.before-invalid-graphspace" "${identity_conf}" + done + for graphspace in DEFAULT team_a "${graph_space_max}"; do + (cd "${TEST_HOME}"; env "${graphspace_env}=${graphspace}" bash ./docker-entrypoint.sh) + grep -Fxq "server.graphspace=${graphspace}" "${identity_conf}" + done + cp "${identity_conf}" "${identity_conf}.before-invalid-graphspace" +done +(cd "${TEST_HOME}"; HG_SERVER_GRAPHSPACE=team_a GRAPH_SPACE=TeamA bash ./docker-entrypoint.sh) +grep -qx 'server.graphspace=team_a' "${identity_conf}" + +# Some installed locales make [a-z] include uppercase letters; validation owns its locale. +for identity_locale in sk_SK.ISO8859-2 cs_CZ.ISO8859-2; do + case "$(LC_ALL="${identity_locale}" locale charmap 2>/dev/null)" in + ISO8859-2 | ISO-8859-2) ;; + *) continue ;; + esac + cp "${identity_conf}" "${identity_conf}.before-invalid-locale" + for graphspace_env in HG_SERVER_GRAPHSPACE GRAPH_SPACE; do + if (cd "${TEST_HOME}"; env LC_ALL="${identity_locale}" "${graphspace_env}=TeamA" \ + bash ./docker-entrypoint.sh > ./docker/invalid-graphspace-locale.log 2>&1); then + echo "invalid ${graphspace_env} under ${identity_locale} unexpectedly succeeded" >&2 + exit 1 + fi + grep -q 'HG_SERVER_GRAPHSPACE must be DEFAULT' "${TEST_HOME}/docker/invalid-graphspace-locale.log" + cmp "${identity_conf}.before-invalid-locale" "${identity_conf}" + done +done + # Signing keys are opaque strings: both files must load exactly what the caller supplied. fake_key='FAKE_SIGNING_KEY_0123456789abcdef' control_soh=$(printf '\001') diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 5b804e5ba8..1f32dac7d5 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -109,6 +109,28 @@ migrate_env() { migrate_env "BACKEND" "HG_SERVER_BACKEND" migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" +migrate_env "SERVICE_ID" "HG_SERVER_SERVICE_ID" +migrate_env "GRAPH_SPACE" "HG_SERVER_GRAPHSPACE" + +# The Server reads these properties as ISO-8859-1. Reject unsupported input +# before writing UTF-8 bytes that would select a different service identity. +for identity_name in HG_SERVER_SERVICE_ID HG_SERVER_GRAPHSPACE; do + if [[ -n "${!identity_name:-}" ]] && + (( $(printf '%s' "${!identity_name}" | LC_ALL=C tr -d '\040-\176' | wc -c) > 0 )); then + log "ERROR: ${identity_name} must be printable ASCII (bytes 0x20-0x7E)" + exit 1 + fi +done + +# Match the Server API graph-space naming contract after legacy migration. +if [[ -n "${HG_SERVER_GRAPHSPACE:-}" ]] && ! ( + export LC_ALL=C + [[ "${HG_SERVER_GRAPHSPACE}" =~ ^(DEFAULT|[a-z][a-z0-9_]{0,47})$ ]] +); then + log "ERROR: HG_SERVER_GRAPHSPACE must be DEFAULT or a name starting with" \ + "a lowercase letter, followed by lowercase letters, digits or underscores (1-48 characters)" + exit 1 +fi if [[ -n "${HG_SERVER_AUTH_TOKEN_SECRET:-}" ]]; then if (( $(printf '%s' "${HG_SERVER_AUTH_TOKEN_SECRET}" | LC_ALL=C wc -c) < 32 )); then @@ -187,6 +209,14 @@ fi set_prop "pd.peers" "${HG_SERVER_PD_PEERS}" "${REST_SERVER_CONF}" [[ -n "${HG_SERVER_CLUSTER:-}" ]] && \ set_prop "cluster" "${HG_SERVER_CLUSTER}" "${REST_SERVER_CONF}" +if [[ -n "${HG_SERVER_SERVICE_ID:-}" ]]; then + # Service names can contain spaces. Commons Configuration keeps a backslash + # before a literal space, but decodes Unicode escapes, including at the edges. + service_id_encoded=$(encode_prop_value "${HG_SERVER_SERVICE_ID}" | sed 's/\\ /\\u0020/g') + set_prop_encoded "server.service_id" "${service_id_encoded}" "${REST_SERVER_CONF}" +fi +[[ -n "${HG_SERVER_GRAPHSPACE:-}" ]] && \ + set_prop "server.graphspace" "${HG_SERVER_GRAPHSPACE}" "${REST_SERVER_CONF}" [[ -n "${HG_SERVER_REST_URL:-}" ]] && set_prop "restserver.url" \ "${HG_SERVER_REST_URL}" "${REST_SERVER_CONF}" [[ -n "${HG_SERVER_MIN_FREE_MEMORY:-}" ]] && set_prop "restserver.min_free_memory" \ diff --git a/hugegraph-server/hugegraph-dist/docker/test/ConfigReadback.java b/hugegraph-server/hugegraph-dist/docker/test/ConfigReadback.java new file mode 100644 index 0000000000..6c6b016bfc --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/test/ConfigReadback.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import org.apache.hugegraph.config.HugeConfig; + +public final class ConfigReadback { + + public static void main(String[] args) { + HugeConfig config = new HugeConfig(args[0]); + Object actual = config.getProperty("server.service_id"); + if (!args[1].equals(actual)) { + throw new AssertionError("Expected service ID [" + args[1] + "] but loaded [" + actual + "]"); + } + System.out.println("PASS: HugeConfig service ID readback [" + actual + "]"); + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 2ab8d4e8fa..81c7666814 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -77,6 +77,7 @@ import org.apache.hugegraph.unit.core.ExceptionTest; import org.apache.hugegraph.unit.core.GraphManagerAdminInitTest; import org.apache.hugegraph.unit.core.GraphManagerConfigTest; +import org.apache.hugegraph.unit.core.GraphManagerServiceNamesTest; import org.apache.hugegraph.unit.core.GroovyScriptEngineCompatibilityTest; import org.apache.hugegraph.unit.core.HugeFeaturesTest; import org.apache.hugegraph.unit.core.HstoreSessionsTest; @@ -220,6 +221,7 @@ ExceptionTest.class, GraphManagerAdminInitTest.class, GraphManagerConfigTest.class, + GraphManagerServiceNamesTest.class, HstoreSessionsTest.class, HstoreTableRoutingTest.class, BackendStoreInfoTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerServiceNamesTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerServiceNamesTest.java new file mode 100644 index 0000000000..242e769724 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerServiceNamesTest.java @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit.core; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.core.GraphManager; +import org.apache.hugegraph.event.EventHub; +import org.apache.hugegraph.meta.MetaDriver; +import org.apache.hugegraph.meta.MetaManager; +import org.apache.hugegraph.meta.lock.LockResult; +import org.apache.hugegraph.meta.managers.ServiceMetaManager; +import org.apache.hugegraph.pd.client.DiscoveryClientImpl; +import org.apache.hugegraph.pd.grpc.discovery.NodeInfo; +import org.apache.hugegraph.pd.grpc.discovery.NodeInfos; +import org.apache.hugegraph.pd.grpc.discovery.Query; +import org.apache.hugegraph.space.GraphSpace; +import org.apache.hugegraph.space.Service; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.testutil.Whitebox; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +public class GraphManagerServiceNamesTest { + + @Test + public void testServiceUrlsPreservesRawIdentityAndRegisterType() throws Exception { + DiscoveryClientImpl client = Mockito.mock(DiscoveryClientImpl.class); + Mockito.when(client.getNodeInfos(Mockito.any(Query.class))) + .thenReturn(NodeInfos.newBuilder() + .addInfo(NodeInfo.newBuilder().setAddress("http://server:8080")) + .build()); + GraphManager manager = new GraphManager( + new HugeConfig(new PropertiesConfiguration()), + new EventHub("service-query-test")); + try { + setField(manager, "pdClient", client); + Class registerType = Class.forName(GraphManager.class.getName() + "$PdRegisterType"); + Object nodePort = Whitebox.getInternalState(registerType, "NODE_PORT"); + Set urls = Whitebox.invoke(GraphManager.class, + new Class[]{String.class, String.class, registerType}, + "getServiceUrls", manager, "team_a", "team/Analytics-prod", nodePort); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + Mockito.verify(client).getNodeInfos(query.capture()); + Assert.assertEquals(Map.of("GRAPHSPACE", "team_a", + "SERVICE_NAME", "team/Analytics-prod", + "REGISTER_TYPE", "NODE_PORT"), query.getValue().getLabelsMap()); + Assert.assertEquals(Set.of("http://server:8080"), urls); + } finally { + manager.close(); + } + } + + @Test + public void testReloadPreservesCompleteServiceNames() { + MetaDriver driver = storageDriver(); + ServiceMetaManager first = new ServiceMetaManager(driver, "cluster"); + for (String name : new String[]{"analytics", "team/analytics", + "analytics-prod", "team/analytics-prod"}) { + first.addServiceConfig("team_a", service(name)); + } + first.addServiceConfig("other", service("analytics")); + + ServiceMetaManager reloaded = new ServiceMetaManager(driver, "cluster"); + Map services = reloaded.serviceConfigs("team_a"); + Assert.assertEquals(4, services.size()); + for (String name : new String[]{"analytics", "team/analytics", + "analytics-prod", "team/analytics-prod"}) { + Assert.assertEquals(name, services.get(name).name()); + Assert.assertEquals(name, reloaded.service("team_a", name).name()); + } + Assert.assertEquals(1, reloaded.serviceConfigs("other").size()); + + reloaded.removeServiceConfig("team_a", "team/analytics"); + Assert.assertNull(reloaded.service("team_a", "team/analytics")); + Assert.assertNotNull(reloaded.service("team_a", "analytics")); + Assert.assertEquals(3, reloaded.serviceConfigs("team_a").size()); + } + + @Test + public void testClearSpaceDropsFullServiceNames() throws Exception { + MetaDriver driver = storageDriver(); + Constructor constructor = MetaManager.class.getDeclaredConstructor(); + constructor.setAccessible(true); + MetaManager metadata = constructor.newInstance(); + setField(metadata, "metaDriver", driver); + setField(metadata, "cluster", "cluster"); + Whitebox.invoke(MetaManager.class, new Class[]{String.class}, + "initManagers", metadata, "cluster"); + GraphManager manager = new GraphManager( + new HugeConfig(new PropertiesConfiguration()), + new EventHub("service-name-test")); + try { + setField(manager, "metaManager", metadata); + // Exercise the metadata-backed clear path without bootstrapping + // a PD/HStore cluster; the real managers use a storage-only seam. + setField(manager, "PDExist", true); + Map spaces = Whitebox.getInternalState(manager, "graphSpaces"); + spaces.put("team_a", new GraphSpace("team_a")); + spaces.put("other", new GraphSpace("other")); + Map cache = Whitebox.getInternalState(manager, "services"); + for (String name : new String[]{"analytics", "analytics-prod", + "team/analytics-prod"}) { + Service service = service(name); + metadata.addServiceConfig("team_a", service); + String key = Whitebox.invoke(GraphManager.class, + new Class[]{String.class, String.class}, "serviceName", + null, "team_a", name); + cache.put(key, service); + } + metadata.addServiceConfig("other", service("analytics")); + cache.put("other-analytics", service("analytics")); + + manager.clearGraphSpace("team_a"); + + Assert.assertTrue(metadata.serviceConfigs("team_a").isEmpty()); + Assert.assertEquals(1, metadata.serviceConfigs("other").size()); + Assert.assertEquals(1, cache.size()); + Assert.assertTrue(cache.containsKey("other-analytics")); + } finally { + manager.close(); + } + } + + private static Service service(String name) { + return new Service(name, "admin", Service.ServiceType.OLTP, + Service.DeploymentType.MANUAL); + } + + private static void setField(Object target, String name, Object value) + throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static MetaDriver storageDriver() { + Map storage = new LinkedHashMap<>(); + MetaDriver driver = Mockito.mock(MetaDriver.class); + Mockito.doAnswer(call -> { + storage.put(call.getArgument(0), call.getArgument(1)); + return null; + }).when(driver).put(Mockito.anyString(), Mockito.anyString()); + Mockito.when(driver.get(Mockito.anyString())) + .thenAnswer(call -> storage.get(call.getArgument(0))); + Mockito.doAnswer(call -> { + storage.remove(call.getArgument(0)); + return null; + }).when(driver).delete(Mockito.anyString()); + Mockito.doAnswer(call -> { + storage.keySet().removeIf(key -> key.startsWith(call.getArgument(0))); + return null; + }).when(driver).deleteWithPrefix(Mockito.anyString()); + Mockito.when(driver.scanWithPrefix(Mockito.anyString())).thenAnswer(call -> + storage.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(call.getArgument(0))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + Mockito.when(driver.tryLock(Mockito.anyString(), Mockito.anyLong(), Mockito.anyLong())) + .thenAnswer(call -> { + LockResult lock = new LockResult(); + lock.lockSuccess(true); + return lock; + }); + return driver; + } +}