diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce60bae..a1e5868 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,13 +81,13 @@ jobs: cache-dependency-path: go.sum - name: Test Runtime B - run: go test -count=1 ./runtime-b/... ./internal/nacosregistration/... + run: go test -count=1 ./runtime-b/... - name: Race test Runtime B - run: go test -race ./runtime-b/... ./internal/nacosregistration/... + run: go test -race ./runtime-b/... - name: Vet Runtime B - run: go vet ./runtime-b/... ./internal/nacosregistration/... + run: go vet ./runtime-b/... images: runs-on: ubuntu-latest diff --git a/README.md b/README.md index b2fc61a..6af7c72 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ contract/SDK source. The two samples deliberately do not import each other. Their shared platform behavior is limited to public Core contracts, the public Go SDK, and the A2A -wire profile. `internal/challengeproof` contains only sample-owned endpoint -ownership proof handling. +wire profile. Both provider entrypoints use the public SDK's +`agent/registration/nacos` package; `internal/challengeproof` contains only +sample-owned endpoint ownership proof handling. ## Repository verification diff --git a/go.mod b/go.mod index 407c95e..7ced533 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/NeKiro-project/NeKiro-Samples go 1.26.0 require ( - github.com/NeKiro-project/NeKiro v0.0.0-20260810043416-3e815b89cb37 - github.com/NeKiro-project/nekiro-sdk-go v0.0.0-20260811153316-3f4c32d6e895 + github.com/NeKiro-project/NeKiro v0.0.0-20260811175413-b588c754e369 + github.com/NeKiro-project/nekiro-sdk-go v0.0.0-20260811200209-d7e06dff6115 github.com/a2aproject/a2a-go v0.3.15 github.com/golang-jwt/jwt/v5 v5.3.1 trpc.group/trpc-go/trpc-agent-go v1.10.0 diff --git a/go.sum b/go.sum index e8afca6..8239cae 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ 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-sdk-go v0.0.0-20260811153316-3f4c32d6e895 h1:In02gBJq2ZHUdfjcPYrz2BePtJOfdscNBxQOiL8mu14= -github.com/NeKiro-project/nekiro-sdk-go v0.0.0-20260811153316-3f4c32d6e895/go.mod h1:lxAQLsSVXE3lmWoz+SMDKsDOc1b0Vj7okAGNkE7KFRI= +github.com/NeKiro-project/NeKiro v0.0.0-20260811175413-b588c754e369 h1:eL4X7jgfAOHB9zqocNmpOfLvqUk1JWrJ2HdAbiGH4HQ= +github.com/NeKiro-project/NeKiro v0.0.0-20260811175413-b588c754e369/go.mod h1:JCIEeiLu52WC/Q5QlcAKmWKRtW7CNLkZ3lV3BAn92Oo= +github.com/NeKiro-project/nekiro-sdk-go v0.0.0-20260811200209-d7e06dff6115 h1:/baMQ7BljeY5DH3uMHvwxirR60zzltymxPr2zbhUxCg= +github.com/NeKiro-project/nekiro-sdk-go v0.0.0-20260811200209-d7e06dff6115/go.mod h1:45UMR7aQNvyLhpWH9vuk2HSPE469m7eWoDZS+HN1X20= 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/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= diff --git a/internal/nacosregistration/config.go b/internal/nacosregistration/config.go deleted file mode 100644 index 1a034f7..0000000 --- a/internal/nacosregistration/config.go +++ /dev/null @@ -1,296 +0,0 @@ -package nacosregistration - -import ( - "fmt" - "net" - "net/url" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/NeKiro-project/NeKiro/registry" -) - -const ( - ModeDisabled = "disabled" - ModeNacos = "nacos" - AuthNone = "none" - AuthAccessToken = "access_token" - minimumMillis = 100 - maximumMillis = 60000 -) - -var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) - -type Config struct { - Mode string - AgentID string - InstanceID string - AgentCardVersion string - ReleaseID string - CardDigest string - CanonicalEndpoint string - Audience string - APIOrigin string - NamespaceID string - GroupName string - ServiceName string - ClusterName string - PortName string - AdvertisedIP string - AdvertisedPort int - Weight float64 - HeartbeatInterval time.Duration - HeartbeatTimeout time.Duration - IPDeleteTimeout time.Duration - RequestTimeout time.Duration - AuthMode string - AccessToken string - TLSCAFile string - TLSServerName string - TLSClientCertFile string - TLSClientKeyFile string -} - -func Load(lookup func(string) (string, bool), prefix, agentID, instanceID string) (Config, error) { - if lookup == nil || !identifierPattern.MatchString(agentID) || !identifierPattern.MatchString(instanceID) || prefix != "RUNTIME_A" && prefix != "RUNTIME_B" { - return Config{}, errorsFor(prefix, "registration dependencies are invalid") - } - name := func(suffix string) string { return prefix + "_" + suffix } - mode, err := required(lookup, name("REGISTRATION_MODE")) - if err != nil { - return Config{}, err - } - config := Config{Mode: mode, AgentID: agentID, InstanceID: instanceID} - nacosSuffixes := []string{ - "AGENT_CARD_VERSION", "RELEASE_ID", "CARD_DIGEST", "CANONICAL_ENDPOINT", "AUDIENCE", - "NACOS_API_ORIGIN", "NACOS_NAMESPACE_ID", "NACOS_GROUP_NAME", "NACOS_SERVICE_NAME", "NACOS_CLUSTER_NAME", "NACOS_PORT_NAME", - "NACOS_ADVERTISED_IP", "NACOS_ADVERTISED_PORT", "NACOS_WEIGHT", "NACOS_HEARTBEAT_INTERVAL_MS", "NACOS_HEARTBEAT_TIMEOUT_MS", - "NACOS_IP_DELETE_TIMEOUT_MS", "NACOS_REQUEST_TIMEOUT_MS", "NACOS_AUTH_MODE", "NACOS_ACCESS_TOKEN", - "NACOS_TLS_CA_FILE", "NACOS_TLS_SERVER_NAME", "NACOS_TLS_CLIENT_CERT_FILE", "NACOS_TLS_CLIENT_KEY_FILE", - } - if mode == ModeDisabled { - for _, suffix := range nacosSuffixes { - if _, exists := lookup(name(suffix)); exists { - return Config{}, fmt.Errorf("%s must be absent when registration is disabled", name(suffix)) - } - } - return config, nil - } - if mode != ModeNacos { - return Config{}, fmt.Errorf("%s is unsupported", name("REGISTRATION_MODE")) - } - for environment, destination := range map[string]*string{ - name("AGENT_CARD_VERSION"): &config.AgentCardVersion, - name("RELEASE_ID"): &config.ReleaseID, - name("CARD_DIGEST"): &config.CardDigest, - name("CANONICAL_ENDPOINT"): &config.CanonicalEndpoint, - name("AUDIENCE"): &config.Audience, - } { - *destination, err = required(lookup, environment) - if err != nil { - return Config{}, err - } - } - if config.APIOrigin, err = required(lookup, name("NACOS_API_ORIGIN")); err != nil { - return Config{}, err - } - if err := validateOrigin(config.APIOrigin, name("NACOS_API_ORIGIN")); err != nil { - return Config{}, err - } - parsedOrigin, _ := url.Parse(config.APIOrigin) - tlsNames := []string{name("NACOS_TLS_CA_FILE"), name("NACOS_TLS_SERVER_NAME"), name("NACOS_TLS_CLIENT_CERT_FILE"), name("NACOS_TLS_CLIENT_KEY_FILE")} - if parsedOrigin.Scheme == "http" { - for _, environment := range tlsNames { - if _, exists := lookup(environment); exists { - return Config{}, fmt.Errorf("%s must be absent for HTTP Nacos registration", environment) - } - } - } else { - if config.TLSCAFile, err = required(lookup, tlsNames[0]); err != nil { - return Config{}, err - } - if !validTLSPath(config.TLSCAFile) { - return Config{}, fmt.Errorf("%s must be a clean absolute path", tlsNames[0]) - } - if config.TLSServerName, err = required(lookup, tlsNames[1]); err != nil { - return Config{}, err - } - var certExists, keyExists bool - config.TLSClientCertFile, certExists = lookup(tlsNames[2]) - config.TLSClientKeyFile, keyExists = lookup(tlsNames[3]) - if certExists != keyExists || certExists && (!validTLSPath(config.TLSClientCertFile) || !validTLSPath(config.TLSClientKeyFile)) { - return Config{}, fmt.Errorf("%s and %s must be a complete non-empty pair", tlsNames[2], tlsNames[3]) - } - if !validTLSServerName(config.TLSServerName) { - return Config{}, fmt.Errorf("%s must be a valid DNS name or IP address", tlsNames[1]) - } - } - for environment, destination := range map[string]*string{ - name("NACOS_NAMESPACE_ID"): &config.NamespaceID, - name("NACOS_GROUP_NAME"): &config.GroupName, - name("NACOS_SERVICE_NAME"): &config.ServiceName, - name("NACOS_CLUSTER_NAME"): &config.ClusterName, - name("NACOS_PORT_NAME"): &config.PortName, - } { - *destination, err = requiredIdentifier(lookup, environment) - if err != nil { - return Config{}, err - } - } - config.AdvertisedIP, err = required(lookup, name("NACOS_ADVERTISED_IP")) - parsedIP := net.ParseIP(config.AdvertisedIP) - if err != nil || parsedIP == nil || parsedIP.String() != config.AdvertisedIP { - return Config{}, fmt.Errorf("%s must be a canonical IP address", name("NACOS_ADVERTISED_IP")) - } - port, err := requiredUnsigned(lookup, name("NACOS_ADVERTISED_PORT"), 1, 65535) - if err != nil { - return Config{}, err - } - config.AdvertisedPort = int(port) - weight, err := requiredUnsigned(lookup, name("NACOS_WEIGHT"), 1, 10000) - if err != nil { - return Config{}, err - } - config.Weight = float64(weight) - if config.HeartbeatInterval, err = requiredDuration(lookup, name("NACOS_HEARTBEAT_INTERVAL_MS"), 1000, maximumMillis); err != nil { - return Config{}, err - } - if config.HeartbeatTimeout, err = requiredDuration(lookup, name("NACOS_HEARTBEAT_TIMEOUT_MS"), 1001, 300000); err != nil { - return Config{}, err - } - if config.IPDeleteTimeout, err = requiredDuration(lookup, name("NACOS_IP_DELETE_TIMEOUT_MS"), 1002, 600000); err != nil { - return Config{}, err - } - if config.RequestTimeout, err = requiredDuration(lookup, name("NACOS_REQUEST_TIMEOUT_MS"), minimumMillis, maximumMillis); err != nil { - return Config{}, err - } - config.AuthMode, err = required(lookup, name("NACOS_AUTH_MODE")) - if err != nil { - return Config{}, err - } - switch config.AuthMode { - case AuthNone: - if _, exists := lookup(name("NACOS_ACCESS_TOKEN")); exists { - return Config{}, fmt.Errorf("%s must be absent when Nacos authentication is none", name("NACOS_ACCESS_TOKEN")) - } - case AuthAccessToken: - config.AccessToken, err = required(lookup, name("NACOS_ACCESS_TOKEN")) - if err != nil { - return Config{}, err - } - default: - return Config{}, fmt.Errorf("%s is unsupported", name("NACOS_AUTH_MODE")) - } - return config, config.Validate() -} - -func (config Config) Validate() error { - if !identifierPattern.MatchString(config.AgentID) || !identifierPattern.MatchString(config.InstanceID) { - return errorsFor("runtime", "registration identity is invalid") - } - if config.Mode == ModeDisabled { - return nil - } - if config.Mode != ModeNacos || validateOrigin(config.APIOrigin, "Nacos API origin") != nil || !identifierPattern.MatchString(config.NamespaceID) || !identifierPattern.MatchString(config.GroupName) || !identifierPattern.MatchString(config.ServiceName) || !identifierPattern.MatchString(config.ClusterName) || !identifierPattern.MatchString(config.PortName) { - return errorsFor("runtime", "Nacos registration tuple is invalid") - } - if _, err := registry.NewReleaseTarget(registry.ReleaseTargetInput{ - AgentID: config.AgentID, AgentCardVersion: config.AgentCardVersion, ReleaseID: config.ReleaseID, - CardDigest: config.CardDigest, CanonicalEndpoint: config.CanonicalEndpoint, Audience: config.Audience, - }); err != nil { - return errorsFor("runtime", "exact Release target is invalid") - } - parsedIP := net.ParseIP(config.AdvertisedIP) - if parsedIP == nil || parsedIP.String() != config.AdvertisedIP || config.AdvertisedPort < 1 || config.AdvertisedPort > 65535 || config.Weight < 1 || config.Weight > 10000 || config.Weight != float64(int(config.Weight)) || config.HeartbeatInterval < time.Second || config.HeartbeatInterval > time.Minute || config.HeartbeatTimeout <= config.HeartbeatInterval || config.HeartbeatTimeout > 5*time.Minute || config.IPDeleteTimeout <= config.HeartbeatTimeout || config.IPDeleteTimeout > 10*time.Minute || config.RequestTimeout < minimumMillis*time.Millisecond || config.RequestTimeout > maximumMillis*time.Millisecond { - return errorsFor("runtime", "Nacos registration endpoint or timing is invalid") - } - if config.AuthMode != AuthNone && config.AuthMode != AuthAccessToken || config.AuthMode == AuthNone && config.AccessToken != "" || config.AuthMode == AuthAccessToken && strings.TrimSpace(config.AccessToken) == "" { - return errorsFor("runtime", "Nacos authentication configuration is invalid") - } - parsedOrigin, _ := url.Parse(config.APIOrigin) - if parsedOrigin.Scheme == "http" && (config.TLSCAFile != "" || config.TLSServerName != "" || config.TLSClientCertFile != "" || config.TLSClientKeyFile != "") { - return errorsFor("runtime", "Nacos HTTP registration cannot contain TLS configuration") - } - if parsedOrigin.Scheme == "https" && (!validTLSPath(config.TLSCAFile) || !validTLSServerName(config.TLSServerName) || (config.TLSClientCertFile == "") != (config.TLSClientKeyFile == "") || config.TLSClientCertFile != "" && (!validTLSPath(config.TLSClientCertFile) || !validTLSPath(config.TLSClientKeyFile))) { - return errorsFor("runtime", "Nacos HTTPS registration TLS configuration is invalid") - } - return nil -} - -func validTLSPath(value string) bool { - return value != "" && strings.TrimSpace(value) == value && filepath.IsAbs(value) && filepath.Clean(value) == value -} - -func validTLSServerName(value string) bool { - if parsed := net.ParseIP(value); parsed != nil { - return parsed.String() == value - } - if len(value) == 0 || len(value) > 253 || value != strings.ToLower(value) || strings.Contains(value, "..") || strings.Contains(value, ":") { - return false - } - if strings.Trim(value, "0123456789.") == "" { - return false - } - for _, label := range strings.Split(value, ".") { - if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return false - } - for _, character := range label { - if character != '-' && (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') { - return false - } - } - } - return true -} - -func required(lookup func(string) (string, bool), name string) (string, error) { - value, exists := lookup(name) - if !exists || value == "" || strings.TrimSpace(value) != value { - return "", fmt.Errorf("%s is required and must contain no surrounding whitespace", name) - } - return value, nil -} - -func requiredIdentifier(lookup func(string) (string, bool), name string) (string, error) { - value, err := required(lookup, name) - if err != nil || !identifierPattern.MatchString(value) { - return "", fmt.Errorf("%s must be a safe identifier", name) - } - return value, nil -} - -func requiredUnsigned(lookup func(string) (string, bool), name string, minimum, maximum int64) (int64, error) { - value, err := required(lookup, name) - if err != nil { - return 0, err - } - for _, character := range value { - if character < '0' || character > '9' { - return 0, fmt.Errorf("%s must be an unsigned base-10 integer", name) - } - } - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil || parsed < minimum || parsed > maximum { - return 0, fmt.Errorf("%s must be an integer from %d through %d", name, minimum, maximum) - } - return parsed, nil -} - -func requiredDuration(lookup func(string) (string, bool), name string, minimum, maximum int64) (time.Duration, error) { - value, err := requiredUnsigned(lookup, name, minimum, maximum) - return time.Duration(value) * time.Millisecond, err -} - -func validateOrigin(value, name string) error { - parsed, err := url.Parse(value) - if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "/nacos" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.RawFragment != "" { - return fmt.Errorf("%s must be an HTTP(S) URL with the exact /nacos path", name) - } - return nil -} - -func errorsFor(owner, message string) error { return fmt.Errorf("%s %s", owner, message) } diff --git a/internal/nacosregistration/config_test.go b/internal/nacosregistration/config_test.go deleted file mode 100644 index f8ae149..0000000 --- a/internal/nacosregistration/config_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package nacosregistration - -import ( - "path/filepath" - "testing" -) - -func TestLoadRequiresExactReleaseAndExplicitFreshness(t *testing.T) { - values := validEnvironment() - config, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary") - if err != nil { - t.Fatal(err) - } - if config.ReleaseID != "rel_runtime_b_1" || config.PortName != "a2a" || config.HeartbeatTimeout.Milliseconds() != 5000 || config.IPDeleteTimeout.Milliseconds() != 10000 { - t.Fatalf("config=%#v", config) - } - for _, name := range []string{ - "RUNTIME_B_RELEASE_ID", "RUNTIME_B_CARD_DIGEST", "RUNTIME_B_CANONICAL_ENDPOINT", "RUNTIME_B_AUDIENCE", - "RUNTIME_B_NACOS_PORT_NAME", "RUNTIME_B_NACOS_WEIGHT", "RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS", "RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS", - } { - invalid := validEnvironment() - delete(invalid, name) - if _, err := Load(mapLookup(invalid), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { - t.Errorf("missing %s was accepted", name) - } - } -} - -func TestLoadRequiresExplicitHTTPSRegistrationTrust(t *testing.T) { - values := validEnvironment() - values["RUNTIME_B_NACOS_API_ORIGIN"] = "https://nacos.internal:8848/nacos" - values["RUNTIME_B_NACOS_TLS_CA_FILE"] = filepath.Join(t.TempDir(), "ca.pem") - values["RUNTIME_B_NACOS_TLS_SERVER_NAME"] = "nacos.internal" - config, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary") - if err != nil || config.TLSCAFile == "" || config.TLSServerName != "nacos.internal" { - t.Fatalf("HTTPS config=%#v error=%v", config, err) - } - - for name, mutate := range map[string]func(map[string]string){ - "missing CA": func(values map[string]string) { delete(values, "RUNTIME_B_NACOS_TLS_CA_FILE") }, - "missing server name": func(values map[string]string) { delete(values, "RUNTIME_B_NACOS_TLS_SERVER_NAME") }, - "relative CA": func(values map[string]string) { values["RUNTIME_B_NACOS_TLS_CA_FILE"] = "ca.pem" }, - "invalid server name": func(values map[string]string) { values["RUNTIME_B_NACOS_TLS_SERVER_NAME"] = "nacos_internal" }, - "client cert only": func(values map[string]string) { - values["RUNTIME_B_NACOS_TLS_CLIENT_CERT_FILE"] = filepath.Join(t.TempDir(), "client.pem") - }, - "client key only": func(values map[string]string) { - values["RUNTIME_B_NACOS_TLS_CLIENT_KEY_FILE"] = filepath.Join(t.TempDir(), "client-key.pem") - }, - } { - t.Run(name, func(t *testing.T) { - invalid := make(map[string]string, len(values)) - for key, value := range values { - invalid[key] = value - } - mutate(invalid) - if _, err := Load(mapLookup(invalid), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { - t.Fatal("invalid HTTPS registration trust was accepted") - } - }) - } -} - -func TestLoadRejectsTLSFieldsForHTTPAndDisabledRegistration(t *testing.T) { - for _, mode := range []string{"http", "disabled"} { - t.Run(mode, func(t *testing.T) { - values := validEnvironment() - if mode == "disabled" { - values = map[string]string{"RUNTIME_B_REGISTRATION_MODE": ModeDisabled} - } - values["RUNTIME_B_NACOS_TLS_CA_FILE"] = filepath.Join(t.TempDir(), "ca.pem") - if _, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { - t.Fatal("non-HTTPS registration accepted TLS fields") - } - }) - } -} - -func TestLoadRejectsMismatchedTargetAndFreshnessOrder(t *testing.T) { - for name, mutate := range map[string]func(map[string]string){ - "audience": func(values map[string]string) { values["RUNTIME_B_AUDIENCE"] = "http://runtime-a:8091" }, - "heartbeat timeout": func(values map[string]string) { values["RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS"] = "1000" }, - "delete timeout": func(values map[string]string) { values["RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS"] = "5000" }, - } { - t.Run(name, func(t *testing.T) { - values := validEnvironment() - mutate(values) - if _, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { - t.Fatal("invalid registration config was accepted") - } - }) - } -} - -func validEnvironment() map[string]string { - return map[string]string{ - "RUNTIME_B_REGISTRATION_MODE": "nacos", "RUNTIME_B_AGENT_CARD_VERSION": "1.0.0", "RUNTIME_B_RELEASE_ID": "rel_runtime_b_1", - "RUNTIME_B_CARD_DIGEST": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "RUNTIME_B_CANONICAL_ENDPOINT": "http://runtime-b:8092/", "RUNTIME_B_AUDIENCE": "http://runtime-b:8092", - "RUNTIME_B_NACOS_API_ORIGIN": "http://nacos:8848/nacos", "RUNTIME_B_NACOS_NAMESPACE_ID": "public", - "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": "127.0.0.1", "RUNTIME_B_NACOS_ADVERTISED_PORT": "8092", - "RUNTIME_B_NACOS_WEIGHT": "1", "RUNTIME_B_NACOS_HEARTBEAT_INTERVAL_MS": "1000", "RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS": "5000", - "RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS": "10000", "RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS": "1000", "RUNTIME_B_NACOS_AUTH_MODE": "none", - } -} - -func mapLookup(values map[string]string) func(string) (string, bool) { - return func(name string) (string, bool) { - value, ok := values[name] - return value, ok - } -} diff --git a/internal/nacosregistration/http_client.go b/internal/nacosregistration/http_client.go deleted file mode 100644 index 04ae124..0000000 --- a/internal/nacosregistration/http_client.go +++ /dev/null @@ -1,113 +0,0 @@ -package nacosregistration - -import ( - "crypto/tls" - "crypto/x509" - "encoding/pem" - "errors" - "io" - "net/http" - "net/url" - "os" - "strings" -) - -const maximumTLSMaterialBytes int64 = 1 << 20 - -// NewHTTPClient constructs the deployment-owned Nacos registration transport. -// HTTPS never falls back to system roots; all trust material is explicit. -func NewHTTPClient(config Config) (*http.Client, error) { - if config.Mode != ModeNacos || config.Validate() != nil { - return nil, errors.New("Nacos registration transport configuration is invalid") - } - origin, _ := url.Parse(config.APIOrigin) - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = nil - transport.DisableKeepAlives = true - transport.TLSClientConfig = nil - if origin.Scheme == "https" { - roots, err := loadCAPool(config.TLSCAFile) - if err != nil { - return nil, err - } - tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots, ServerName: config.TLSServerName} - if config.TLSClientCertFile != "" { - certificatePEM, err := readTLSMaterial(config.TLSClientCertFile, "client certificate") - if err != nil { - return nil, err - } - keyPEM, err := readTLSMaterial(config.TLSClientKeyFile, "client key") - if err != nil { - return nil, err - } - certificate, err := tls.X509KeyPair(certificatePEM, keyPEM) - if err != nil { - return nil, errors.New("Nacos TLS client certificate pair is invalid") - } - tlsConfig.Certificates = []tls.Certificate{certificate} - } - transport.TLSClientConfig = tlsConfig - } - return &http.Client{ - Transport: transport, - Timeout: config.RequestTimeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return errors.New("Nacos redirects are disabled") - }, - }, nil -} - -func loadCAPool(path string) (*x509.CertPool, error) { - content, err := readTLSMaterial(path, "CA") - if err != nil { - return nil, err - } - pool := x509.NewCertPool() - certificates := 0 - for len(strings.TrimSpace(string(content))) != 0 { - block, rest := pem.Decode(content) - if block == nil || block.Type != "CERTIFICATE" { - return nil, errors.New("Nacos TLS CA material is invalid") - } - parsed, err := x509.ParseCertificates(block.Bytes) - if err != nil || len(parsed) == 0 { - return nil, errors.New("Nacos TLS CA material is invalid") - } - for _, certificate := range parsed { - if !certificate.IsCA || !certificate.BasicConstraintsValid || certificate.KeyUsage&x509.KeyUsageCertSign == 0 { - return nil, errors.New("Nacos TLS CA material is invalid") - } - pool.AddCert(certificate) - certificates++ - } - content = rest - } - if certificates == 0 { - return nil, errors.New("Nacos TLS CA material is invalid") - } - return pool, nil -} - -func readTLSMaterial(path, kind string) ([]byte, error) { - pathInfo, err := os.Lstat(path) - if err != nil { - return nil, errors.New("Nacos TLS " + kind + " material is unavailable") - } - if !pathInfo.Mode().IsRegular() || pathInfo.Size() <= 0 || pathInfo.Size() > maximumTLSMaterialBytes { - return nil, errors.New("Nacos TLS " + kind + " material is invalid") - } - file, err := os.Open(path) - if err != nil { - return nil, errors.New("Nacos TLS " + kind + " material is unavailable") - } - defer file.Close() - info, err := file.Stat() - if err != nil || !info.Mode().IsRegular() || !os.SameFile(pathInfo, info) || info.Size() <= 0 || info.Size() > maximumTLSMaterialBytes { - return nil, errors.New("Nacos TLS " + kind + " material is invalid") - } - content, err := io.ReadAll(io.LimitReader(file, maximumTLSMaterialBytes+1)) - if err != nil || int64(len(content)) != info.Size() || int64(len(content)) > maximumTLSMaterialBytes { - return nil, errors.New("Nacos TLS " + kind + " material is invalid") - } - return content, nil -} diff --git a/internal/nacosregistration/http_client_test.go b/internal/nacosregistration/http_client_test.go deleted file mode 100644 index a754c98..0000000 --- a/internal/nacosregistration/http_client_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package nacosregistration - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestNewHTTPClientDisablesAmbientNetworkBehavior(t *testing.T) { - config := validHTTPClientConfig() - client, err := NewHTTPClient(config) - if err != nil { - t.Fatal(err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok || transport.Proxy != nil || transport.TLSClientConfig != nil || !transport.DisableKeepAlives || client.Timeout != time.Second { - t.Fatalf("client=%#v transport=%#v", client, transport) - } - if err := client.CheckRedirect(httptest.NewRequest(http.MethodGet, "http://nacos.test/next", nil), nil); err == nil { - t.Fatal("redirect was accepted") - } -} - -func TestNewHTTPClientAuthenticatesPrivateCAAndOptionalClient(t *testing.T) { - material := newTLSMaterial(t) - for _, test := range []struct { - name, caFile, serverName string - clientCertificate bool - requireClient bool - wantError bool - }{ - {name: "TLS", caFile: material.caFile, serverName: "nacos.internal"}, - {name: "mTLS", caFile: material.caFile, serverName: "nacos.internal", clientCertificate: true, requireClient: true}, - {name: "wrong CA", caFile: newTLSMaterial(t).caFile, serverName: "nacos.internal", wantError: true}, - {name: "wrong server name", caFile: material.caFile, serverName: "other.internal", wantError: true}, - {name: "missing client certificate", caFile: material.caFile, serverName: "nacos.internal", requireClient: true, wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) })) - server.TLS = material.serverTLS(test.requireClient) - server.StartTLS() - defer server.Close() - - config := validHTTPClientConfig() - config.APIOrigin = "https://nacos.internal:8848/nacos" - config.TLSCAFile = test.caFile - config.TLSServerName = test.serverName - if test.clientCertificate { - config.TLSClientCertFile = material.clientCertFile - config.TLSClientKeyFile = material.clientKeyFile - } - client, err := NewHTTPClient(config) - if err != nil { - t.Fatal(err) - } - response, err := client.Get(server.URL) - if response != nil { - _ = response.Body.Close() - } - if (err != nil) != test.wantError { - t.Fatalf("request error=%v wantError=%v", err, test.wantError) - } - }) - } -} - -func TestNewHTTPClientRejectsUnsafeMaterialWithoutPathLeakage(t *testing.T) { - directory := t.TempDir() - secretPath := filepath.Join(directory, "secret-marker.pem") - if err := os.WriteFile(secretPath, []byte("not a certificate"), 0o600); err != nil { - t.Fatal(err) - } - config := validHTTPClientConfig() - config.APIOrigin = "https://nacos.internal:8848/nacos" - config.TLSCAFile = secretPath - config.TLSServerName = "nacos.internal" - if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), secretPath) || strings.Contains(err.Error(), "not a certificate") { - t.Fatalf("unsafe or leaking error=%v", err) - } - config.TLSCAFile = directory - if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), directory) { - t.Fatalf("non-regular material error=%v", err) - } - for name, content := range map[string][]byte{ - "empty": {}, - "oversized": make([]byte, maximumTLSMaterialBytes+1), - } { - t.Run(name, func(t *testing.T) { - path := filepath.Join(directory, name+".pem") - if err := os.WriteFile(path, content, 0o600); err != nil { - t.Fatal(err) - } - config.TLSCAFile = path - if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), path) { - t.Fatalf("bounded material error=%v", err) - } - }) - } -} - -func validHTTPClientConfig() Config { - config, err := Load(mapLookup(validEnvironment()), "RUNTIME_B", "runtime-b", "runtime-b-primary") - if err != nil { - panic(err) - } - return config -} - -type tlsMaterial struct { - caFile, clientCertFile, clientKeyFile string - serverCertificate tls.Certificate - caPool *x509.CertPool -} - -func newTLSMaterial(t *testing.T) tlsMaterial { - t.Helper() - directory := t.TempDir() - caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - now := time.Now() - caTemplate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "NeKiro test CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} - caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPublic, caPrivate) - if err != nil { - t.Fatal(err) - } - caCertificate, err := x509.ParseCertificate(caDER) - if err != nil { - t.Fatal(err) - } - caFile := filepath.Join(directory, "ca.pem") - if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0o600); err != nil { - t.Fatal(err) - } - issue := func(name string, serial int64, usage x509.ExtKeyUsage, dnsNames []string) (string, string, tls.Certificate) { - public, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: name}, DNSNames: dnsNames, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage}} - der, err := x509.CreateCertificate(rand.Reader, template, caCertificate, public, caPrivate) - if err != nil { - t.Fatal(err) - } - certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyDER, err := x509.MarshalPKCS8PrivateKey(private) - if err != nil { - t.Fatal(err) - } - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) - certificateFile, keyFile := filepath.Join(directory, name+".pem"), filepath.Join(directory, name+"-key.pem") - if err := os.WriteFile(certificateFile, certificatePEM, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil { - t.Fatal(err) - } - certificate, err := tls.X509KeyPair(certificatePEM, keyPEM) - if err != nil { - t.Fatal(err) - } - return certificateFile, keyFile, certificate - } - _, _, serverCertificate := issue("server", 2, x509.ExtKeyUsageServerAuth, []string{"nacos.internal"}) - clientCertificateFile, clientKeyFile, _ := issue("client", 3, x509.ExtKeyUsageClientAuth, nil) - pool := x509.NewCertPool() - pool.AddCert(caCertificate) - return tlsMaterial{caFile: caFile, clientCertFile: clientCertificateFile, clientKeyFile: clientKeyFile, serverCertificate: serverCertificate, caPool: pool} -} - -func (material tlsMaterial) serverTLS(requireClient bool) *tls.Config { - configuration := &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{material.serverCertificate}} - if requireClient { - configuration.ClientAuth = tls.RequireAndVerifyClientCert - configuration.ClientCAs = material.caPool - } - return configuration -} diff --git a/internal/nacosregistration/registration.go b/internal/nacosregistration/registration.go deleted file mode 100644 index 2a9ea94..0000000 --- a/internal/nacosregistration/registration.go +++ /dev/null @@ -1,130 +0,0 @@ -package nacosregistration - -import ( - "context" - "errors" - "fmt" - "net" - "sync" - - "github.com/NeKiro-project/NeKiro/registry" - registrynacos "github.com/NeKiro-project/NeKiro/registry/nacos" -) - -type HTTPDoer = registrynacos.RequestExecutor - -type Registration struct { - registrar registry.InstanceRegistrar - registration registry.Registration - - mu sync.RWMutex - lease registry.InstanceLease -} - -func New(config Config, executor HTTPDoer) (*Registration, error) { - if config.Mode != ModeNacos || executor == nil || config.Validate() != nil { - return nil, errors.New("Nacos registration dependencies are invalid") - } - target, _ := registry.NewReleaseTarget(registry.ReleaseTargetInput{ - AgentID: config.AgentID, AgentCardVersion: config.AgentCardVersion, ReleaseID: config.ReleaseID, - CardDigest: config.CardDigest, CanonicalEndpoint: config.CanonicalEndpoint, Audience: config.Audience, - }) - binding, err := registrynacos.NewBinding(registrynacos.BindingInput{ - Target: target, ServiceName: config.ServiceName, GroupName: config.GroupName, ClusterName: config.ClusterName, - }) - if err != nil { - return nil, errors.New("Nacos registration binding is invalid") - } - addressType := registry.AddressTypeIPv4 - if parsed := net.ParseIP(config.AdvertisedIP); parsed != nil && parsed.To4() == nil { - addressType = registry.AddressTypeIPv6 - } - endpoint, err := registry.NewNetworkEndpoint(registry.NetworkEndpointInput{ - AddressType: addressType, Address: config.AdvertisedIP, PortName: config.PortName, - Port: config.AdvertisedPort, Protocol: registry.TransportProtocolTCP, - }) - if err != nil { - return nil, errors.New("Nacos registration endpoint is invalid") - } - weight := int(config.Weight) - instance, err := registry.NewInstance(registry.InstanceInput{ - ID: config.InstanceID, Endpoints: []registry.NetworkEndpoint{endpoint}, Ready: true, Serving: true, Weight: &weight, - }) - if err != nil { - return nil, errors.New("Nacos registration instance is invalid") - } - registration, err := registry.NewRegistration(registry.RegistrationInput{Target: target, Instance: instance}) - if err != nil { - return nil, errors.New("Nacos registration is invalid") - } - registrar, err := registrynacos.NewRegistrar(registrynacos.RegistrarConfig{ - APIOrigin: config.APIOrigin, NamespaceID: config.NamespaceID, Binding: binding, PortName: config.PortName, - Weight: config.Weight, HeartbeatInterval: config.HeartbeatInterval, HeartbeatTimeout: config.HeartbeatTimeout, - IPDeleteTimeout: config.IPDeleteTimeout, AuthMode: config.AuthMode, AccessToken: config.AccessToken, Executor: executor, - }) - if err != nil { - return nil, errors.New("Nacos registrar configuration is invalid") - } - return &Registration{registrar: registrar, registration: registration}, nil -} - -func (value *Registration) Register(ctx context.Context) error { - value.mu.Lock() - defer value.mu.Unlock() - if value.lease != nil { - return errors.New("Nacos registration has already started") - } - lease, err := value.registrar.Register(ctx, value.registration) - if err != nil { - return fmt.Errorf("register runtime with Nacos: %w", err) - } - value.lease = lease - return nil -} - -func (value *Registration) Run(ctx context.Context) error { - if ctx == nil { - return errors.New("Nacos registration context is required") - } - value.mu.RLock() - lease := value.lease - value.mu.RUnlock() - if lease == nil { - return errors.New("Nacos registration has not started") - } - select { - case <-ctx.Done(): - return nil - case <-lease.Done(): - return fmt.Errorf("Nacos registration lease terminated: %w", lease.Err()) - } -} - -func (value *Registration) Deregister(ctx context.Context) error { - if ctx == nil { - return errors.New("Nacos deregistration context is required") - } - value.mu.RLock() - lease := value.lease - value.mu.RUnlock() - var leaseErr error - if lease != nil { - leaseErr = lease.Close(ctx) - } - return errors.Join(leaseErr, value.registrar.Close()) -} - -func (value *Registration) Ready() bool { - value.mu.RLock() - lease := value.lease - value.mu.RUnlock() - if lease == nil { - return false - } - select { - case <-lease.Done(): - return false - default: - return true - } -} diff --git a/internal/nacosregistration/registration_test.go b/internal/nacosregistration/registration_test.go deleted file mode 100644 index 8e7937e..0000000 --- a/internal/nacosregistration/registration_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package nacosregistration - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" -) - -func TestRegistrationUsesCoreLeaseAndDeregisters(t *testing.T) { - var deletes atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodDelete { - deletes.Add(1) - } - _, _ = writer.Write([]byte("ok")) - })) - t.Cleanup(server.Close) - registration, err := New(validConfig(server.URL+"/nacos"), server.Client()) - if err != nil { - t.Fatal(err) - } - if registration.Ready() { - t.Fatal("registration was ready before publish") - } - if err := registration.Register(t.Context()); err != nil || !registration.Ready() { - t.Fatalf("register ready=%v error=%v", registration.Ready(), err) - } - if err := registration.Deregister(t.Context()); err != nil || registration.Ready() { - t.Fatalf("deregister ready=%v error=%v", registration.Ready(), err) - } - if err := registration.Deregister(t.Context()); err != nil || deletes.Load() != 1 { - t.Fatalf("idempotent deregister deletes=%d error=%v", deletes.Load(), err) - } -} - -func TestRegistrationLeaseFailureIsTerminal(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method == http.MethodPut { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = writer.Write([]byte("ok")) - })) - t.Cleanup(server.Close) - registration, err := New(validConfig(server.URL+"/nacos"), server.Client()) - if err != nil { - t.Fatal(err) - } - if err := registration.Register(t.Context()); err != nil { - t.Fatal(err) - } - if err := registration.Run(t.Context()); err == nil || registration.Ready() { - t.Fatalf("terminal lease error=%v ready=%v", err, registration.Ready()) - } - if err := registration.Deregister(t.Context()); err != nil { - t.Fatal(err) - } -} - -func validConfig(origin string) Config { - return Config{ - Mode: ModeNacos, AgentID: "runtime-b", InstanceID: "runtime-b-primary", AgentCardVersion: "1.0.0", ReleaseID: "rel_runtime_b_1", - CardDigest: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - CanonicalEndpoint: "http://runtime-b:8092/", Audience: "http://runtime-b:8092", APIOrigin: origin, - NamespaceID: "public", GroupName: "NEKIRO", ServiceName: "runtime-b", ClusterName: "DEFAULT", PortName: "a2a", - AdvertisedIP: "127.0.0.1", AdvertisedPort: 8092, Weight: 1, HeartbeatInterval: time.Second, - HeartbeatTimeout: 5 * time.Second, IPDeleteTimeout: 10 * time.Second, RequestTimeout: time.Second, AuthMode: AuthNone, - } -} diff --git a/runtime-a/README.md b/runtime-a/README.md index 70c5a4c..417afd4 100644 --- a/runtime-a/README.md +++ b/runtime-a/README.md @@ -32,9 +32,10 @@ provide the exact target fields `RUNTIME_A_AGENT_CARD_VERSION`, `RUNTIME_A_CANONICAL_ENDPOINT`, and `RUNTIME_A_AUDIENCE`; the Nacos tuple; `RUNTIME_A_NACOS_PORT_NAME`, advertised IP/port and weight; explicit heartbeat, heartbeat-timeout, IP-delete-timeout, and request-timeout values; and the -selected authentication mode. Runtime A uses Core's `InstanceRegistrar` and -`InstanceLease`, fails startup if the initial publish fails, becomes not-ready -and stops on terminal lease failure, and explicitly deregisters on shutdown. +selected authentication mode. Runtime A composes Core's `InstanceRegistrar` +and `InstanceLease` through the public SDK `agent/registration/nacos` package, +fails startup if the initial publish fails, becomes not-ready and stops on +terminal lease failure, and explicitly deregisters on shutdown. The `RUNTIME_A_NACOS_API_ORIGIN` scheme explicitly selects the registration transport. An `http` origin is controlled plaintext and every Nacos TLS field diff --git a/runtime-a/cmd/runtime-a/main.go b/runtime-a/cmd/runtime-a/main.go index 2912c47..8b1b76c 100644 --- a/runtime-a/cmd/runtime-a/main.go +++ b/runtime-a/cmd/runtime-a/main.go @@ -9,9 +9,9 @@ import ( "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" - "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimea "github.com/NeKiro-project/NeKiro-Samples/runtime-a" agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" + registrationnacos "github.com/NeKiro-project/nekiro-sdk-go/agent/registration/nacos" ) func main() { @@ -29,23 +29,13 @@ func runWithLookup(lookup func(string) (string, bool)) error { if err != nil { return agenthost.Wrap(agenthost.StageConfig, "load Runtime A configuration", err) } - registrationConfig, err := nacosregistration.Load(lookup, "RUNTIME_A", config.AgentID, config.InstanceID) + registrationConfig, err := registrationnacos.LoadConfig(lookup, "RUNTIME_A", config.AgentID, config.InstanceID) if err != nil { return agenthost.Wrap(agenthost.StageConfig, "load Runtime A registration configuration", err) } - var registration agenthost.Registration - var readiness runtimea.Readiness = ready(true) - if registrationConfig.Mode == nacosregistration.ModeNacos { - registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) - if clientErr != nil { - return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A Nacos transport", clientErr) - } - runtimeRegistration, err := nacosregistration.New(registrationConfig, registrationClient) - if err != nil { - return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err) - } - registration = runtimeRegistration - readiness = runtimeRegistration + registration, readiness, err := newRuntimeRegistration(registrationConfig) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err) } handler, err := runtimea.NewHandler(config, http.DefaultClient) if err != nil { @@ -75,3 +65,14 @@ func runWithLookup(lookup func(string) (string, bool)) error { type ready bool func (value ready) Ready() bool { return bool(value) } + +func newRuntimeRegistration(config registrationnacos.Config) (agenthost.Registration, runtimea.Readiness, error) { + if config.Mode == registrationnacos.ModeDisabled { + return nil, ready(true), nil + } + registration, err := registrationnacos.New(config) + if err != nil { + return nil, nil, err + } + return registration, registration, nil +} diff --git a/runtime-a/cmd/runtime-a/main_test.go b/runtime-a/cmd/runtime-a/main_test.go index 3a24294..438bc7d 100644 --- a/runtime-a/cmd/runtime-a/main_test.go +++ b/runtime-a/cmd/runtime-a/main_test.go @@ -4,6 +4,7 @@ import ( "testing" agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" + registrationnacos "github.com/NeKiro-project/nekiro-sdk-go/agent/registration/nacos" ) func TestRunStagesMissingRuntimeAConfiguration(t *testing.T) { @@ -13,3 +14,16 @@ func TestRunStagesMissingRuntimeAConfiguration(t *testing.T) { t.Fatalf("StageOf(run error) = %q, %v; error=%v", stage, ok, err) } } + +func TestNewRuntimeRegistrationUsesPublicSDKModes(t *testing.T) { + registration, readiness, err := newRuntimeRegistration(registrationnacos.Config{ + Mode: registrationnacos.ModeDisabled, AgentID: "runtime-a", InstanceID: "runtime-a-primary", + }) + if err != nil || registration != nil || !readiness.Ready() { + t.Fatalf("disabled registration=%v readiness=%v error=%v", registration, readiness, err) + } + registration, readiness, err = newRuntimeRegistration(registrationnacos.Config{Mode: registrationnacos.ModeNacos}) + if err == nil || registration != nil || readiness != nil { + t.Fatalf("invalid Nacos registration=%v readiness=%v error=%v", registration, readiness, err) + } +} diff --git a/runtime-b/README.md b/runtime-b/README.md index 0a8de9a..c324e47 100644 --- a/runtime-b/README.md +++ b/runtime-b/README.md @@ -63,10 +63,11 @@ the sample's JSON and SSE results so Stack acceptance can prove which replica handled an Invocation. It does not change the Agent ID, Release identity, Router credential audience, or nested-call authorization. -With `nacos` registration, Runtime B uses Core's provider-neutral -`InstanceRegistrar` and `InstanceLease` contracts. The ready instance is bound -to one exact Agent Card/Release target before serving, its freshness values are -explicitly published, and shutdown closes the lease and deregisters it. A +With `nacos` registration, Runtime B composes Core's provider-neutral +`InstanceRegistrar` and `InstanceLease` contracts through the public SDK +`agent/registration/nacos` package. The ready instance is bound to one exact +Agent Card/Release target before serving, its freshness values are explicitly +published, and shutdown closes the lease and deregisters it. A failed initial registration fails startup. A terminal heartbeat failure closes the lease, makes `/readyz` return `503`, and stops serving; there is no retry, alternate Nacos endpoint, stale lease, or Release fallback. diff --git a/runtime-b/cmd/runtime-b/main.go b/runtime-b/cmd/runtime-b/main.go index d86edce..c029c2c 100644 --- a/runtime-b/cmd/runtime-b/main.go +++ b/runtime-b/cmd/runtime-b/main.go @@ -9,9 +9,9 @@ import ( "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" - "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b" agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" + registrationnacos "github.com/NeKiro-project/nekiro-sdk-go/agent/registration/nacos" "github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth" ) @@ -38,23 +38,13 @@ func runWithLookup(lookup func(string) (string, bool)) error { if err != nil { return agenthost.Wrap(agenthost.StageConfig, "load Runtime B configuration", err) } - registrationConfig, err := runtimeb.LoadRegistrationConfig(lookup, config.AgentID, config.InstanceID) + registrationConfig, err := registrationnacos.LoadConfig(lookup, "RUNTIME_B", config.AgentID, config.InstanceID) if err != nil { return agenthost.Wrap(agenthost.StageConfig, "load Runtime B registration configuration", err) } - var registration agenthost.Registration - var readiness runtimeb.Readiness = ready(true) - if registrationConfig.Mode == runtimeb.RegistrationModeNacos { - registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) - if clientErr != nil { - return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B Nacos transport", clientErr) - } - runtimeRegistration, err := runtimeb.NewNacosRegistration(registrationConfig, registrationClient) - if err != nil { - return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err) - } - registration = runtimeRegistration - readiness = runtimeRegistration + registration, readiness, err := newRuntimeRegistration(registrationConfig) + if err != nil { + return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err) } handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient) if err != nil { @@ -88,3 +78,14 @@ func runWithLookup(lookup func(string) (string, bool)) error { type ready bool func (value ready) Ready() bool { return bool(value) } + +func newRuntimeRegistration(config registrationnacos.Config) (agenthost.Registration, runtimeb.Readiness, error) { + if config.Mode == registrationnacos.ModeDisabled { + return nil, ready(true), nil + } + registration, err := registrationnacos.New(config) + if err != nil { + return nil, nil, err + } + return registration, registration, nil +} diff --git a/runtime-b/cmd/runtime-b/main_test.go b/runtime-b/cmd/runtime-b/main_test.go index bfb9d3c..c0d97f6 100644 --- a/runtime-b/cmd/runtime-b/main_test.go +++ b/runtime-b/cmd/runtime-b/main_test.go @@ -4,6 +4,7 @@ import ( "testing" agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host" + registrationnacos "github.com/NeKiro-project/nekiro-sdk-go/agent/registration/nacos" ) func TestRunStagesMissingRuntimeBConfiguration(t *testing.T) { @@ -13,3 +14,16 @@ func TestRunStagesMissingRuntimeBConfiguration(t *testing.T) { t.Fatalf("StageOf(run error) = %q, %v; error=%v", stage, ok, err) } } + +func TestNewRuntimeRegistrationUsesPublicSDKModes(t *testing.T) { + registration, readiness, err := newRuntimeRegistration(registrationnacos.Config{ + Mode: registrationnacos.ModeDisabled, AgentID: "runtime-b", InstanceID: "runtime-b-primary", + }) + if err != nil || registration != nil || !readiness.Ready() { + t.Fatalf("disabled registration=%v readiness=%v error=%v", registration, readiness, err) + } + registration, readiness, err = newRuntimeRegistration(registrationnacos.Config{Mode: registrationnacos.ModeNacos}) + if err == nil || registration != nil || readiness != nil { + t.Fatalf("invalid Nacos registration=%v readiness=%v error=%v", registration, readiness, err) + } +} diff --git a/runtime-b/config_test.go b/runtime-b/config_test.go index 88a8e8b..3b0cd0f 100644 --- a/runtime-b/config_test.go +++ b/runtime-b/config_test.go @@ -19,79 +19,6 @@ func validRuntimeBEnvironment() map[string]string { } } -func validNacosRegistrationEnvironment() map[string]string { - return map[string]string{ - RegistrationModeEnvironment: RegistrationModeNacos, - AgentCardVersionEnvironment: "1.0.0", - ReleaseIDEnvironment: "rel_runtime_b_1", - CardDigestEnvironment: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - CanonicalEndpointEnvironment: "http://runtime-b:8092/", - AudienceEnvironment: "http://runtime-b:8092", - NacosAPIOriginEnvironment: "http://nacos:8848/nacos", - NacosNamespaceEnvironment: "public", - NacosGroupEnvironment: "NEKIRO", - NacosServiceEnvironment: "runtime-b", - NacosClusterEnvironment: "DEFAULT", - NacosPortNameEnvironment: "a2a", - NacosAdvertisedIPEnvironment: "172.28.0.12", - NacosAdvertisedPortEnvironment: "8092", - NacosWeightEnvironment: "1", - NacosHeartbeatIntervalEnvironment: "2000", - NacosHeartbeatTimeoutEnvironment: "5000", - NacosIPDeleteTimeoutEnvironment: "10000", - NacosRequestTimeoutEnvironment: "1000", - NacosAuthModeEnvironment: NacosAuthNone, - } -} - -func TestLoadRegistrationConfigRequiresExplicitModeAndNacosTuple(t *testing.T) { - disabled, err := LoadRegistrationConfig(runtimeBLookup(map[string]string{RegistrationModeEnvironment: RegistrationModeDisabled}), "runtime-b", "runtime-b-primary") - if err != nil || disabled.Mode != RegistrationModeDisabled { - t.Fatalf("disabled registration=%#v error=%v", disabled, err) - } - configured, err := LoadRegistrationConfig(runtimeBLookup(validNacosRegistrationEnvironment()), "runtime-b", "runtime-b-directory") - if err != nil || configured.ServiceName != "runtime-b" || configured.AdvertisedIP != "172.28.0.12" || configured.AdvertisedPort != 8092 { - t.Fatalf("Nacos registration=%#v error=%v", configured, err) - } - for name := range validNacosRegistrationEnvironment() { - invalid := validNacosRegistrationEnvironment() - delete(invalid, name) - if _, err := LoadRegistrationConfig(runtimeBLookup(invalid), "runtime-b", "runtime-b-directory"); err == nil { - t.Errorf("missing %s was accepted", name) - } - } - if _, err := LoadRegistrationConfig(runtimeBLookup(map[string]string{}), "runtime-b", "runtime-b-primary"); err == nil { - t.Fatal("missing registration mode was accepted") - } - if _, err := LoadRegistrationConfig(runtimeBLookup(map[string]string{RegistrationModeEnvironment: RegistrationModeDisabled, NacosServiceEnvironment: "unexpected"}), "runtime-b", "runtime-b-primary"); err == nil { - t.Fatal("disabled registration accepted Nacos settings") - } -} - -func TestLoadRegistrationConfigRejectsInvalidNacosSettings(t *testing.T) { - for name, test := range map[string]struct{ key, value string }{ - "origin": {NacosAPIOriginEnvironment, "http://nacos:8848"}, - "target": {CardDigestEnvironment, "not-a-digest"}, - "audience": {AudienceEnvironment, "http://another-runtime:8092"}, - "namespace": {NacosNamespaceEnvironment, "not safe"}, - "IP": {NacosAdvertisedIPEnvironment, "runtime-b"}, - "port": {NacosAdvertisedPortEnvironment, "0"}, - "heartbeat": {NacosHeartbeatIntervalEnvironment, "999"}, - "freshness": {NacosHeartbeatTimeoutEnvironment, "2000"}, - "expiry": {NacosIPDeleteTimeoutEnvironment, "5000"}, - "timeout": {NacosRequestTimeoutEnvironment, "60001"}, - "auth": {NacosAuthModeEnvironment, "implicit"}, - } { - t.Run(name, func(t *testing.T) { - environment := validNacosRegistrationEnvironment() - environment[test.key] = test.value - if _, err := LoadRegistrationConfig(runtimeBLookup(environment), "runtime-b", "runtime-b-directory"); err == nil { - t.Fatalf("invalid %s=%q was accepted", test.key, test.value) - } - }) - } -} - func runtimeBLookup(values map[string]string) func(string) (string, bool) { return func(name string) (string, bool) { value, exists := values[name] diff --git a/runtime-b/nacos_registration.go b/runtime-b/nacos_registration.go deleted file mode 100644 index 1f09f2e..0000000 --- a/runtime-b/nacos_registration.go +++ /dev/null @@ -1,10 +0,0 @@ -package runtimeb - -import "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" - -type HTTPDoer = nacosregistration.HTTPDoer -type NacosRegistration = nacosregistration.Registration - -func NewNacosRegistration(config RegistrationConfig, executor HTTPDoer) (*NacosRegistration, error) { - return nacosregistration.New(config, executor) -} diff --git a/runtime-b/nacos_registration_test.go b/runtime-b/nacos_registration_test.go deleted file mode 100644 index f8b06cd..0000000 --- a/runtime-b/nacos_registration_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package runtimeb - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" -) - -func TestNacosRegistrationOwnsRegisterHeartbeatAndDeregister(t *testing.T) { - var mu sync.Mutex - methods := make([]string, 0, 3) - heartbeat := make(chan struct{}, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - mu.Lock() - methods = append(methods, request.Method+" "+request.URL.Path) - mu.Unlock() - if err := request.ParseForm(); err != nil { - t.Fatal(err) - } - query := request.Form - if query.Get("serviceName") != "NEKIRO@@runtime-b" || query.Get("groupName") != "NEKIRO" || query.Get("clusterName") != "DEFAULT" || query.Get("namespaceId") != "public" || query.Get("ip") != "127.0.0.1" || query.Get("port") != "8092" { - t.Errorf("request query=%v", query) - } - if request.Method == http.MethodPost { - var metadata map[string]string - if json.Unmarshal([]byte(query.Get("metadata")), &metadata) != nil || metadata["nekiro.instanceId"] != "runtime-b-directory" || metadata["preserved.heart.beat.interval"] != "1000" || metadata["preserved.heart.beat.timeout"] != "5000" || metadata["preserved.ip.delete.timeout"] != "10000" || query.Get("ephemeral") != "true" || query.Get("weight") != "1" { - t.Errorf("registration metadata=%v query=%v", metadata, query) - } - } - if request.Method == http.MethodPut { - select { - case heartbeat <- struct{}{}: - default: - } - var beat struct { - Service string `json:"serviceName"` - Metadata map[string]string `json:"metadata"` - Weight float64 `json:"weight"` - } - if json.Unmarshal([]byte(query.Get("beat")), &beat) != nil || beat.Service != "NEKIRO@@runtime-b" || beat.Metadata["nekiro.instanceId"] != "runtime-b-directory" || beat.Metadata["preserved.heart.beat.timeout"] != "5000" || beat.Weight != 1.0 || query.Get("ephemeral") != "true" { - t.Errorf("heartbeat=%v query=%v", beat, query) - } - } - if request.Method == http.MethodPut { - _, _ = writer.Write([]byte(`{"clientBeatInterval":1000,"code":10200,"lightBeatEnabled":true}`)) - return - } - _, _ = writer.Write([]byte("ok")) - })) - t.Cleanup(server.Close) - registration := testRegistration(t, server, time.Second) - if err := registration.Register(t.Context()); err != nil || !registration.Ready() { - t.Fatalf("Register ready=%v error=%v", registration.Ready(), err) - } - runContext, cancelRun := context.WithCancel(t.Context()) - runErrors := make(chan error, 1) - go func() { runErrors <- registration.Run(runContext) }() - select { - case <-heartbeat: - case <-time.After(3 * time.Second): - t.Fatal("heartbeat was not sent") - } - cancelRun() - if err := <-runErrors; err != nil { - t.Fatal(err) - } - if err := registration.Deregister(t.Context()); err != nil || registration.Ready() { - t.Fatalf("Deregister ready=%v error=%v", registration.Ready(), err) - } - mu.Lock() - defer mu.Unlock() - want := []string{"POST /nacos/v1/ns/instance", "PUT /nacos/v1/ns/instance/beat", "DELETE /nacos/v1/ns/instance"} - if len(methods) != len(want) { - t.Fatalf("methods=%v", methods) - } - for index := range want { - if methods[index] != want[index] { - t.Fatalf("methods=%v want=%v", methods, want) - } - } -} - -func TestNacosHeartbeatFailureMakesRuntimeNotReadyWithoutRetry(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests++ - if request.Method == http.MethodPut { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = writer.Write([]byte("ok")) - })) - t.Cleanup(server.Close) - registration := testRegistration(t, server, time.Second) - if err := registration.Register(t.Context()); err != nil { - t.Fatal(err) - } - err := registration.Run(t.Context()) - if err == nil || registration.Ready() || requests != 2 { - t.Fatalf("Run error=%v ready=%v requests=%d", err, registration.Ready(), requests) - } - request := httptest.NewRequest(http.MethodGet, "/readyz", nil) - response := httptest.NewRecorder() - handler := NewHandler() - application := httpHandlerWithReadiness(t, handler, registration) - application.ServeHTTP(response, request) - if response.Code != http.StatusServiceUnavailable { - t.Fatalf("readiness status=%d", response.Code) - } -} - -func TestNacosRegistrationClassifiesCanceledAndUnavailableRequests(t *testing.T) { - registration, err := NewNacosRegistration(RegistrationConfig{Mode: RegistrationModeNacos}, nil) - if err == nil || registration != nil { - t.Fatal("nil executor was accepted") - } - canceled, cancel := context.WithCancel(t.Context()) - cancel() - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - server.Close() - registration = testRegistrationWithURL(t, server.URL+"/nacos", server.Client(), time.Second) - if err := registration.Register(canceled); err == nil { - t.Fatal("canceled registration succeeded") - } - if err := registration.Register(t.Context()); err == nil || errors.Is(err, context.Canceled) { - t.Fatalf("unavailable registration error=%v", err) - } -} - -func TestNacosRegistrationRejectsInvalidLifecycleCalls(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - _, _ = writer.Write([]byte("ok")) - })) - t.Cleanup(server.Close) - registration := testRegistration(t, server, time.Second) - if registration.Ready() { - t.Fatal("registration was ready before its initial publish") - } - if err := registration.Run(t.Context()); err == nil { - t.Fatal("lease observation started before registration") - } - if err := registration.Run(nil); err == nil { - t.Fatal("nil lease observation context was accepted") - } - if err := registration.Deregister(nil); err == nil { - t.Fatal("nil deregistration context was accepted") - } - if err := registration.Register(t.Context()); err != nil { - t.Fatal(err) - } - if err := registration.Register(t.Context()); err == nil { - t.Fatal("duplicate registration was accepted") - } - if err := registration.Deregister(t.Context()); err != nil { - t.Fatal(err) - } - if err := registration.Deregister(t.Context()); err != nil { - t.Fatalf("idempotent deregistration: %v", err) - } -} - -func TestNacosRegistrationRejectsInvalidExactRelease(t *testing.T) { - config := validRegistrationConfig("http://nacos.test/nacos", time.Second) - config.CardDigest = "not-a-digest" - registration, err := NewNacosRegistration(config, http.DefaultClient) - if err == nil || registration != nil { - t.Fatal("invalid exact Release target was accepted") - } -} - -func testRegistration(t *testing.T, server *httptest.Server, interval time.Duration) *NacosRegistration { - t.Helper() - return testRegistrationWithURL(t, server.URL+"/nacos", server.Client(), interval) -} - -func testRegistrationWithURL(t *testing.T, origin string, client HTTPDoer, interval time.Duration) *NacosRegistration { - t.Helper() - registration, err := NewNacosRegistration(validRegistrationConfig(origin, interval), client) - if err != nil { - t.Fatal(err) - } - return registration -} - -func validRegistrationConfig(origin string, interval time.Duration) RegistrationConfig { - return RegistrationConfig{ - Mode: RegistrationModeNacos, AgentID: "runtime-b", InstanceID: "runtime-b-directory", - AgentCardVersion: "1.0.0", ReleaseID: "rel_runtime_b_1", - CardDigest: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - CanonicalEndpoint: "http://runtime-b:8092/", Audience: "http://runtime-b:8092", - APIOrigin: origin, NamespaceID: "public", GroupName: "NEKIRO", ServiceName: "runtime-b", ClusterName: "DEFAULT", - PortName: "a2a", AdvertisedIP: "127.0.0.1", AdvertisedPort: 8092, Weight: 1, - HeartbeatInterval: interval, HeartbeatTimeout: 5 * time.Second, IPDeleteTimeout: 10 * time.Second, - RequestTimeout: time.Second, AuthMode: NacosAuthNone, - } -} diff --git a/runtime-b/registration_config.go b/runtime-b/registration_config.go deleted file mode 100644 index 3a65299..0000000 --- a/runtime-b/registration_config.go +++ /dev/null @@ -1,41 +0,0 @@ -package runtimeb - -import "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" - -const ( - RegistrationModeEnvironment = "RUNTIME_B_REGISTRATION_MODE" - AgentCardVersionEnvironment = "RUNTIME_B_AGENT_CARD_VERSION" - ReleaseIDEnvironment = "RUNTIME_B_RELEASE_ID" - CardDigestEnvironment = "RUNTIME_B_CARD_DIGEST" - CanonicalEndpointEnvironment = "RUNTIME_B_CANONICAL_ENDPOINT" - AudienceEnvironment = "RUNTIME_B_AUDIENCE" - NacosAPIOriginEnvironment = "RUNTIME_B_NACOS_API_ORIGIN" - NacosNamespaceEnvironment = "RUNTIME_B_NACOS_NAMESPACE_ID" - NacosGroupEnvironment = "RUNTIME_B_NACOS_GROUP_NAME" - NacosServiceEnvironment = "RUNTIME_B_NACOS_SERVICE_NAME" - NacosClusterEnvironment = "RUNTIME_B_NACOS_CLUSTER_NAME" - NacosPortNameEnvironment = "RUNTIME_B_NACOS_PORT_NAME" - NacosAdvertisedIPEnvironment = "RUNTIME_B_NACOS_ADVERTISED_IP" - NacosAdvertisedPortEnvironment = "RUNTIME_B_NACOS_ADVERTISED_PORT" - NacosWeightEnvironment = "RUNTIME_B_NACOS_WEIGHT" - NacosHeartbeatIntervalEnvironment = "RUNTIME_B_NACOS_HEARTBEAT_INTERVAL_MS" - NacosHeartbeatTimeoutEnvironment = "RUNTIME_B_NACOS_HEARTBEAT_TIMEOUT_MS" - NacosIPDeleteTimeoutEnvironment = "RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS" - NacosRequestTimeoutEnvironment = "RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS" - NacosAuthModeEnvironment = "RUNTIME_B_NACOS_AUTH_MODE" - NacosAccessTokenEnvironment = "RUNTIME_B_NACOS_ACCESS_TOKEN" - NacosTLSCAFileEnvironment = "RUNTIME_B_NACOS_TLS_CA_FILE" - NacosTLSServerNameEnvironment = "RUNTIME_B_NACOS_TLS_SERVER_NAME" - NacosTLSClientCertEnvironment = "RUNTIME_B_NACOS_TLS_CLIENT_CERT_FILE" - NacosTLSClientKeyEnvironment = "RUNTIME_B_NACOS_TLS_CLIENT_KEY_FILE" - RegistrationModeDisabled = nacosregistration.ModeDisabled - RegistrationModeNacos = nacosregistration.ModeNacos - NacosAuthNone = nacosregistration.AuthNone - NacosAuthAccessToken = nacosregistration.AuthAccessToken -) - -type RegistrationConfig = nacosregistration.Config - -func LoadRegistrationConfig(lookup func(string) (string, bool), agentID, instanceID string) (RegistrationConfig, error) { - return nacosregistration.Load(lookup, "RUNTIME_B", agentID, instanceID) -}