diff --git a/components.json b/components.json index b3b05e8..a8545a1 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "schemaVersion": "1", - "contractIdentity": "NeKiro/contracts@e883ad4bcfaf75ffe80e9d9e6fb79b2a8c58b18c", + "contractIdentity": "NeKiro/contracts@6cee25590bb720ed447d70e82204abdad48c5779", "components": { "core": { "repository": "NeKiro-project/NeKiro", - "commitSha": "e883ad4bcfaf75ffe80e9d9e6fb79b2a8c58b18c" + "commitSha": "6cee25590bb720ed447d70e82204abdad48c5779" }, "console": { "repository": "NeKiro-project/NeKiro-Console", @@ -16,7 +16,7 @@ }, "samples": { "repository": "NeKiro-project/NeKiro-Samples", - "commitSha": "5bc9dde207732d25e853520f39144d2bfee8c879" + "commitSha": "8f6842eab413d9cc1926cbb247dc5e26cc0a7fa4" }, "transportGo": { "repository": "NeKiro-project/nekiro-a2a-transport-go", diff --git a/compose.yaml b/compose.yaml index 3e25ffa..e6636e2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,45 @@ name: nekiro services: + nacos: + image: "nacos/nacos-server:v2.5.1@sha256:8987908cb94ed5f9d30522a64493d35732a6c05f216d667a7addb022f3d92e80" + environment: + MODE: standalone + NACOS_AUTH_ENABLE: "false" + networks: + - platform-internal + - local-access + ports: + - "127.0.0.1:${NACOS_PORT:?NACOS_PORT must be set and non-empty}:8848" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8848/nacos/v1/console/health/readiness"] + interval: 5s + timeout: 5s + retries: 24 + start_period: 20s + + nacos-bootstrap: + image: "curlimages/curl:8.16.0@sha256:463eaf6072688fe96ac64fa623fe73e1dbe25d8ad6c34404a669ad3ce1f104b6" + environment: + NACOS_BINDING_CONTENT: '{"schemaVersion":"1","revision":"stack-bootstrap-1","targets":[]}' + command: + - sh + - -eu + - -c + - | + curl -fsS -X POST http://nacos:8848/nacos/v1/console/namespaces \ + --data-urlencode customNamespaceId=nekiro \ + --data-urlencode namespaceName=nekiro \ + --data-urlencode 'namespaceDesc=NeKiro Stack acceptance' + curl -fsS -X POST 'http://nacos:8848/nacos/v1/cs/configs?dataId=router.nacos-bindings&group=NEKIRO&tenant=nekiro' \ + --data-urlencode content=$$NACOS_BINDING_CONTENT + depends_on: + nacos: + condition: service_healthy + networks: + - platform-internal + restart: "no" + postgres: image: postgres:17.9-bookworm@sha256:47f917f7409eacd22fc5dfb1dee634e1b55cf0c01d1a7eb701be2227a03e0641 environment: @@ -107,6 +146,12 @@ services: NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL: "${NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL:?NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL must be set and non-empty}" NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS: "${NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS:?NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS must be set and non-empty}" NEKIRO_ROUTER_INSTANCE_ROUTING_MODE: "${NEKIRO_ROUTER_INSTANCE_ROUTING_MODE:?NEKIRO_ROUTER_INSTANCE_ROUTING_MODE must be set and non-empty}" + NEKIRO_ROUTER_NACOS_API_ORIGIN: "http://nacos:8848/nacos" + NEKIRO_ROUTER_NACOS_NAMESPACE_ID: "nekiro" + NEKIRO_ROUTER_NACOS_CONFIG_GROUP: "NEKIRO" + NEKIRO_ROUTER_NACOS_AUTH_MODE: "none" + NEKIRO_ROUTER_NACOS_RESPONSE_LIMIT_BYTES: "${NEKIRO_ROUTER_NACOS_RESPONSE_LIMIT_BYTES:?NEKIRO_ROUTER_NACOS_RESPONSE_LIMIT_BYTES must be set and non-empty}" + NEKIRO_ROUTER_NACOS_REQUEST_TIMEOUT_MS: "${NEKIRO_ROUTER_NACOS_REQUEST_TIMEOUT_MS:?NEKIRO_ROUTER_NACOS_REQUEST_TIMEOUT_MS must be set and non-empty}" NEKIRO_ROUTER_CONFIG_CENTER_FILE_ROOT: "/var/lib/nekiro/config" NEKIRO_ROUTER_CONFIG_CENTER_MAX_PAYLOAD_BYTES: "${NEKIRO_ROUTER_CONFIG_CENTER_MAX_PAYLOAD_BYTES:?NEKIRO_ROUTER_CONFIG_CENTER_MAX_PAYLOAD_BYTES must be set and non-empty}" NEKIRO_ROUTER_INSTANCE_DIRECTORY_KEY: "${NEKIRO_ROUTER_INSTANCE_DIRECTORY_KEY:?NEKIRO_ROUTER_INSTANCE_DIRECTORY_KEY must be set and non-empty}" @@ -120,6 +165,8 @@ services: condition: service_completed_successfully control-plane: condition: service_healthy + nacos-bootstrap: + condition: service_completed_successfully networks: - platform-internal - local-access @@ -136,6 +183,7 @@ services: RUNTIME_B_LISTEN_ADDR: "0.0.0.0:8092" RUNTIME_B_AGENT_ID: "runtime-b" RUNTIME_B_INSTANCE_ID: "runtime-b-primary" + RUNTIME_B_REGISTRATION_MODE: "disabled" RUNTIME_B_ROUTER_URL: "http://a2a-router:8081" RUNTIME_B_ROUTER_TOKEN: "${RUNTIME_B_ROUTER_TOKEN:?RUNTIME_B_ROUTER_TOKEN must be set and non-empty}" RUNTIME_B_TARGET_AGENT_ID: "runtime-a" @@ -165,6 +213,17 @@ services: 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_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_ADVERTISED_IP: "172.28.0.12" + RUNTIME_B_NACOS_ADVERTISED_PORT: "8092" + RUNTIME_B_NACOS_HEARTBEAT_INTERVAL_MS: "2000" + RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS: "3000" + RUNTIME_B_NACOS_AUTH_MODE: "none" RUNTIME_B_ROUTER_URL: "http://a2a-router:8081" RUNTIME_B_ROUTER_TOKEN: "${RUNTIME_B_ROUTER_TOKEN:?RUNTIME_B_ROUTER_TOKEN must be set and non-empty}" RUNTIME_B_TARGET_AGENT_ID: "runtime-a" @@ -180,7 +239,8 @@ services: a2a-router: condition: service_healthy networks: - - platform-internal + platform-internal: + ipv4_address: 172.28.0.12 healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8092/readyz"] interval: 5s @@ -193,6 +253,18 @@ services: 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_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_ADVERTISED_PORT: "8091" + RUNTIME_A_NACOS_HEARTBEAT_INTERVAL_MS: "2000" + RUNTIME_A_NACOS_REQUEST_TIMEOUT_MS: "3000" + RUNTIME_A_NACOS_AUTH_MODE: "none" 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" @@ -210,7 +282,8 @@ services: runtime-b: condition: service_healthy networks: - - platform-internal + 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 @@ -224,4 +297,7 @@ volumes: networks: platform-internal: internal: true + ipam: + config: + - subnet: 172.28.0.0/24 local-access: diff --git a/go.mod b/go.mod index f2e7104..f5cb5d5 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-20260808045545-e883ad4bcfaf + github.com/NeKiro-project/NeKiro v0.0.0-20260809083232-6cee25590bb7 github.com/jackc/pgx/v5 v5.10.0 ) diff --git a/go.sum b/go.sum index 0d90494..74d6b7c 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-20260808045545-e883ad4bcfaf h1:A8V2HXsH4VdiIKjkQKN2gO5abvF3fJykkKTccDoEvFc= -github.com/NeKiro-project/NeKiro v0.0.0-20260808045545-e883ad4bcfaf/go.mod h1:FMQC8bVPgsnMImg7eHaykOpWTOfQs9uDU6DVMDa1UVQ= +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/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/scripts/write-ci-env.sh b/scripts/write-ci-env.sh index 53a1eef..b237ecc 100755 --- a/scripts/write-ci-env.sh +++ b/scripts/write-ci-env.sh @@ -21,6 +21,7 @@ write_common() { printf '%s\n' '{"schemaVersion":"1","revision":"stack-bootstrap-1","targets":[]}' >"$config_root/cfg-v1-cm91dGVyL2luc3RhbmNlLWRpcmVjdG9yeQ.value" cat >>"$output" <<'EOF' POSTGRES_PORT=55432 +NACOS_PORT=18848 CONTROL_PLANE_PORT=18080 A2A_ROUTER_PORT=18081 NEKIRO_PUBLIC_AGENT_ORIGIN=https://agents.nekiro.test @@ -50,6 +51,8 @@ RUNTIME_B_EVENT_LIMIT_BYTES=65536 NEKIRO_ROUTER_CONFIG_CENTER_MAX_PAYLOAD_BYTES=1048576 NEKIRO_ROUTER_INSTANCE_DIRECTORY_KEY=router/instance-directory NEKIRO_ROUTER_INSTANCE_PORT_NAME=a2a +NEKIRO_ROUTER_NACOS_RESPONSE_LIMIT_BYTES=1048576 +NEKIRO_ROUTER_NACOS_REQUEST_TIMEOUT_MS=3000 EOF printf 'NEKIRO_ROUTER_CONFIG_CENTER_ROOT=%s\n' "$config_root" >>"$output" printf 'NEKIRO_E2E_COMPOSE_FILE=%s/compose.yaml\n' "$stack_root" >>"$output" @@ -85,9 +88,11 @@ NEKIRO_E2E_OWNER_TOKEN=acceptance-owner-token NEKIRO_E2E_USER_TOKEN=acceptance-user-token NEKIRO_E2E_OTHER_TOKEN=acceptance-other-token NEKIRO_E2E_DATABASE_URL=postgresql://nekiro_acceptance:acceptance-only-password@127.0.0.1:55432/nekiro_acceptance?sslmode=disable -NEKIRO_ROUTER_INSTANCE_ROUTING_MODE=config_center_file +NEKIRO_ROUTER_INSTANCE_ROUTING_MODE=nacos +NEKIRO_ROUTER_INSTANCE_DIRECTORY_KEY=router.nacos-bindings EOF printf 'NEKIRO_E2E_CONFIG_CENTER_ROOT=%s\n' "$config_root" >>"$output" + printf 'NEKIRO_E2E_NACOS_URL=http://127.0.0.1:%s/nacos\n' "${NACOS_PORT:-18848}" >>"$output" ;; browser) write_common diff --git a/tests/backend/invoke_record_test.go b/tests/backend/invoke_record_test.go index fb91ef4..0a8a231 100644 --- a/tests/backend/invoke_record_test.go +++ b/tests/backend/invoke_record_test.go @@ -42,6 +42,7 @@ type acceptanceEnv struct { controlPlane string publicAgentOrigin string routerURL string + nacosURL string routerToken string ownerToken string userToken string @@ -49,7 +50,6 @@ type acceptanceEnv struct { databaseURL string composeFile string composeProject string - configCenterRoot string challengeTTL time.Duration credentialIssuer string credentialKeyID string @@ -137,7 +137,8 @@ func TestInvokeToRecordAcceptance(t *testing.T) { registerAndPublish(t, client, &env, runtimeTimeout) registerAndPublish(t, client, &env, runtimeInterrupted) runtimeLifecycleRelease := registerAndPublish(t, client, &env, runtimeLifecycle) - publishRouterInstanceDirectory(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"]}) assertVerificationFailureMatrix(t, client, &env) @@ -251,10 +252,6 @@ func loadAcceptanceEnv(t *testing.T) acceptanceEnv { if !filepath.IsAbs(composeFile) { t.Fatalf("NEKIRO_E2E_COMPOSE_FILE must be an absolute path") } - configCenterRoot := requiredEnv(t, "NEKIRO_E2E_CONFIG_CENTER_ROOT") - if !filepath.IsAbs(configCenterRoot) { - t.Fatalf("NEKIRO_E2E_CONFIG_CENTER_ROOT must be an absolute path") - } ttlSeconds, err := strconv.ParseInt(requiredEnv(t, "NEKIRO_ENDPOINT_CHALLENGE_TTL_SECONDS"), 10, 64) if err != nil || ttlSeconds < 2 || ttlSeconds > 15 { t.Fatalf("NEKIRO_ENDPOINT_CHALLENGE_TTL_SECONDS must be an acceptance value from 2 through 15 seconds") @@ -263,6 +260,7 @@ func loadAcceptanceEnv(t *testing.T) acceptanceEnv { controlPlane: requiredEnv(t, "NEKIRO_E2E_CONTROL_PLANE_URL"), publicAgentOrigin: requiredEnv(t, "NEKIRO_E2E_PUBLIC_AGENT_ORIGIN"), routerURL: requiredEnv(t, "NEKIRO_E2E_ROUTER_URL"), + nacosURL: requiredEnv(t, "NEKIRO_E2E_NACOS_URL"), routerToken: requiredEnv(t, "NEKIRO_E2E_ROUTER_TOKEN"), ownerToken: requiredEnv(t, "NEKIRO_E2E_OWNER_TOKEN"), userToken: requiredEnv(t, "NEKIRO_E2E_USER_TOKEN"), @@ -270,7 +268,6 @@ func loadAcceptanceEnv(t *testing.T) acceptanceEnv { databaseURL: requiredEnv(t, "NEKIRO_E2E_DATABASE_URL"), composeFile: composeFile, composeProject: requiredEnv(t, "NEKIRO_E2E_COMPOSE_PROJECT"), - configCenterRoot: configCenterRoot, challengeTTL: time.Duration(ttlSeconds) * time.Second, credentialIssuer: requiredEnv(t, "NEKIRO_ROUTER_AGENT_CREDENTIAL_ISSUER"), credentialKeyID: requiredEnv(t, "NEKIRO_ROUTER_AGENT_CREDENTIAL_KEY_ID"), @@ -280,55 +277,73 @@ func loadAcceptanceEnv(t *testing.T) acceptanceEnv { } } -func publishRouterInstanceDirectory(t *testing.T, env acceptanceEnv) { +func assertNacosRegistrations(t *testing.T, client *http.Client, env acceptanceEnv) { + t.Helper() + for service, instanceID := range map[string]string{"runtime-a": "runtime-a-primary", "runtime-b": "runtime-b-directory"} { + endpoint, err := url.Parse(env.nacosURL + "/v1/ns/instance/list") + if err != nil { + t.Fatal(err) + } + query := endpoint.Query() + query.Set("serviceName", service) + query.Set("groupName", "NEKIRO") + query.Set("clusters", "DEFAULT") + query.Set("namespaceId", "nekiro") + query.Set("healthyOnly", "false") + endpoint.RawQuery = query.Encode() + result := doRequest(t, client, endpoint.String(), http.MethodGet, "", "", nil) + if result.status != http.StatusOK || !bytes.Contains(result.body, []byte(`"nekiro.instanceId":"`+instanceID+`"`)) || !bytes.Contains(result.body, []byte(`"healthy":true`)) { + t.Fatalf("Nacos registration %s status=%d body=%s", service, result.status, result.body) + } + } +} + +func publishRouterNacosBindings(t *testing.T, client *http.Client, env acceptanceEnv) { t.Helper() agentIDs := make([]string, 0, len(env.releases)) for agentID := range env.releases { agentIDs = append(agentIDs, agentID) } sort.Strings(agentIDs) - document := contracts.RouterInstanceDirectoryV1{SchemaVersion: contracts.RouterInstanceDirectorySchemaVersion, Revision: "stack-runtime-1", Targets: make([]contracts.RouterInstanceDirectoryTargetV1, 0, len(agentIDs))} + document := contracts.RouterNacosInstanceBindingsV1{ + SchemaVersion: contracts.RouterNacosInstanceBindingsSchemaVersion, + Revision: "stack-runtime-1", + Targets: make([]contracts.RouterNacosInstanceBindingTargetV1, 0, len(agentIDs)), + } for _, agentID := range agentIDs { release := env.releases[agentID] origin, err := url.Parse(release.EndpointOrigin) if err != nil { t.Fatalf("parse Release endpoint origin: %v", err) } - port, err := strconv.Atoi(origin.Port()) - if err != nil || port < 1 || port > 65535 { - t.Fatalf("Release endpoint origin has invalid port: %s", release.EndpointOrigin) + serviceName := origin.Hostname() + if serviceName != "runtime-a" && serviceName != "runtime-b" { + t.Fatalf("Release endpoint origin has no owned Nacos service: %s", release.EndpointOrigin) } - address := origin.Hostname() - instanceID := agentID + "-primary" - if agentID == "runtime-b" { - address = "runtime-b-directory" - instanceID = "runtime-b-directory" - } - document.Targets = append(document.Targets, contracts.RouterInstanceDirectoryTargetV1{ + document.Targets = append(document.Targets, contracts.RouterNacosInstanceBindingTargetV1{ AgentID: agentID, AgentCardVersion: release.AgentCardVersion, ReleaseID: release.ReleaseID, CardDigest: release.CardDigest, CanonicalEndpoint: release.EndpointOrigin + release.EndpointPath, - Audience: release.EndpointOrigin, - Instances: []contracts.RouterInstanceV1{{ - InstanceID: instanceID, Ready: true, Serving: true, - Endpoints: []contracts.RouterNetworkEndpointV1{{AddressType: "DNS", Address: address, PortName: "a2a", Port: port, Protocol: "TCP"}}, - }}, + Audience: release.EndpointOrigin, ServiceName: serviceName, GroupName: "NEKIRO", ClusterName: "DEFAULT", }) } payload, err := json.Marshal(document) if err != nil { t.Fatal(err) } - const leaf = "cfg-v1-cm91dGVyL2luc3RhbmNlLWRpcmVjdG9yeQ.value" - temporary := filepath.Join(env.configCenterRoot, leaf+".tmp") - destination := filepath.Join(env.configCenterRoot, leaf) - if err := os.WriteFile(temporary, payload, 0o644); err != nil { - t.Fatalf("write Router instance directory: %v", err) + form := url.Values{"dataId": {"router.nacos-bindings"}, "group": {"NEKIRO"}, "tenant": {"nekiro"}, "type": {"json"}, "content": {string(payload)}} + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, env.nacosURL+"/v1/cs/configs", strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) } - if err := os.Chmod(temporary, 0o644); err != nil { - t.Fatalf("make Router instance directory readable by the Router container: %v", err) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := client.Do(request) + if err != nil { + t.Fatalf("publish Router Nacos bindings: %v", err) } - if err := os.Rename(temporary, destination); err != nil { - t.Fatalf("publish Router instance directory: %v", err) + body, readErr := io.ReadAll(io.LimitReader(response.Body, 4097)) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil || response.StatusCode != http.StatusOK || len(body) > 4096 || strings.TrimSpace(string(body)) != "true" { + t.Fatalf("publish Router Nacos bindings status=%d body=%s read=%v close=%v", response.StatusCode, body, readErr, closeErr) } }