From 6f824aefec6c08a0e209a8b5fb57c0343f6e2018 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 10 Aug 2026 13:07:31 +0800 Subject: [PATCH 1/4] test(stack): prove registration lease lifecycle --- .github/workflows/ci.yml | 4 +- .github/workflows/core-integration.yml | 4 +- README.md | 2 +- components.json | 6 +- compose.yaml | 66 +++++++++++++++++- go.mod | 2 +- go.sum | 4 +- tests/backend/invoke_record_test.go | 95 ++++++++++++++++++++++++-- 8 files changed, 166 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e6a098..d2800a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,11 +130,11 @@ jobs: - name: Capture sanitized backend logs if: always() && steps.prepare.outcome == 'success' shell: bash - run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile watch-refresh logs --no-color 2>&1 | perl scripts/sanitize-logs.pl + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile runtime-registration --profile watch-refresh logs --no-color 2>&1 | perl scripts/sanitize-logs.pl - name: Tear down backend assembly if: always() && steps.prepare.outcome == 'success' - run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile watch-refresh down --volumes --remove-orphans + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile runtime-registration --profile watch-refresh down --volumes --remove-orphans browser: runs-on: ubuntu-latest diff --git a/.github/workflows/core-integration.yml b/.github/workflows/core-integration.yml index a388f52..44ec3ff 100644 --- a/.github/workflows/core-integration.yml +++ b/.github/workflows/core-integration.yml @@ -83,11 +83,11 @@ jobs: - name: Capture sanitized backend logs if: always() && steps.prepare.outcome == 'success' shell: bash - run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile watch-refresh logs --no-color 2>&1 | perl scripts/sanitize-logs.pl + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile runtime-registration --profile watch-refresh logs --no-color 2>&1 | perl scripts/sanitize-logs.pl - name: Tear down backend assembly if: always() && steps.prepare.outcome == 'success' - run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile watch-refresh down --volumes --remove-orphans + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file compose.yaml --profile runtime-registration --profile watch-refresh down --volumes --remove-orphans browser: runs-on: ubuntu-latest diff --git a/README.md b/README.md index ca89de1..b8d5c15 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ explicitly because instance-directory behavior is outside those checks. |---|---|---| | Manifest | `go run ./cmd/manifest-validator components.json` | All five component owners and immutable revisions validate | | Compose | `docker compose --file compose.yaml config --quiet` | Configuration exits `0` with no `build:` or floating image | -| Backend acceptance | `go test -tags=e2e -count=1 ./tests/backend` | Register → publish → discover → install → invoke → record passes, including cross-runtime lineage | +| Backend acceptance | `go test -tags=e2e -count=1 ./tests/backend` | Provider registration lease → Nacos watch discovery → install → Router-only invoke → Ledger record passes, including cross-runtime lineage, explicit deregistration, fail-closed removal, and replacement recovery | | Browser acceptance | Console `pnpm test:e2e` in Stack CI | Every production Console scenario passes through the live Gateway | A healthy container set alone is not acceptance. Backend success requires the diff --git a/components.json b/components.json index 65d3fac..c073713 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "schemaVersion": "1", - "contractIdentity": "NeKiro/contracts@bf705bf0b2cfb412d0c21e46a3b7c0c2bdbe0aa1", + "contractIdentity": "NeKiro/contracts@3e815b89cb3799767632291dc882f20e3a95b61c", "components": { "core": { "repository": "NeKiro-project/NeKiro", - "commitSha": "bf705bf0b2cfb412d0c21e46a3b7c0c2bdbe0aa1" + "commitSha": "3e815b89cb3799767632291dc882f20e3a95b61c" }, "console": { "repository": "NeKiro-project/NeKiro-Console", @@ -16,7 +16,7 @@ }, "samples": { "repository": "NeKiro-project/NeKiro-Samples", - "commitSha": "8f6842eab413d9cc1926cbb247dc5e26cc0a7fa4" + "commitSha": "143685bebf3dc88819d3192e9047c1204a2f871d" }, "transportGo": { "repository": "NeKiro-project/nekiro-a2a-transport-go", diff --git a/compose.yaml b/compose.yaml index 3b6bb9f..ff22b26 100644 --- a/compose.yaml +++ b/compose.yaml @@ -216,20 +216,30 @@ services: start_period: 5s runtime-b-directory: + profiles: ["runtime-registration"] image: "${NEKIRO_RUNTIME_B_IMAGE:?NEKIRO_RUNTIME_B_IMAGE must be set to the prepared immutable image}" environment: RUNTIME_B_LISTEN_ADDR: "0.0.0.0:8092" RUNTIME_B_AGENT_ID: "runtime-b" RUNTIME_B_INSTANCE_ID: "runtime-b-directory" RUNTIME_B_REGISTRATION_MODE: "nacos" + RUNTIME_B_AGENT_CARD_VERSION: "${RUNTIME_B_AGENT_CARD_VERSION:-}" + RUNTIME_B_RELEASE_ID: "${RUNTIME_B_RELEASE_ID:-}" + RUNTIME_B_CARD_DIGEST: "${RUNTIME_B_CARD_DIGEST:-}" + RUNTIME_B_CANONICAL_ENDPOINT: "${RUNTIME_B_CANONICAL_ENDPOINT:-}" + RUNTIME_B_AUDIENCE: "${RUNTIME_B_AUDIENCE:-}" RUNTIME_B_NACOS_API_ORIGIN: "http://nacos:8848/nacos" RUNTIME_B_NACOS_NAMESPACE_ID: "nekiro" RUNTIME_B_NACOS_GROUP_NAME: "NEKIRO" RUNTIME_B_NACOS_SERVICE_NAME: "runtime-b" RUNTIME_B_NACOS_CLUSTER_NAME: "DEFAULT" + RUNTIME_B_NACOS_PORT_NAME: "a2a" RUNTIME_B_NACOS_ADVERTISED_IP: "172.28.0.12" RUNTIME_B_NACOS_ADVERTISED_PORT: "8092" + RUNTIME_B_NACOS_WEIGHT: "1" RUNTIME_B_NACOS_HEARTBEAT_INTERVAL_MS: "2000" + RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS: "5000" + RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS: "10000" RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS: "3000" RUNTIME_B_NACOS_AUTH_MODE: "none" RUNTIME_B_ROUTER_URL: "http://a2a-router:8081" @@ -264,14 +274,23 @@ services: RUNTIME_B_AGENT_ID: "runtime-b" RUNTIME_B_INSTANCE_ID: "runtime-b-primary" RUNTIME_B_REGISTRATION_MODE: "nacos" + RUNTIME_B_AGENT_CARD_VERSION: "${RUNTIME_B_AGENT_CARD_VERSION:-}" + RUNTIME_B_RELEASE_ID: "${RUNTIME_B_RELEASE_ID:-}" + RUNTIME_B_CARD_DIGEST: "${RUNTIME_B_CARD_DIGEST:-}" + RUNTIME_B_CANONICAL_ENDPOINT: "${RUNTIME_B_CANONICAL_ENDPOINT:-}" + RUNTIME_B_AUDIENCE: "${RUNTIME_B_AUDIENCE:-}" RUNTIME_B_NACOS_API_ORIGIN: "http://nacos:8848/nacos" RUNTIME_B_NACOS_NAMESPACE_ID: "nekiro" RUNTIME_B_NACOS_GROUP_NAME: "NEKIRO" RUNTIME_B_NACOS_SERVICE_NAME: "runtime-b" RUNTIME_B_NACOS_CLUSTER_NAME: "DEFAULT" + RUNTIME_B_NACOS_PORT_NAME: "a2a" RUNTIME_B_NACOS_ADVERTISED_IP: "172.28.0.13" RUNTIME_B_NACOS_ADVERTISED_PORT: "8092" + RUNTIME_B_NACOS_WEIGHT: "1" RUNTIME_B_NACOS_HEARTBEAT_INTERVAL_MS: "2000" + RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS: "5000" + RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS: "10000" RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS: "3000" RUNTIME_B_NACOS_AUTH_MODE: "none" RUNTIME_B_ROUTER_URL: "http://a2a-router:8081" @@ -299,20 +318,63 @@ services: start_period: 5s runtime-a: + image: "${NEKIRO_RUNTIME_A_IMAGE:?NEKIRO_RUNTIME_A_IMAGE must be set to the prepared immutable image}" + environment: + RUNTIME_A_LISTEN_ADDR: "0.0.0.0:8091" + RUNTIME_A_AGENT_ID: "runtime-a" + RUNTIME_A_INSTANCE_ID: "runtime-a-primary" + RUNTIME_A_REGISTRATION_MODE: "disabled" + RUNTIME_A_ROUTER_URL: "http://a2a-router:8081" + RUNTIME_A_ROUTER_TOKEN: "${RUNTIME_A_ROUTER_TOKEN:?RUNTIME_A_ROUTER_TOKEN must be set and non-empty}" + RUNTIME_A_TARGET_AGENT_ID: "runtime-b" + RUNTIME_A_TARGET_CAPABILITY: "runtime.echo" + RUNTIME_A_RESPONSE_LIMIT_BYTES: "${RUNTIME_A_RESPONSE_LIMIT_BYTES:?RUNTIME_A_RESPONSE_LIMIT_BYTES must be set and non-empty}" + RUNTIME_A_EVENT_LIMIT_BYTES: "${RUNTIME_A_EVENT_LIMIT_BYTES:?RUNTIME_A_EVENT_LIMIT_BYTES must be set and non-empty}" + NEKIRO_AGENT_CHALLENGE_DIRECTORY: "/tmp/nekiro-challenges" + NEKIRO_AGENT_ROUTER_ISSUER: "${NEKIRO_AGENT_ROUTER_ISSUER:?NEKIRO_AGENT_ROUTER_ISSUER must be set and non-empty}" + NEKIRO_AGENT_ROUTER_AUDIENCE: "http://runtime-a:8091" + NEKIRO_AGENT_ROUTER_KEY_ID: "${NEKIRO_AGENT_ROUTER_KEY_ID:?NEKIRO_AGENT_ROUTER_KEY_ID must be set and non-empty}" + NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL: "${NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL:?NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL must be set and non-empty}" + depends_on: + a2a-router: + condition: service_healthy + runtime-b: + condition: service_healthy + networks: + platform-internal: + ipv4_address: 172.28.0.11 + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8091/readyz"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 5s + + runtime-a-directory: + profiles: ["runtime-registration"] image: "${NEKIRO_RUNTIME_A_IMAGE:?NEKIRO_RUNTIME_A_IMAGE must be set to the prepared immutable image}" environment: RUNTIME_A_LISTEN_ADDR: "0.0.0.0:8091" RUNTIME_A_AGENT_ID: "runtime-a" RUNTIME_A_INSTANCE_ID: "runtime-a-primary" RUNTIME_A_REGISTRATION_MODE: "nacos" + RUNTIME_A_AGENT_CARD_VERSION: "${RUNTIME_A_AGENT_CARD_VERSION:-}" + RUNTIME_A_RELEASE_ID: "${RUNTIME_A_RELEASE_ID:-}" + RUNTIME_A_CARD_DIGEST: "${RUNTIME_A_CARD_DIGEST:-}" + RUNTIME_A_CANONICAL_ENDPOINT: "${RUNTIME_A_CANONICAL_ENDPOINT:-}" + RUNTIME_A_AUDIENCE: "${RUNTIME_A_AUDIENCE:-}" RUNTIME_A_NACOS_API_ORIGIN: "http://nacos:8848/nacos" RUNTIME_A_NACOS_NAMESPACE_ID: "nekiro" RUNTIME_A_NACOS_GROUP_NAME: "NEKIRO" RUNTIME_A_NACOS_SERVICE_NAME: "runtime-a" RUNTIME_A_NACOS_CLUSTER_NAME: "DEFAULT" - RUNTIME_A_NACOS_ADVERTISED_IP: "172.28.0.11" + RUNTIME_A_NACOS_PORT_NAME: "a2a" + RUNTIME_A_NACOS_ADVERTISED_IP: "172.28.0.14" RUNTIME_A_NACOS_ADVERTISED_PORT: "8091" + RUNTIME_A_NACOS_WEIGHT: "1" RUNTIME_A_NACOS_HEARTBEAT_INTERVAL_MS: "2000" + RUNTIME_A_NACOS_HEARTBEAT_TIMEOUT_MS: "5000" + RUNTIME_A_NACOS_IP_DELETE_TIMEOUT_MS: "10000" RUNTIME_A_NACOS_REQUEST_TIMEOUT_MS: "3000" RUNTIME_A_NACOS_AUTH_MODE: "none" RUNTIME_A_ROUTER_URL: "http://a2a-router:8081" @@ -333,7 +395,7 @@ services: condition: service_healthy networks: platform-internal: - ipv4_address: 172.28.0.11 + ipv4_address: 172.28.0.14 healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8091/readyz"] interval: 5s diff --git a/go.mod b/go.mod index f5cb5d5..bf6ef13 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/NeKiro-project/NeKiro-Stack go 1.26.0 require ( - github.com/NeKiro-project/NeKiro v0.0.0-20260809083232-6cee25590bb7 + github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37 github.com/jackc/pgx/v5 v5.10.0 ) diff --git a/go.sum b/go.sum index 74d6b7c..22446e0 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NeKiro-project/NeKiro v0.0.0-20260809083232-6cee25590bb7 h1:ju+I5nKZxgl/22830m+PewVUeqSrw8dYAGHPl7aBZyg= -github.com/NeKiro-project/NeKiro v0.0.0-20260809083232-6cee25590bb7/go.mod h1:FMQC8bVPgsnMImg7eHaykOpWTOfQs9uDU6DVMDa1UVQ= +github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37 h1:ai0eN+G2k6rwtF9h1dDhDKzloueH6AyuJp2UOIoyQec= +github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/tests/backend/invoke_record_test.go b/tests/backend/invoke_record_test.go index e74abb1..178385e 100644 --- a/tests/backend/invoke_record_test.go +++ b/tests/backend/invoke_record_test.go @@ -118,7 +118,7 @@ func TestInvokeToRecordAcceptance(t *testing.T) { "policy-content-secret", "protocol-content-secret", "agent-content-secret", "route-content-secret", "timeout-content-secret", "cancel-content-secret", "interrupted-content-secret", "dependency-content-secret", "dependency-raw-secret", - "snapshot-refresh-value", + "snapshot-refresh-value", "removed-runtime-value", }, env.credentialForbidden...) client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Timeout: 45 * time.Second} if result := doRequest(t, client, env.controlPlane+"/readyz", http.MethodGet, "", "", nil); result.status != http.StatusNoContent { @@ -138,6 +138,7 @@ func TestInvokeToRecordAcceptance(t *testing.T) { registerAndPublish(t, client, &env, runtimeTimeout) registerAndPublish(t, client, &env, runtimeInterrupted) runtimeLifecycleRelease := registerAndPublish(t, client, &env, runtimeLifecycle) + startRegisteredRuntimes(t, env) assertNacosRegistrations(t, client, env) publishRouterNacosBindings(t, client, env) assertPublicAgentInputMatrix(t, client, env, contracts.CatalogEntry{PublicAgentID: env.publicAgentIDs["runtime-a"], PublicURL: env.publicAgentOrigin + "/a/" + env.publicAgentIDs["runtime-a"]}) @@ -308,12 +309,26 @@ func assertNacosRegistrations(t *testing.T, client *http.Client, env acceptanceE } } +func startRegisteredRuntimes(t *testing.T, env acceptanceEnv) { + t.Helper() + command := composeCommand( + t.Context(), env, + "--profile", "runtime-registration", "up", "--detach", "--no-deps", "--force-recreate", + "--wait", "--wait-timeout", "60", "runtime-a-directory", "runtime-b-directory", + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("start exact-Release runtime registrations: %v output=%s", err, output) + } +} + func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env acceptanceEnv) { t.Helper() - stop := composeCommand(t.Context(), env, "stop", "runtime-b-directory") + stop := composeCommand(t.Context(), env, "--profile", "runtime-registration", "stop", "runtime-b-directory") if output, err := stop.CombinedOutput(); err != nil { t.Fatalf("stop original Runtime B directory instance: %v output=%s", err, output) } + waitForNacosInstanceRemoval(t, client, env, "runtime-b") + assertRemovedRuntimeRejected(t, client, env) start := composeCommand( t.Context(), env, @@ -326,6 +341,58 @@ func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env accepta waitForNacosInstance(t, client, env, "runtime-b", "runtime-b-primary") } +func waitForNacosInstanceRemoval(t *testing.T, client *http.Client, env acceptanceEnv, serviceName string) { + t.Helper() + endpoint, err := url.Parse(env.nacosURL + "/v1/ns/instance/list") + if err != nil { + t.Fatal(err) + } + query := endpoint.Query() + query.Set("serviceName", serviceName) + query.Set("groupName", "NEKIRO") + query.Set("clusters", "DEFAULT") + query.Set("namespaceId", "nekiro") + query.Set("healthyOnly", "false") + endpoint.RawQuery = query.Encode() + deadline := time.Now().Add(30 * time.Second) + var last httpResult + for time.Now().Before(deadline) { + last = doRequest(t, client, endpoint.String(), http.MethodGet, "", "", nil) + var response struct { + Hosts []json.RawMessage `json:"hosts"` + } + if last.status == http.StatusOK && json.Unmarshal(last.body, &response) == nil && len(response.Hosts) == 0 { + return + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("Nacos service %s retained an instance after lease close: status=%d body=%s", serviceName, last.status, last.body) +} + +func assertRemovedRuntimeRejected(t *testing.T, client *http.Client, env acceptanceEnv) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + var last httpResult + for time.Now().Before(deadline) { + result, err := doRequestRaw(t.Context(), client, env.controlPlane+"/v4/workspaces/"+acceptanceWorkspace+"/invocations", http.MethodPost, env.ownerToken, "application/json", map[string]any{ + "agentId": "runtime-b", "capability": "runtime.echo", + "input": map[string]any{"fixture": "success", "value": "removed-runtime-value"}, "stream": false, + }) + if err != nil { + t.Fatal(err) + } + last = result + var observation platformErrorObservation + if result.status != http.StatusOK && json.Unmarshal(result.body, &observation) == nil && observation.Code == contracts.ErrorCodeAgentUnavailable && observation.InvocationID != "" && observation.RootTaskID != "" { + validated := assertCorrelatedInvocationError(t, result, contracts.ErrorCodeAgentUnavailable, env.forbidden) + assertRecord(t, client, env, validated.InvocationID, acceptanceWorkspace, "runtime-b", "failed", string(contracts.ErrorCodeAgentUnavailable)) + return + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("Router did not fail closed after Runtime B lease removal: status=%d body=%s", last.status, last.body) +} + func waitForNacosInstance(t *testing.T, client *http.Client, env acceptanceEnv, serviceName, instanceID string) { t.Helper() endpoint, err := url.Parse(env.nacosURL + "/v1/ns/instance/list") @@ -423,7 +490,27 @@ func requiredEnv(t *testing.T, name string) string { func composeCommand(ctx context.Context, env acceptanceEnv, args ...string) *exec.Cmd { base := []string{"compose", "--project-name", env.composeProject, "--file", env.composeFile} - return exec.CommandContext(ctx, "docker", append(base, args...)...) + command := exec.CommandContext(ctx, "docker", append(base, args...)...) + command.Env = append(os.Environ(), runtimeRegistrationEnvironment(env)...) + return command +} + +func runtimeRegistrationEnvironment(env acceptanceEnv) []string { + values := make([]string, 0, 10) + for prefix, agentID := range map[string]string{"RUNTIME_A": "runtime-a", "RUNTIME_B": "runtime-b"} { + release, ok := env.releases[agentID] + if !ok { + continue + } + values = append(values, + prefix+"_AGENT_CARD_VERSION="+release.AgentCardVersion, + prefix+"_RELEASE_ID="+release.ReleaseID, + prefix+"_CARD_DIGEST="+release.CardDigest, + prefix+"_CANONICAL_ENDPOINT="+release.EndpointOrigin+release.EndpointPath, + prefix+"_AUDIENCE="+release.EndpointOrigin, + ) + } + return values } func acceptanceCard(agentID, name, endpoint, capability string, permissions []string, streaming bool) []byte { @@ -1911,7 +1998,7 @@ func assertStorageAndLogsAreMetadataOnly(t *testing.T, env acceptanceEnv) { t.Fatal(err) } installationRows.Close() - logs := composeCommand(ctx, env, "--profile", "watch-refresh", "logs", "--no-color") + logs := composeCommand(ctx, env, "--profile", "runtime-registration", "--profile", "watch-refresh", "logs", "--no-color") output, err := logs.Output() if err != nil { t.Fatal(err) From 620b4e57e86a8eaf0781fa48ed6725e7fc0d7d26 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 10 Aug 2026 13:50:41 +0800 Subject: [PATCH 2/4] test(stack): prove router-observed topology lifecycle --- components.json | 4 +-- go.mod | 2 +- go.sum | 4 +-- tests/backend/invoke_record_test.go | 54 +++++++++++++++++++++++++---- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/components.json b/components.json index c073713..7746a7e 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "schemaVersion": "1", - "contractIdentity": "NeKiro/contracts@3e815b89cb3799767632291dc882f20e3a95b61c", + "contractIdentity": "NeKiro/contracts@2820c81fa7d650735af6448a2b0f02cead59a534", "components": { "core": { "repository": "NeKiro-project/NeKiro", - "commitSha": "3e815b89cb3799767632291dc882f20e3a95b61c" + "commitSha": "2820c81fa7d650735af6448a2b0f02cead59a534" }, "console": { "repository": "NeKiro-project/NeKiro-Console", diff --git a/go.mod b/go.mod index bf6ef13..c8cfd20 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/NeKiro-project/NeKiro-Stack go 1.26.0 require ( - github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37 + github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6 github.com/jackc/pgx/v5 v5.10.0 ) diff --git a/go.sum b/go.sum index 22446e0..4f4e651 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37 h1:ai0eN+G2k6rwtF9h1dDhDKzloueH6AyuJp2UOIoyQec= -github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= +github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6 h1:8vCW/5pJVLkYJFRYSKb6oFgZdpmmz3i9kFaVa7y+GiA= +github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/tests/backend/invoke_record_test.go b/tests/backend/invoke_record_test.go index 178385e..6027bdf 100644 --- a/tests/backend/invoke_record_test.go +++ b/tests/backend/invoke_record_test.go @@ -323,12 +323,18 @@ func startRegisteredRuntimes(t *testing.T, env acceptanceEnv) { func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env acceptanceEnv) { t.Helper() + release := env.releases["runtime-b"] + populated := waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStatePopulated, 0) stop := composeCommand(t.Context(), env, "--profile", "runtime-registration", "stop", "runtime-b-directory") if output, err := stop.CombinedOutput(); err != nil { t.Fatalf("stop original Runtime B directory instance: %v output=%s", err, output) } waitForNacosInstanceRemoval(t, client, env, "runtime-b") - assertRemovedRuntimeRejected(t, client, env) + empty := waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStateEmpty, populated.LocalRevision+1) + if !empty.ObservedAt.After(populated.ObservedAt) { + t.Fatalf("Router empty observation time=%s did not advance after populated time=%s", empty.ObservedAt, populated.ObservedAt) + } + assertRemovedRuntimeRejected(t, client, env, contracts.ErrorCodeDependency) start := composeCommand( t.Context(), env, @@ -339,6 +345,42 @@ func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env accepta t.Fatalf("start replacement Runtime B directory instance: %v output=%s", err, output) } waitForNacosInstance(t, client, env, "runtime-b", "runtime-b-primary") + recovered := waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStatePopulated, empty.LocalRevision+1) + if !recovered.ObservedAt.After(empty.ObservedAt) { + t.Fatalf("Router recovery observation time=%s did not advance after empty time=%s", recovered.ObservedAt, empty.ObservedAt) + } +} + +func waitForRouterTopologyState( + t *testing.T, + client *http.Client, + env acceptanceEnv, + release contracts.AgentReleaseResponse, + wantState contracts.RouterTopologyObservationState, + minimumRevision uint64, +) contracts.RouterTopologyStatusObservationV1 { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + var last httpResult + for time.Now().Before(deadline) { + last = doRequest(t, client, env.routerURL+"/internal/v1/instance-topology/status", http.MethodGet, env.routerToken, "", nil) + var status contracts.RouterTopologyStatusV1 + if last.status == http.StatusOK { + assertNoForbiddenBody(t, last.body, env.forbidden, "Router topology status") + } + if last.status == http.StatusOK && json.Unmarshal(last.body, &status) == nil && contracts.ValidateRouterTopologyStatusV1(status) == nil && status.Provider == "nacos" { + for _, observation := range status.Observations { + if observation.AgentID == release.AgentID && observation.AgentCardVersion == release.AgentCardVersion && observation.ReleaseID == release.ReleaseID && + observation.State == wantState && observation.LocalRevision >= minimumRevision { + return observation + } + } + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("Router topology did not reach %s at revision >= %d for %s/%s/%s: status=%d body=%s", + wantState, minimumRevision, release.AgentID, release.AgentCardVersion, release.ReleaseID, last.status, last.body) + return contracts.RouterTopologyStatusObservationV1{} } func waitForNacosInstanceRemoval(t *testing.T, client *http.Client, env acceptanceEnv, serviceName string) { @@ -369,7 +411,7 @@ func waitForNacosInstanceRemoval(t *testing.T, client *http.Client, env acceptan t.Fatalf("Nacos service %s retained an instance after lease close: status=%d body=%s", serviceName, last.status, last.body) } -func assertRemovedRuntimeRejected(t *testing.T, client *http.Client, env acceptanceEnv) { +func assertRemovedRuntimeRejected(t *testing.T, client *http.Client, env acceptanceEnv, wantCode contracts.PlatformErrorCode) { t.Helper() deadline := time.Now().Add(30 * time.Second) var last httpResult @@ -383,14 +425,14 @@ func assertRemovedRuntimeRejected(t *testing.T, client *http.Client, env accepta } last = result var observation platformErrorObservation - if result.status != http.StatusOK && json.Unmarshal(result.body, &observation) == nil && observation.Code == contracts.ErrorCodeAgentUnavailable && observation.InvocationID != "" && observation.RootTaskID != "" { - validated := assertCorrelatedInvocationError(t, result, contracts.ErrorCodeAgentUnavailable, env.forbidden) - assertRecord(t, client, env, validated.InvocationID, acceptanceWorkspace, "runtime-b", "failed", string(contracts.ErrorCodeAgentUnavailable)) + if result.status != http.StatusOK && json.Unmarshal(result.body, &observation) == nil && observation.Code == wantCode && observation.InvocationID != "" && observation.RootTaskID != "" { + validated := assertCorrelatedInvocationError(t, result, wantCode, env.forbidden) + assertRecord(t, client, env, validated.InvocationID, acceptanceWorkspace, "runtime-b", "failed", string(wantCode)) return } time.Sleep(250 * time.Millisecond) } - t.Fatalf("Router did not fail closed after Runtime B lease removal: status=%d body=%s", last.status, last.body) + t.Fatalf("Router did not fail closed with %s after observed Runtime B lease removal: status=%d body=%s", wantCode, last.status, last.body) } func waitForNacosInstance(t *testing.T, client *http.Client, env acceptanceEnv, serviceName, instanceID string) { From 200ec3d2388ff27ffc84b882124e75e81dfe7c82 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 10 Aug 2026 13:55:39 +0800 Subject: [PATCH 3/4] fix(stack): require first empty-topology failure --- components.json | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- tests/backend/invoke_record_test.go | 35 ++++++++--------------------- 4 files changed, 14 insertions(+), 31 deletions(-) diff --git a/components.json b/components.json index 7746a7e..c7a99e2 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "schemaVersion": "1", - "contractIdentity": "NeKiro/contracts@2820c81fa7d650735af6448a2b0f02cead59a534", + "contractIdentity": "NeKiro/contracts@95d216796741fc09b1c934ee0095bd2980758bf7", "components": { "core": { "repository": "NeKiro-project/NeKiro", - "commitSha": "2820c81fa7d650735af6448a2b0f02cead59a534" + "commitSha": "95d216796741fc09b1c934ee0095bd2980758bf7" }, "console": { "repository": "NeKiro-project/NeKiro-Console", diff --git a/go.mod b/go.mod index c8cfd20..2c16e78 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/NeKiro-project/NeKiro-Stack go 1.26.0 require ( - github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6 + github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741 github.com/jackc/pgx/v5 v5.10.0 ) diff --git a/go.sum b/go.sum index 4f4e651..5507f46 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6 h1:8vCW/5pJVLkYJFRYSKb6oFgZdpmmz3i9kFaVa7y+GiA= -github.com/NeKiro-project/NeKiro v0.0.0-20260810054127-2820c81fa7d6/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= +github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741 h1:A/kEgoHzn12E/PpR/8T4OAqWZ506JqVs5rrQifRvRb8= +github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/tests/backend/invoke_record_test.go b/tests/backend/invoke_record_test.go index 6027bdf..e4596cb 100644 --- a/tests/backend/invoke_record_test.go +++ b/tests/backend/invoke_record_test.go @@ -331,9 +331,6 @@ func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env accepta } waitForNacosInstanceRemoval(t, client, env, "runtime-b") empty := waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStateEmpty, populated.LocalRevision+1) - if !empty.ObservedAt.After(populated.ObservedAt) { - t.Fatalf("Router empty observation time=%s did not advance after populated time=%s", empty.ObservedAt, populated.ObservedAt) - } assertRemovedRuntimeRejected(t, client, env, contracts.ErrorCodeDependency) start := composeCommand( @@ -345,10 +342,7 @@ func replaceRuntimeBNacosInstance(t *testing.T, client *http.Client, env accepta t.Fatalf("start replacement Runtime B directory instance: %v output=%s", err, output) } waitForNacosInstance(t, client, env, "runtime-b", "runtime-b-primary") - recovered := waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStatePopulated, empty.LocalRevision+1) - if !recovered.ObservedAt.After(empty.ObservedAt) { - t.Fatalf("Router recovery observation time=%s did not advance after empty time=%s", recovered.ObservedAt, empty.ObservedAt) - } + waitForRouterTopologyState(t, client, env, release, contracts.RouterTopologyStatePopulated, empty.LocalRevision+1) } func waitForRouterTopologyState( @@ -413,26 +407,15 @@ func waitForNacosInstanceRemoval(t *testing.T, client *http.Client, env acceptan func assertRemovedRuntimeRejected(t *testing.T, client *http.Client, env acceptanceEnv, wantCode contracts.PlatformErrorCode) { t.Helper() - deadline := time.Now().Add(30 * time.Second) - var last httpResult - for time.Now().Before(deadline) { - result, err := doRequestRaw(t.Context(), client, env.controlPlane+"/v4/workspaces/"+acceptanceWorkspace+"/invocations", http.MethodPost, env.ownerToken, "application/json", map[string]any{ - "agentId": "runtime-b", "capability": "runtime.echo", - "input": map[string]any{"fixture": "success", "value": "removed-runtime-value"}, "stream": false, - }) - if err != nil { - t.Fatal(err) - } - last = result - var observation platformErrorObservation - if result.status != http.StatusOK && json.Unmarshal(result.body, &observation) == nil && observation.Code == wantCode && observation.InvocationID != "" && observation.RootTaskID != "" { - validated := assertCorrelatedInvocationError(t, result, wantCode, env.forbidden) - assertRecord(t, client, env, validated.InvocationID, acceptanceWorkspace, "runtime-b", "failed", string(wantCode)) - return - } - time.Sleep(250 * time.Millisecond) + result, err := doRequestRaw(t.Context(), client, env.controlPlane+"/v4/workspaces/"+acceptanceWorkspace+"/invocations", http.MethodPost, env.ownerToken, "application/json", map[string]any{ + "agentId": "runtime-b", "capability": "runtime.echo", + "input": map[string]any{"fixture": "success", "value": "removed-runtime-value"}, "stream": false, + }) + if err != nil { + t.Fatal(err) } - t.Fatalf("Router did not fail closed with %s after observed Runtime B lease removal: status=%d body=%s", wantCode, last.status, last.body) + validated := assertCorrelatedInvocationError(t, result, wantCode, env.forbidden) + assertRecord(t, client, env, validated.InvocationID, acceptanceWorkspace, "runtime-b", "failed", string(wantCode)) } func waitForNacosInstance(t *testing.T, client *http.Client, env acceptanceEnv, serviceName, instanceID string) { From 12f79c4a4ff387356b6f4193b08de29e3a9885bb Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 10 Aug 2026 16:01:42 +0800 Subject: [PATCH 4/4] test(stack): refresh router topology revision --- components.json | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components.json b/components.json index c7a99e2..09ffe7c 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "schemaVersion": "1", - "contractIdentity": "NeKiro/contracts@95d216796741fc09b1c934ee0095bd2980758bf7", + "contractIdentity": "NeKiro/contracts@a737184be63533a578355f98d1b8b94648e05910", "components": { "core": { "repository": "NeKiro-project/NeKiro", - "commitSha": "95d216796741fc09b1c934ee0095bd2980758bf7" + "commitSha": "a737184be63533a578355f98d1b8b94648e05910" }, "console": { "repository": "NeKiro-project/NeKiro-Console", diff --git a/go.mod b/go.mod index 2c16e78..72b5137 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/NeKiro-project/NeKiro-Stack go 1.26.0 require ( - github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741 + github.com/NeKiro-project/NeKiro v0.0.0-20260810073448-a737184be635 github.com/jackc/pgx/v5 v5.10.0 ) diff --git a/go.sum b/go.sum index 5507f46..4bde0bf 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741 h1:A/kEgoHzn12E/PpR/8T4OAqWZ506JqVs5rrQifRvRb8= -github.com/NeKiro-project/NeKiro v0.0.0-20260810055410-95d216796741/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= +github.com/NeKiro-project/NeKiro v0.0.0-20260810073448-a737184be635 h1:lNVDKPv040qm4EGN26vet3feeSyvfDmTS51ZUWJnjdI= +github.com/NeKiro-project/NeKiro v0.0.0-20260810073448-a737184be635/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=