From 392faa945ec7c5e37acfb70575c983ad98486866 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 15 Oct 2026 04:26:28 +0800 Subject: [PATCH 1/4] fix(helm): detect invalid index state - inspect associated indexes by ID on every Server Pod - support explicit business assertions without default data writes - verify query behavior and shell quoting without services - document coverage and custom-script credentials --- .github/workflows/helm-chart-ci.yml | 3 + helm/hugegraph/README.md | 9 +- helm/hugegraph/docs/operations.md | 37 ++++ .../templates/tests/test-connection.yaml | 50 +++-- .../tests/test_hook_gremlin_test.yaml | 31 ++++ helm/hugegraph/values.schema.json | 4 + helm/hugegraph/values.yaml | 4 + helm/test_helm_hook.py | 173 ++++++++++++++++++ helm/testdata/test_index_check.groovy | 57 ++++++ 9 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 helm/test_helm_hook.py create mode 100644 helm/testdata/test_index_check.groovy diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index a24e6a242d..cb08f096d1 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -328,6 +328,9 @@ jobs: expect "$(printf 'review\tsecret')" 'must be printable ASCII' expect 'café' 'must be printable ASCII' + - name: helm test query assertions and shell quoting + run: python3 helm/test_helm_hook.py + - name: helm test hook behaviour shell: bash run: | diff --git a/helm/hugegraph/README.md b/helm/hugegraph/README.md index 5284603071..d62054ac63 100644 --- a/helm/hugegraph/README.md +++ b/helm/hugegraph/README.md @@ -189,9 +189,12 @@ an existing account; follow [credential rotation](docs/operations.md#credentials The chart checks persisted Raft identities and storage/membership changes during upgrades; those changes require a coordinated procedure. -`helm --wait` checks readiness; `helm test` checks graph-bound Gremlin on each -Server Pod. Also verify a fresh Hubble login and graph query. Templates and -linting alone do not prove live volume, network or application behavior. +`helm --wait` checks readiness. `helm test` checks graph binding and visible +associated indexes for `INVALID` on each Server Pod. It does not prove index +query results; add [business assertions](docs/operations.md#business-query-assertions) +with `--set-file server.testGremlin=assertions.groovy`. Also verify a fresh Hubble +login and graph query. Templates and linting alone do not prove live volume, +network or application behavior. Website guides: [Helm deployment](https://hugegraph.apache.org/docs/quickstart/hugegraph/hugegraph-helm/) and [Kubernetes operations](https://hugegraph.apache.org/docs/quickstart/hugegraph/hugegraph-helm-operations/). diff --git a/helm/hugegraph/docs/operations.md b/helm/hugegraph/docs/operations.md index cb7f2e8048..d61ec12b62 100644 --- a/helm/hugegraph/docs/operations.md +++ b/helm/hugegraph/docs/operations.md @@ -283,6 +283,43 @@ and `q=1.` are equivalent to `q=0` and `q=1`. Explicit gzip exclusion overrides Older images can still force gzip; use a Server image built from current source. Updating chart values alone does not change an existing image's behavior. +## Business query assertions + +The default `helm test` checks `DEFAULT-hugegraph` binding and rejects `INVALID` indexes +associated with the vertex/edge labels visible on each Pod. It reads those +indexes by ID, using the same local cache path as queries; it does not refresh +the full index cache. This does not detect missing/stale label associations, +orphaned indexes or incorrect index data, and does not prove range-query results. +Normal index creation/rebuild states are not rejected by this status check. + +For a graph with existing test data, provide a read-only Gremlin script that +asserts the expected result. For example, with 40 existing `person` vertices +(ages 0–39), save this as `assertions.groovy`, adapting names/results to your data: + +```groovy +def n = graph.traversal().V().hasLabel('person') + .has('age', org.apache.tinkerpop.gremlin.process.traversal.P.gte(20)) + .count().next() +assert n == 20 : "Expected 20 adults, got ${n}" +``` + +```bash +helm upgrade hugegraph ./helm/hugegraph -n hugegraph --reuse-values \ + --set-file server.testGremlin=assertions.groovy +helm test hugegraph -n hugegraph --logs --timeout 5m +``` + +The hook runs both checks on every Ready Server Pod in each attempt. A failing +request or assertion reports its Pod IP, check stage and response, and fails +the hook if no complete attempt succeeds before its deadline. Use +`kubectl -n hugegraph get pods -o wide` to map IPs to Pod names. Returning a value +or `false` alone is not a failure: use `assert` or throw an exception. Without a +script, the hook explicitly reports business assertions as skipped. The hook +creates no schema/data by default. Custom scripts use the hook's Server credential +(`admin` when authentication is enabled); read-only execution is not enforced, +so supply trusted, read-only assertions. The hook does not guarantee the first request +after startup succeeds; its retries allow transient elections to settle. + ## Troubleshooting | Symptom | First checks | diff --git a/helm/hugegraph/templates/tests/test-connection.yaml b/helm/hugegraph/templates/tests/test-connection.yaml index b683b0d4c3..9ae27a6531 100644 --- a/helm/hugegraph/templates/tests/test-connection.yaml +++ b/helm/hugegraph/templates/tests/test-connection.yaml @@ -16,6 +16,17 @@ # {{ include "hugegraph.resolveValues" . }} +{{- $smoke := `def ids = [] as Set; +(graph.schema().getVertexLabels() + graph.schema().getEdgeLabels()).each { + ids.addAll(it.indexLabels()) +}; +ids.each { + def index = graph.indexLabel(it); + if (index.status().name() == 'INVALID') { + throw new IllegalStateException('INVALID index: ' + index.name()) + } +}; +graph.traversal().V().limit(1).count()` }} # Official Helm chart test hook — only runs with: helm test # Not an install-time hook. Safe with --wait (no init Job in no-init design). apiVersion: v1 @@ -78,7 +89,12 @@ 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" $smoke "aliases" (dict "graph" "DEFAULT-hugegraph") | toJson | replace "'" "'\"'\"'" }}' + {{- if .Values.server.testGremlin }} + BUSINESS_BODY='{{ dict "gremlin" .Values.server.testGremlin "aliases" (dict "graph" "DEFAULT-hugegraph") | toJson | replace "'" "'\"'\"'" }}' + {{- else }} + BUSINESS_BODY="" + {{- end }} {{- if .Values.server.auth.enabled }} AUTH_HEADER=$(printf 'admin:%s' "${PASSWORD}" | base64 | tr -d '\r\n') request() { @@ -88,7 +104,7 @@ spec: gremlin() { printf 'header = "Authorization: Basic %s"\n' "${AUTH_HEADER}" | curl --config - -sS --compressed --connect-timeout 3 --max-time 20 \ - -H 'Content-Type: application/json' -X POST -d "${BODY}" \ + -H 'Content-Type: application/json' -X POST -d "$2" \ -w '\n%{http_code}\n' "$1" 2>&1 } {{- else }} @@ -97,10 +113,21 @@ spec: } gremlin() { curl -sS --compressed --connect-timeout 3 --max-time 20 \ - -H 'Content-Type: application/json' -X POST -d "${BODY}" \ + -H 'Content-Type: application/json' -X POST -d "$2" \ -w '\n%{http_code}\n' "$1" 2>&1 } {{- end }} + check_gremlin() { + OUT=$(gremlin "$1" "$3") || true + CODE=$(printf '%s\n' "${OUT}" | grep -E '^[0-9][0-9][0-9]$' | tail -1) || CODE="" + if [ "${CODE}" = "200" ]; then + echo "helm test: $2 OK on $1" + return 0 + fi + printf 'helm test: %s failed on %s code=%s body: %.400s\n' \ + "$2" "$1" "${CODE:-no-response}" "${OUT}" + return 1 + } echo "helm test: GET http://${SVC}:${PORT}/versions" request "http://${SVC}:${PORT}/versions" echo @@ -115,6 +142,9 @@ spec: # Pods, retrying until the deadline so a transient PD election is # not misread as a broken Pod. echo "helm test: Gremlin on every Server Pod via ${HEADLESS} (floor ${FLOOR})" + if [ -z "${BUSINESS_BODY}" ]; then + echo "helm test: business assertions skipped (server.testGremlin is empty)" + fi START=$(date +%s) while :; do # The headless Service answers with every Ready Pod's addresses, @@ -135,18 +165,16 @@ spec: FAILED="" for IP in ${IPS}; do case "${IP}" in *:*) TARGET="[${IP}]" ;; *) TARGET="${IP}" ;; esac - OUT=$(gremlin "http://${TARGET}:${PORT}/gremlin") || true - CODE=$(printf '%s\n' "${OUT}" | grep -E '^[0-9][0-9][0-9]$' | tail -1) || CODE="" - if [ "${CODE}" = "200" ]; then - echo "helm test: Gremlin OK on ${IP}" - else + URL="http://${TARGET}:${PORT}/gremlin" + if ! check_gremlin "${URL}" "Gremlin binding/index-state" "${BODY}"; then + FAILED="${FAILED}${IP}(${CODE:-no-response}) " + elif [ -n "${BUSINESS_BODY}" ] && + ! check_gremlin "${URL}" "business assertion" "${BUSINESS_BODY}"; then FAILED="${FAILED}${IP}(${CODE:-no-response}) " - printf 'helm test: Gremlin failed on %s code=%s body: %.400s\n' \ - "${IP}" "${CODE:-no-response}" "${OUT}" fi done if [ -z "${FAILED}" ] && [ "${COUNT}" -ge "${FLOOR}" ]; then - echo "helm test: OK (${COUNT} Server Pods, all Gremlin-bound)" + echo "helm test: OK (${COUNT} Server Pods, all configured checks passed)" exit 0 fi if [ $(( $(date +%s) - START )) -ge "${DEADLINE_S}" ]; then diff --git a/helm/hugegraph/tests/test_hook_gremlin_test.yaml b/helm/hugegraph/tests/test_hook_gremlin_test.yaml index c35ef51de8..b1ad5c9caa 100644 --- a/helm/hugegraph/tests/test_hook_gremlin_test.yaml +++ b/helm/hugegraph/tests/test_hook_gremlin_test.yaml @@ -71,6 +71,37 @@ tests: path: spec.containers[0].command[2] pattern: "FLOOR=\"5\"" + - it: checks linked indexes by ID without refreshing the full index cache + template: tests/test-connection.yaml + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: "getVertexLabels.*getEdgeLabels" + - matchRegex: + path: spec.containers[0].command[2] + pattern: "graph.indexLabel" + - matchRegex: + path: spec.containers[0].command[2] + pattern: "INVALID index" + - notMatchRegex: + path: spec.containers[0].command[2] + pattern: "getIndexLabels" + - matchRegex: + path: spec.containers[0].command[2] + pattern: "business assertions skipped" + + - it: sends optional business assertions on each Pod + template: tests/test-connection.yaml + set: + server.testGremlin: "assert graph.traversal().V().count().next() == 40" + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: "assert graph.traversal" + - matchRegex: + path: spec.containers[0].command[2] + pattern: "business assertion" + - it: sends no credentials when authentication is disabled template: tests/test-connection.yaml set: diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json index 409a8bdda0..3b80138bf5 100644 --- a/helm/hugegraph/values.schema.json +++ b/helm/hugegraph/values.schema.json @@ -1156,6 +1156,10 @@ "testResources": { "$ref": "#/definitions/resources" }, + "testGremlin": { + "type": "string", + "description": "Optional Gremlin assertions run with the hook's Server credential on each Pod. Supply trusted read-only scripts; read-only execution is not enforced." + }, "service": { "$ref": "#/definitions/service" }, diff --git a/helm/hugegraph/values.yaml b/helm/hugegraph/values.yaml index daa4df2f3c..97b5cdbd0e 100644 --- a/helm/hugegraph/values.yaml +++ b/helm/hugegraph/values.yaml @@ -341,6 +341,10 @@ server: minAvailable: 2 # Optional Helm test image override; empty reuses the Server image. waitImage: "" + # Optional Gremlin assertions, run with the hook's Server credential on every Pod. + # Supply trusted read-only scripts; read-only execution is not enforced. + # Use --set-file server.testGremlin=/path/to/assertions.groovy; assert expected results. + testGremlin: "" # 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/helm/test_helm_hook.py b/helm/test_helm_hook.py new file mode 100644 index 0000000000..2d549f0dfb --- /dev/null +++ b/helm/test_helm_hook.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# 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. + +"""Execute the rendered Helm hook with local command fixtures, without networking.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +CHART = ROOT / "helm" / "hugegraph" +MOCK = r''' +import json, os, pathlib, sys +command = pathlib.Path(sys.argv[0]).name +directory = pathlib.Path(os.environ["FIXTURE_DIR"]) +if command == "date": + counter = directory / "time" + value = int(counter.read_text()) + 100 if counter.exists() else 0 + counter.write_text(str(value)) + print(value) +elif command == "getent": + for address in os.environ["FIXTURE_IPS"].split(): + print(address, "STREAM server") +elif command == "curl": + args = sys.argv[1:] + config = sys.stdin.read() if "--config" in args else "" + url = args[-1] + body = json.loads(args[args.index("-d") + 1]) if "-d" in args else None + with (directory / "requests.jsonl").open("a") as output: + output.write(json.dumps({"url": url, "body": body, + "args": args, "config": config}) + "\n") + if body is None: + print("{}") + else: + case = os.environ.get("FIXTURE_CASE", "healthy") + bad_pod = "10.0.0.2" in url + business = body["gremlin"] == os.environ.get("FIXTURE_BUSINESS") + failure = bad_pod and (case in ("binding", "invalid") or + (case == "business" and business)) + if failure: + message = {"binding": "Could not rebind graph", "invalid": "INVALID index: by_age", + "business": "Assertion failed: expected index results"}[case] + print(json.dumps({"message": message})) + print("400" if case == "binding" else "500") + else: + print('{"result":{"data":[0]}}\n200') +''' + + +class HelmHookTest(unittest.TestCase): + def render(self, directory, **values): + configuration = directory / "values.yaml" + configuration.write_text(yaml.safe_dump(values)) + result = subprocess.run( + ["helm", "template", "fixture", str(CHART), "-f", str(configuration), + "--show-only", "templates/tests/test-connection.yaml"], + capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + return yaml.safe_load(result.stdout)["spec"]["containers"][0]["command"][2] + + def run_hook(self, case="healthy", addresses=None, **values): + temporary = tempfile.TemporaryDirectory(prefix="helm-hook-") + self.addCleanup(temporary.cleanup) + directory = Path(temporary.name) + script = self.render(directory, **values) + commands = directory / "bin" + commands.mkdir() + for name in ("curl", "date", "getent", "sleep"): + executable = commands / name + executable.write_text(f"#!{sys.executable}\n" + MOCK) + executable.chmod(0o755) + business = values.get("server", {}).get("testGremlin", "") + environment = dict(os.environ, PATH=f"{commands}:{os.environ['PATH']}", + FIXTURE_DIR=str(directory), FIXTURE_CASE=case, + FIXTURE_BUSINESS=business, PASSWORD="fixture-secret", + HG_TEST_DEADLINE_S="1", FIXTURE_IPS=addresses or + "10.0.0.1 10.0.0.2 10.0.0.3") + result = subprocess.run(["sh", "-c", script], env=environment, cwd=directory, + capture_output=True, text=True, timeout=15) + requests = [json.loads(line) for line in + (directory / "requests.jsonl").read_text().splitlines()] + return result, requests, directory + + def test_every_pod_is_checked_with_auth(self): + result, requests, _ = self.run_hook() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(3, sum(request["body"] is not None for request in requests)) + self.assertTrue(all("Authorization: Basic " in request["config"] for request in requests)) + self.assertIn("business assertions skipped", result.stdout) + + def test_failed_binding_or_cached_invalid_index_reports_pod_and_stage(self): + for case, message in (("binding", "Could not rebind graph"), + ("invalid", "INVALID index: by_age")): + with self.subTest(case=case): + result, requests, _ = self.run_hook(case) + self.assertNotEqual(0, result.returncode) + self.assertIn("Gremlin binding/index-state failed on http://10.0.0.2:8080/gremlin", + result.stdout) + self.assertIn(message, result.stdout) + self.assertEqual(3, sum(request["body"] is not None for request in requests)) + + def test_business_assertion_runs_on_every_pod_and_reports_failure(self): + business = "assert graph.traversal().V().count().next() == 7; true" + for case, expected in (("healthy", 0), ("business", 1)): + with self.subTest(case=case): + result, requests, _ = self.run_hook(case, server={"testGremlin": business}) + self.assertEqual(expected, result.returncode, result.stdout + result.stderr) + self.assertEqual(3, sum(request["body"] is not None and + request["body"]["gremlin"] == business for request in requests)) + if expected: + self.assertIn("business assertion failed on http://10.0.0.2:8080/gremlin", + result.stdout) + self.assertIn("Assertion failed: expected index results", result.stdout) + + def test_business_script_is_json_data_and_auth_can_be_disabled(self): + business = '''assert 'single' != "double" +// `touch injected` $(touch injected) ' " +true''' + result, requests, directory = self.run_hook( + server={"testGremlin": business, "auth": {"enabled": False}}, + hubble={"enabled": False}) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertFalse((directory / "injected").exists()) + self.assertTrue(all(not request["config"] and "--config" not in request["args"] + for request in requests)) + self.assertEqual(3, sum(request["body"] is not None and + request["body"]["gremlin"] == business for request in requests)) + + def test_dual_stack_does_not_count_pods_twice(self): + result, requests, _ = self.run_hook( + addresses="10.0.0.1 10.0.0.2 fd00::1 fd00::2") + self.assertNotEqual(0, result.returncode) + self.assertIn("pods=2 floor=3", result.stderr) + self.assertEqual(2, sum(request["body"] is not None for request in requests)) + + @unittest.skipUnless(os.environ.get("GROOVY_JAR"), + "GROOVY_JAR unset: rendered Groovy execution not verified") + def test_rendered_groovy_checks_cached_ids_without_refreshing_indexes(self): + _, requests, directory = self.run_hook() + payload = next(request["body"] for request in requests if request["body"]) + script = directory / "smoke.groovy" + script.write_text(payload["gremlin"]) + result = subprocess.run( + [shutil.which("java"), "-cp", os.environ["GROOVY_JAR"], "groovy.ui.GroovyMain", + str(ROOT / "helm" / "testdata" / "test_index_check.groovy"), str(script)], + capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("cached index checks passed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/helm/testdata/test_index_check.groovy b/helm/testdata/test_index_check.groovy new file mode 100644 index 0000000000..56daef33e2 --- /dev/null +++ b/helm/testdata/test_index_check.groovy @@ -0,0 +1,57 @@ +// 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. + +// Execute the rendered script, with stale cached ID 11 but healthy metadata. +class FixtureTraversal { + def V() { this } + def limit(count) { assert count == 1; this } + def count() { 0 } +} + +class FixtureGraph { + Map statuses + List lookups = [] + def schema() { + return [getVertexLabels: { -> statuses.containsKey(11) ? [[indexLabels: { -> [11] }]] : [] }, + getEdgeLabels: { -> statuses.containsKey(22) ? [[indexLabels: { -> [22] }]] : [] }, + getIndexLabels: { -> throw new AssertionError('metadata enumeration refreshes cache') }] + } + def indexLabel(id) { + assert id instanceof Integer : 'name lookup can refresh the cache' + lookups.add(id) + return [name: { -> "index_${id}" }, status: { -> [name: { -> statuses[id] }] }] + } + def traversal() { + return new FixtureTraversal() + } +} + +def source = new File(args[0]).text +[[11: 'CREATED', 22: 'CREATED'], [:], [11: 'CREATING']].each { statuses -> + def graph = new FixtureGraph(statuses: statuses) + assert new GroovyShell(new Binding(graph: graph)).evaluate(source) == 0 + assert graph.lookups as Set == statuses.keySet() +} +[11, 22].each { invalidId -> + def graph = new FixtureGraph(statuses: [11: 'CREATED', 22: 'CREATED']) + graph.statuses[invalidId] = 'INVALID' + try { + new GroovyShell(new Binding(graph: graph)).evaluate(source) + throw new AssertionError('stale cached INVALID index must fail') + } catch (IllegalStateException expected) { + assert expected.message == "INVALID index: index_${invalidId}" + } +} +println 'cached index checks passed' From 3290b466bff9b3413185b4373401810acda7edb5 Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 15 Oct 2026 12:10:13 +0800 Subject: [PATCH 2/4] fix(helm): reject unready cached indexes - require CREATED indexes during the per-Pod test sweep - execute rendered queries with the Server Groovy version in CI - cover all unready states without refreshing index caches - document running acceptance checks after index jobs finish --- .github/workflows/helm-chart-ci.yml | 12 +++++++++- helm/hugegraph/README.md | 5 ++-- helm/hugegraph/docs/operations.md | 6 +++-- .../templates/tests/test-connection.yaml | 5 ++-- .../tests/test_hook_gremlin_test.yaml | 5 +++- helm/test_helm_hook.py | 17 +++++++++----- helm/testdata/test_index_check.groovy | 23 +++++++++++-------- 7 files changed, 49 insertions(+), 24 deletions(-) diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index cb08f096d1..84dfa2df88 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -329,7 +329,17 @@ jobs: expect 'café' 'must be printable ASCII' - name: helm test query assertions and shell quoting - run: python3 helm/test_helm_hook.py + run: | + # Execute the rendered query with the same Groovy version as Server. + GROOVY_VERSION=$(python3 - <<'PY_VERSION' + import xml.etree.ElementTree as ET + pom = ET.parse('pom.xml') + print(pom.find('{*}properties/{*}groovy.version').text) + PY_VERSION + ) + curl -fsSLo /tmp/groovy.jar \ + "https://repo.maven.apache.org/maven2/org/apache/groovy/groovy/${GROOVY_VERSION}/groovy-${GROOVY_VERSION}.jar" + GROOVY_JAR=/tmp/groovy.jar python3 helm/test_helm_hook.py - name: helm test hook behaviour shell: bash diff --git a/helm/hugegraph/README.md b/helm/hugegraph/README.md index d62054ac63..0e82313a6c 100644 --- a/helm/hugegraph/README.md +++ b/helm/hugegraph/README.md @@ -190,8 +190,9 @@ The chart checks persisted Raft identities and storage/membership changes during upgrades; those changes require a coordinated procedure. `helm --wait` checks readiness. `helm test` checks graph binding and visible -associated indexes for `INVALID` on each Server Pod. It does not prove index -query results; add [business assertions](docs/operations.md#business-query-assertions) +associated indexes for `CREATED` status on each Server Pod. Run it after index +creation/rebuild jobs finish. It does not prove index query results; add +[business assertions](docs/operations.md#business-query-assertions) with `--set-file server.testGremlin=assertions.groovy`. Also verify a fresh Hubble login and graph query. Templates and linting alone do not prove live volume, network or application behavior. diff --git a/helm/hugegraph/docs/operations.md b/helm/hugegraph/docs/operations.md index d61ec12b62..d5508590ae 100644 --- a/helm/hugegraph/docs/operations.md +++ b/helm/hugegraph/docs/operations.md @@ -285,12 +285,14 @@ Updating chart values alone does not change an existing image's behavior. ## Business query assertions -The default `helm test` checks `DEFAULT-hugegraph` binding and rejects `INVALID` indexes +The default `helm test` checks `DEFAULT-hugegraph` binding and requires `CREATED` indexes associated with the vertex/edge labels visible on each Pod. It reads those indexes by ID, using the same local cache path as queries; it does not refresh the full index cache. This does not detect missing/stale label associations, orphaned indexes or incorrect index data, and does not prove range-query results. -Normal index creation/rebuild states are not rejected by this status check. +Run it after index creation/rebuild jobs finish. Like indexed queries, the check +rejects every other status, including `CREATING`, `REBUILDING` and `INVALID`. +Its retry window allows transient index work to settle before the hook fails. For a graph with existing test data, provide a read-only Gremlin script that asserts the expected result. For example, with 40 existing `person` vertices diff --git a/helm/hugegraph/templates/tests/test-connection.yaml b/helm/hugegraph/templates/tests/test-connection.yaml index 9ae27a6531..bc83caf9cc 100644 --- a/helm/hugegraph/templates/tests/test-connection.yaml +++ b/helm/hugegraph/templates/tests/test-connection.yaml @@ -22,8 +22,9 @@ }; ids.each { def index = graph.indexLabel(it); - if (index.status().name() == 'INVALID') { - throw new IllegalStateException('INVALID index: ' + index.name()) + if (!index.status().ok()) { + throw new IllegalStateException('Index ' + index.name() + ' is ' + + index.status().name() + ' (CREATED expected)') } }; graph.traversal().V().limit(1).count()` }} diff --git a/helm/hugegraph/tests/test_hook_gremlin_test.yaml b/helm/hugegraph/tests/test_hook_gremlin_test.yaml index b1ad5c9caa..bd81d55b5c 100644 --- a/helm/hugegraph/tests/test_hook_gremlin_test.yaml +++ b/helm/hugegraph/tests/test_hook_gremlin_test.yaml @@ -82,7 +82,10 @@ tests: pattern: "graph.indexLabel" - matchRegex: path: spec.containers[0].command[2] - pattern: "INVALID index" + pattern: "index.status.*ok" + - matchRegex: + path: spec.containers[0].command[2] + pattern: "CREATED expected" - notMatchRegex: path: spec.containers[0].command[2] pattern: "getIndexLabels" diff --git a/helm/test_helm_hook.py b/helm/test_helm_hook.py index 2d549f0dfb..968a37c285 100644 --- a/helm/test_helm_hook.py +++ b/helm/test_helm_hook.py @@ -56,10 +56,13 @@ case = os.environ.get("FIXTURE_CASE", "healthy") bad_pod = "10.0.0.2" in url business = body["gremlin"] == os.environ.get("FIXTURE_BUSINESS") - failure = bad_pod and (case in ("binding", "invalid") or + failure = bad_pod and (case in ("binding", "invalid", "creating", "rebuilding") or (case == "business" and business)) if failure: - message = {"binding": "Could not rebind graph", "invalid": "INVALID index: by_age", + message = {"binding": "Could not rebind graph", + "invalid": "Index by_age is INVALID (CREATED expected)", + "creating": "Index by_age is CREATING (CREATED expected)", + "rebuilding": "Index by_age is REBUILDING (CREATED expected)", "business": "Assertion failed: expected index results"}[case] print(json.dumps({"message": message})) print("400" if case == "binding" else "500") @@ -109,9 +112,11 @@ def test_every_pod_is_checked_with_auth(self): self.assertTrue(all("Authorization: Basic " in request["config"] for request in requests)) self.assertIn("business assertions skipped", result.stdout) - def test_failed_binding_or_cached_invalid_index_reports_pod_and_stage(self): + def test_failed_binding_or_unready_cached_index_reports_pod_and_stage(self): for case, message in (("binding", "Could not rebind graph"), - ("invalid", "INVALID index: by_age")): + ("invalid", "Index by_age is INVALID (CREATED expected)"), + ("creating", "Index by_age is CREATING (CREATED expected)"), + ("rebuilding", "Index by_age is REBUILDING (CREATED expected)")): with self.subTest(case=case): result, requests, _ = self.run_hook(case) self.assertNotEqual(0, result.returncode) @@ -154,9 +159,9 @@ def test_dual_stack_does_not_count_pods_twice(self): self.assertIn("pods=2 floor=3", result.stderr) self.assertEqual(2, sum(request["body"] is not None for request in requests)) - @unittest.skipUnless(os.environ.get("GROOVY_JAR"), - "GROOVY_JAR unset: rendered Groovy execution not verified") def test_rendered_groovy_checks_cached_ids_without_refreshing_indexes(self): + self.assertTrue(os.environ.get("GROOVY_JAR"), + "Set GROOVY_JAR to the Server Groovy jar before running this suite") _, requests, directory = self.run_hook() payload = next(request["body"] for request in requests if request["body"]) script = directory / "smoke.groovy" diff --git a/helm/testdata/test_index_check.groovy b/helm/testdata/test_index_check.groovy index 56daef33e2..90257b7360 100644 --- a/helm/testdata/test_index_check.groovy +++ b/helm/testdata/test_index_check.groovy @@ -31,7 +31,8 @@ class FixtureGraph { def indexLabel(id) { assert id instanceof Integer : 'name lookup can refresh the cache' lookups.add(id) - return [name: { -> "index_${id}" }, status: { -> [name: { -> statuses[id] }] }] + return [name: { -> "index_${id}" }, + status: { -> [name: { -> statuses[id] }, ok: { -> statuses[id] == 'CREATED' }] }] } def traversal() { return new FixtureTraversal() @@ -39,19 +40,21 @@ class FixtureGraph { } def source = new File(args[0]).text -[[11: 'CREATED', 22: 'CREATED'], [:], [11: 'CREATING']].each { statuses -> +[[11: 'CREATED', 22: 'CREATED'], [:]].each { statuses -> def graph = new FixtureGraph(statuses: statuses) assert new GroovyShell(new Binding(graph: graph)).evaluate(source) == 0 assert graph.lookups as Set == statuses.keySet() } -[11, 22].each { invalidId -> - def graph = new FixtureGraph(statuses: [11: 'CREATED', 22: 'CREATED']) - graph.statuses[invalidId] = 'INVALID' - try { - new GroovyShell(new Binding(graph: graph)).evaluate(source) - throw new AssertionError('stale cached INVALID index must fail') - } catch (IllegalStateException expected) { - assert expected.message == "INVALID index: index_${invalidId}" +['CREATING', 'REBUILDING', 'DELETING', 'UNDELETED', 'INVALID', 'CLEARING'].each { status -> + [11, 22].each { indexId -> + def graph = new FixtureGraph(statuses: [11: 'CREATED', 22: 'CREATED']) + graph.statuses[indexId] = status + try { + new GroovyShell(new Binding(graph: graph)).evaluate(source) + throw new AssertionError('non-CREATED cached index must fail') + } catch (IllegalStateException expected) { + assert expected.message == "Index index_${indexId} is ${status} (CREATED expected)" + } } } println 'cached index checks passed' From 7b28f7b37b22274b8d54520e9e2e0cc7d0d8e6aa Mon Sep 17 00:00:00 2001 From: imbajin Date: Thu, 15 Oct 2026 20:37:13 +0800 Subject: [PATCH 3/4] docs(helm): clarify index check cache limits - require completed index jobs and stable schema during checks - explain persistent HStore cache state and recovery timing - remove the claim that retries settle cached index work --- helm/hugegraph/README.md | 5 ++++- helm/hugegraph/docs/operations.md | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/helm/hugegraph/README.md b/helm/hugegraph/README.md index 0e82313a6c..2dbcd59cda 100644 --- a/helm/hugegraph/README.md +++ b/helm/hugegraph/README.md @@ -191,7 +191,10 @@ upgrades; those changes require a coordinated procedure. `helm --wait` checks readiness. `helm test` checks graph binding and visible associated indexes for `CREATED` status on each Server Pod. Run it after index -creation/rebuild jobs finish. It does not prove index query results; add +creation/rebuild jobs finish, with no schema changes during the test. On HStore, +reading an unfinished index can cache its temporary status on other Pods; +retries do not refresh that state, and indexed queries may keep failing until +those Pods restart after the jobs finish. It does not prove index query results; add [business assertions](docs/operations.md#business-query-assertions) with `--set-file server.testGremlin=assertions.groovy`. Also verify a fresh Hubble login and graph query. Templates and linting alone do not prove live volume, diff --git a/helm/hugegraph/docs/operations.md b/helm/hugegraph/docs/operations.md index d5508590ae..cac81ffef6 100644 --- a/helm/hugegraph/docs/operations.md +++ b/helm/hugegraph/docs/operations.md @@ -290,9 +290,13 @@ associated with the vertex/edge labels visible on each Pod. It reads those indexes by ID, using the same local cache path as queries; it does not refresh the full index cache. This does not detect missing/stale label associations, orphaned indexes or incorrect index data, and does not prove range-query results. -Run it after index creation/rebuild jobs finish. Like indexed queries, the check -rejects every other status, including `CREATING`, `REBUILDING` and `INVALID`. -Its retry window allows transient index work to settle before the hook fails. +Run it after index creation/rebuild jobs finish, with no schema changes during +the test. Like indexed queries, the check rejects every other status, including +`CREATING`, `REBUILDING` and `INVALID`. On HStore, reading an unfinished index can +cache its temporary status on Pods other than the job runner. Later attempts +reuse that cache; the retry window does not repair it. Indexed queries on those +Pods may keep failing after the job succeeds; replace affected Server Pods only +after the jobs finish. This check does not fix cross-Server schema cache consistency. For a graph with existing test data, provide a read-only Gremlin script that asserts the expected result. For example, with 40 existing `person` vertices From c6272a66b09ab477d3849e2076308f6881004b3f Mon Sep 17 00:00:00 2001 From: imbajin Date: Fri, 16 Oct 2026 01:46:08 +0800 Subject: [PATCH 4/4] fix(helm): keep the legacy test graph alias - restore the default alias in both Gremlin request bodies - cover missing and null legacy values and preserve overrides - verify the downloaded Groovy artifact before CI execution --- .github/workflows/helm-chart-ci.yml | 6 ++-- .../templates/tests/test-connection.yaml | 4 +-- .../tests/test_hook_gremlin_test.yaml | 13 ++++++++ helm/test_helm_hook.py | 31 ++++++++++++++++--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml index e3181a6088..d60495fbf6 100644 --- a/.github/workflows/helm-chart-ci.yml +++ b/.github/workflows/helm-chart-ci.yml @@ -358,8 +358,10 @@ jobs: print(pom.find('{*}properties/{*}groovy.version').text) PY_VERSION ) - curl -fsSLo /tmp/groovy.jar \ - "https://repo.maven.apache.org/maven2/org/apache/groovy/groovy/${GROOVY_VERSION}/groovy-${GROOVY_VERSION}.jar" + GROOVY_URL="https://repo.maven.apache.org/maven2/org/apache/groovy/groovy/${GROOVY_VERSION}/groovy-${GROOVY_VERSION}.jar" + curl -fsSLo /tmp/groovy.jar "${GROOVY_URL}" + curl -fsSLo /tmp/groovy.jar.sha1 "${GROOVY_URL}.sha1" + printf '%s /tmp/groovy.jar\n' "$(cat /tmp/groovy.jar.sha1)" | sha1sum -c - GROOVY_JAR=/tmp/groovy.jar python3 helm/test_helm_hook.py - name: helm test hook behaviour diff --git a/helm/hugegraph/templates/tests/test-connection.yaml b/helm/hugegraph/templates/tests/test-connection.yaml index 7a5b0b78fa..020c484f6a 100644 --- a/helm/hugegraph/templates/tests/test-connection.yaml +++ b/helm/hugegraph/templates/tests/test-connection.yaml @@ -90,9 +90,9 @@ spec: PORT="{{ .Values.server.port }}" FLOOR="{{ include "hugegraph.server.replicaFloor" . }}" DEADLINE_S="${HG_TEST_DEADLINE_S:-150}" - BODY='{{ dict "gremlin" $smoke "aliases" (dict "graph" .Values.server.testGraphAlias) | toJson | replace "'" "'\"'\"'" }}' + BODY='{{ dict "gremlin" $smoke "aliases" (dict "graph" (.Values.server.testGraphAlias | default "DEFAULT-hugegraph")) | toJson | replace "'" "'\"'\"'" }}' {{- if .Values.server.testGremlin }} - BUSINESS_BODY='{{ dict "gremlin" .Values.server.testGremlin "aliases" (dict "graph" .Values.server.testGraphAlias) | toJson | replace "'" "'\"'\"'" }}' + BUSINESS_BODY='{{ dict "gremlin" .Values.server.testGremlin "aliases" (dict "graph" (.Values.server.testGraphAlias | default "DEFAULT-hugegraph")) | toJson | replace "'" "'\"'\"'" }}' {{- else }} BUSINESS_BODY="" {{- end }} diff --git a/helm/hugegraph/tests/test_hook_gremlin_test.yaml b/helm/hugegraph/tests/test_hook_gremlin_test.yaml index 6ac537e868..1e40618b98 100644 --- a/helm/hugegraph/tests/test_hook_gremlin_test.yaml +++ b/helm/hugegraph/tests/test_hook_gremlin_test.yaml @@ -91,6 +91,19 @@ tests: path: spec.containers[0].command[2] pattern: '"graph":"DEFAULT-Demo"' + - it: defaults a null alias for both index and business checks + template: tests/test-connection.yaml + set: + server.testGraphAlias: null + server.testGremlin: "assert graph.traversal().V().count().next() == 40" + asserts: + - matchRegex: + path: spec.containers[0].command[2] + pattern: '(?m)^\s*BODY=.*"graph":"DEFAULT-hugegraph"' + - matchRegex: + path: spec.containers[0].command[2] + pattern: '(?m)^\s*BUSINESS_BODY=.*"graph":"DEFAULT-hugegraph"' + - it: floors the per-Pod count at the HPA minimum when HPA is enabled template: tests/test-connection.yaml set: diff --git a/helm/test_helm_hook.py b/helm/test_helm_hook.py index 6eea8c80d0..8c3a2d30f4 100644 --- a/helm/test_helm_hook.py +++ b/helm/test_helm_hook.py @@ -72,21 +72,30 @@ class HelmHookTest(unittest.TestCase): - def render(self, directory, **values): + def render(self, directory, legacy_values=False, **values): + chart = CHART + if legacy_values: + # --reuse-values can render with old defaults that predate this key. + chart = directory / "legacy-chart" + shutil.copytree(CHART, chart) + defaults_file = chart / "values.yaml" + defaults = yaml.safe_load(defaults_file.read_text()) + defaults["server"].pop("testGraphAlias") + defaults_file.write_text(yaml.safe_dump(defaults)) configuration = directory / "values.yaml" configuration.write_text(yaml.safe_dump(values)) result = subprocess.run( - ["helm", "template", "fixture", str(CHART), "-f", str(configuration), + ["helm", "template", "fixture", str(chart), "-f", str(configuration), "--show-only", "templates/tests/test-connection.yaml"], capture_output=True, text=True) self.assertEqual(0, result.returncode, result.stderr) return yaml.safe_load(result.stdout)["spec"]["containers"][0]["command"][2] - def run_hook(self, case="healthy", addresses=None, **values): + def run_hook(self, case="healthy", addresses=None, legacy_values=False, **values): temporary = tempfile.TemporaryDirectory(prefix="helm-hook-") self.addCleanup(temporary.cleanup) directory = Path(temporary.name) - script = self.render(directory, **values) + script = self.render(directory, legacy_values=legacy_values, **values) commands = directory / "bin" commands.mkdir() for name in ("curl", "date", "getent", "sleep"): @@ -148,6 +157,20 @@ def test_selected_alias_applies_to_index_and_business_checks(self): self.assertTrue(all(payload["aliases"] == {"graph": "team_a-demo"} for payload in payloads)) + def test_missing_or_null_alias_preserves_default_for_both_checks(self): + business = "assert graph.traversal().V().count().next() == 7; true" + for missing in (False, True): + with self.subTest(alias="missing" if missing else "null"): + server = {"testGremlin": business} + if not missing: + server["testGraphAlias"] = None + result, requests, _ = self.run_hook(legacy_values=missing, server=server) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + payloads = [request["body"] for request in requests if request["body"]] + self.assertEqual(6, len(payloads)) + self.assertEqual([{"graph": "DEFAULT-hugegraph"}] * 6, + [payload["aliases"] for payload in payloads]) + def test_business_script_is_json_data_and_auth_can_be_disabled(self): business = '''assert 'single' != "double" // `touch injected` $(touch injected) ' "