diff --git a/AGENTS.md b/AGENTS.md index 0259e4a..d9f8365 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ This repo is one codefly agent binary: the **FastAPI specialization** of the generic Python agent. It composes `github.com/codefly-dev/service-python`'s `pkg/*` types and adds what FastAPI needs — uvicorn supervision and hot reload, -OpenAPI-derived REST endpoints, an opt-in `grpc.aio` listener, the Dockerfile -and kustomize recipes, and the scaffold a new FastAPI service is created from. +OpenAPI-derived REST endpoints, an opt-in `grpc.aio` listener and Connect face, +the Dockerfile and kustomize recipes, and the scaffold a new FastAPI service is created from. Dependency management is `uv`; poetry is not supported. It does **not** own: diff --git a/agent.codefly.yaml b/agent.codefly.yaml index 36213f9..987967b 100644 --- a/agent.codefly.yaml +++ b/agent.codefly.yaml @@ -1,7 +1,7 @@ publisher: codefly.dev kind: codefly:service name: python-fastapi -version: 0.0.120 +version: 0.0.121 release: owner: workflow workflow: releaser.yml diff --git a/builder.go b/builder.go index a5e6575..71a490c 100644 --- a/builder.go +++ b/builder.go @@ -65,9 +65,17 @@ func NewBuilder(svc *Service) *Builder { func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builderv0.LoadResponse, error) { defer s.Wool.Catch() - // Call generic first — it loads Settings, Endpoints, handles CreationMode. - resp, err := s.Builder.Load(ctx, req) - if err != nil { + // The generic load with this agent's Settings: it loads them, the + // Endpoints, and handles CreationMode. The generic Load would decode the + // spec into its own half of the settings alone, leaving grpc-server and + // connect-endpoint unset on every loaded service, so Sync generated no + // server code and Deploy rendered no gRPC or Connect port. It reports a + // failure in the response's state, not the error: stop there, or the + // endpoint lookup below replaces the real cause with "no rest endpoint + // found". + resp, err := s.Base.Builder.LoadService(ctx, req, services.BuilderLoad{Settings: s.FastAPI.Settings}) + s.Service.SourceLocation = s.Service.ResolveSourceLocation() + if err != nil || resp.GetState().GetState() == builderv0.LoadStatus_ERROR { return resp, err } requirements.Localize(s.Location) @@ -91,6 +99,9 @@ func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builde if err != nil { return s.Base.Builder.LoadError(err) } + if err := s.FastAPI.Settings.connectSettingError(); err != nil { + return s.Base.Builder.LoadError(err) + } return s.Base.Builder.LoadResponse() } @@ -139,9 +150,42 @@ func (s *Builder) Sync(ctx context.Context, _ *builderv0.SyncRequest) (*builderv return s.Base.Builder.SyncError(err) } } + if s.FastAPI.Settings.ConnectEndpoint { + if err := s.writeConnectFace(); err != nil { + return s.Base.Builder.SyncError(err) + } + } return s.Base.Builder.SyncResponse() } +// connectFacePath is where the agent writes the Connect face, relative to the +// service root: beside the generated gRPC bindings, importable as +// src.rpc.connect_gen. +const connectFacePath = "code/src/rpc/connect_gen.py" + +// connectFace is the Connect face every service with connect-endpoint gets, +// byte for byte: it reads the methods it serves from the descriptors the +// service's gRPC bindings register, so nothing in it is rendered per service. +// +//go:embed templates/connect/connect_gen.py +var connectFace []byte + +// writeConnectFace writes the Connect face, replacing whatever is there: the +// file is the agent's, and a hand edit is drift the next sync removes. +func (s *Builder) writeConnectFace() error { + if err := s.FastAPI.Settings.connectSettingError(); err != nil { + return err + } + target := s.Local(connectFacePath) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return s.Wool.Wrapf(err, "cannot create %s", filepath.Dir(target)) + } + if err := os.WriteFile(target, connectFace, 0o644); err != nil { + return s.Wool.Wrapf(err, "cannot write the Connect face") + } + return nil +} + // syncGRPCServer regenerates the Python protobuf + grpc.aio server stubs from // the service-owned proto contract via Buf. Buf reads proto/ under the service // root (matching the endpoint contract location) and writes the stubs into the @@ -429,6 +473,11 @@ type Parameters struct { // binds (the app defaults to it when CODEFLY_GRPC_PORT is unset). GRPCEnabled bool GRPCPort int + // ConnectEnabled adds the Connect face's containerPort and Service port. + // ConnectPort is the fixed in-cluster port it binds (the app defaults to it + // when CODEFLY_CONNECT_PORT is unset). + ConnectEnabled bool + ConnectPort int // AutomountServiceAccountToken projects the ServiceAccount token into the // pod. It stays false because the identity path is the metadata server or a // webhook-projected token and the app does not call the API server. Setting @@ -509,9 +558,11 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) DependencyConfigurations: true, }, Parameters: Parameters{ - Probes: probes, - GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled, - GRPCPort: int(standards.Port(standards.GRPC)), + Probes: probes, + GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled, + GRPCPort: int(standards.Port(standards.GRPC)), + ConnectEnabled: s.FastAPI.Settings.ConnectEndpoint, + ConnectPort: int(standards.Port(standards.CONNECT)), }, }) } diff --git a/connect_test.go b/connect_test.go new file mode 100644 index 0000000..452c957 --- /dev/null +++ b/connect_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + agenttesting "github.com/codefly-dev/core/agents/testing" + agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestSettingsParseConnectEndpoint proves the setting reads at go-grpc's +// spelling, defaults to off, and is refused without a gRPC server to transcode +// onto. +func TestSettingsParseConnectEndpoint(t *testing.T) { + var on Settings + require.NoError(t, yaml.Unmarshal([]byte(` +grpc-server: + enabled: true +connect-endpoint: true +`), &on)) + require.True(t, on.ConnectEndpoint) + require.NoError(t, on.connectSettingError()) + + var alone Settings + require.NoError(t, yaml.Unmarshal([]byte(`connect-endpoint: true`), &alone)) + require.ErrorContains(t, alone.connectSettingError(), "grpc-server.enabled") + + var absent Settings + require.NoError(t, yaml.Unmarshal([]byte(`python-version: "3.12"`), &absent)) + require.False(t, absent.ConnectEndpoint) +} + +// TestSyncWritesTheConnectFace proves the face lands where the app imports it, +// byte for byte, replacing a hand edit. +func TestSyncWritesTheConnectFace(t *testing.T) { + builder := NewBuilder(NewService()) + builder.Location = t.TempDir() + builder.FastAPI.Settings.GRPCServer.Enabled = true + builder.FastAPI.Settings.ConnectEndpoint = true + + target := filepath.Join(builder.Location, connectFacePath) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte("# edited by hand\n"), 0o644)) + + require.NoError(t, builder.writeConnectFace()) + written, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, connectFace, written) + require.Contains(t, string(written), "Do not edit this file!") + + builder.FastAPI.Settings.GRPCServer.Enabled = false + require.Error(t, builder.writeConnectFace()) +} + +// TestDeploymentRendersConnectPort proves the Connect container and Service +// ports appear only when the service opts in, declared as HTTP/1.1. +func TestDeploymentRendersConnectPort(t *testing.T) { + enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090, ConnectEnabled: true, ConnectPort: 8081})) + svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml")) + require.NoError(t, err) + require.Contains(t, string(svc), "name: connect-port") + require.Contains(t, string(svc), "appProtocol: http\n") + require.Contains(t, string(svc), "port: 8081") + deployment, err := os.ReadFile(filepath.Join(enabled, "base", "deployment.yaml")) + require.NoError(t, err) + require.Contains(t, string(deployment), "containerPort: 8081") + + disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090})) + svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml")) + require.NoError(t, err) + require.NotContains(t, string(svc), "connect-port") +} + +// TestConnectFaceServesEveryProtocol runs the face's own suite +// (testdata/connect) against a real grpc.aio server: Connect unary and server +// streams in both codecs, gRPC-Web, gRPC relayed on the same port, metadata +// both ways, the service's interceptors on every protocol, deadlines and the +// HTTP refusals. The Python pins are the ones a service's lock resolves today. +func TestConnectFaceServesEveryProtocol(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Skip("uv is not installed: the Connect face's Python suite cannot run") + } + cmd := exec.Command("uv", "run", "--no-project", "--python", "3.12", + "--with", "grpcio==1.84.0", "--with", "protobuf==6.33.6", "--with", "h11==0.16.0", + "--with", "httpx==0.28.1", "--with", "pytest==8.4.2", "--with", "pytest-asyncio==1.2.0", + "pytest", "-q", "-p", "no:cacheprovider", + "-o", "asyncio_mode=auto", "-o", "asyncio_default_fixture_loop_scope=function", + filepath.Join("testdata", "connect")) + cmd.Env = append(os.Environ(), "PYTHONPATH="+filepath.Join("templates", "connect"), "PYTHONDONTWRITEBYTECODE=1") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) +} + +// TestBuilderLoadReadsTheAgentsSettings is the regression guard for a loaded +// (not created) service: the builder must see grpc-server and +// connect-endpoint, or Sync writes neither the gRPC stubs nor the Connect face +// and Deploy renders neither port. +func TestBuilderLoadReadsTheAgentsSettings(t *testing.T) { + created, _ := createServiceForTest(t, map[string]*agentv0.Answer{ + HotReload: confirmAnswer(false), + PublicEndpoint: confirmAnswer(false), + GRPCServer: confirmAnswer(true), + }) + ctx := context.Background() + manifest := filepath.Join(created.Location, "service.codefly.yaml") + content, err := os.ReadFile(manifest) + require.NoError(t, err) + var document map[string]any + require.NoError(t, yaml.Unmarshal(content, &document)) + spec, _ := document["spec"].(map[string]any) + require.Equal(t, true, spec["grpc-server"].(map[string]any)["enabled"]) + spec["connect-endpoint"] = true + content, err = yaml.Marshal(document) + require.NoError(t, err) + require.NoError(t, os.WriteFile(manifest, content, 0o644)) + + identity, err := created.Identity.Proto() + require.NoError(t, err) + loaded := NewBuilder(NewService()) + resp, err := loaded.Load(ctx, &builderv0.LoadRequest{Identity: identity}) + require.NoError(t, err) + require.NotEqual(t, builderv0.LoadStatus_ERROR, resp.GetState().GetState(), resp.GetState().GetMessage()) + require.True(t, loaded.FastAPI.Settings.GRPCServer.Enabled) + require.True(t, loaded.FastAPI.Settings.ConnectEndpoint) + require.Equal(t, filepath.Join(created.Location, "code"), loaded.Service.SourceLocation) +} diff --git a/go.mod b/go.mod index a90dcd9..341793d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/service-python-fastapi go 1.27.3 require ( - github.com/codefly-dev/core v0.19.6 + github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55 github.com/codefly-dev/service-python v0.0.66 github.com/pelletier/go-toml/v2 v2.4.3 github.com/stretchr/testify v1.12.1 diff --git a/go.sum b/go.sum index 4630295..414a513 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.6 h1:ZkCChK/BioK+B7zZ0fp+/rXDCy0k+lsdFqHjyc5xFIg= -github.com/codefly-dev/core v0.19.6/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55 h1:U9rzO6zSCigIUHuiiUTKMEtNzyYZ3e6il3A+sEv8jJk= +github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/service-python v0.0.66 h1:Y74QvKAYMaOnZcsfRxuD9dpQo3//7Mjo12RYELyrp5o= diff --git a/load_error_test.go b/load_error_test.go new file mode 100644 index 0000000..ac22f55 --- /dev/null +++ b/load_error_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "path/filepath" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" +) + +// The generic Load reports its failure in the response's state. The fastapi +// Load must hand that failure back as it is, never go on to look for a REST +// endpoint in a service it could not load and report that one is missing. +func TestBuilderLoadSurfacesTheGenericLoadFailure(t *testing.T) { + for name, tc := range map[string]struct { + request *builderv0.LoadRequest + cause string + }{ + "no identity": {request: &builderv0.LoadRequest{}, cause: "requires a service identity"}, + "no service file": {request: &builderv0.LoadRequest{Identity: &basev0.ServiceIdentity{ + Name: "absent", Version: "0.0.0", Module: "mod", Workspace: "test", + WorkspacePath: t.TempDir(), RelativeToWorkspace: filepath.Join("mod", "absent"), + }}, cause: "absent"}, + } { + t.Run(name, func(t *testing.T) { + resp, err := NewBuilder(NewService()).Load(t.Context(), tc.request) + require.NoError(t, err) + require.Equal(t, builderv0.LoadStatus_ERROR, resp.GetState().GetState()) + require.Contains(t, resp.GetState().GetMessage(), tc.cause) + require.NotContains(t, resp.GetState().GetMessage(), "rest endpoint") + }) + } +} diff --git a/main.go b/main.go index 4735b5a..d0640b6 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ package main import ( "context" "embed" + "fmt" "github.com/codefly-dev/core/agents" "github.com/codefly-dev/core/agents/contract" @@ -42,6 +43,9 @@ const ( HotReload = "hot-reload" PublicEndpoint = "public-endpoint" GRPCServer = "grpc-server" + // ConnectEndpointSetting is go-grpc's spelling for the same face: a + // listener serving Connect, gRPC-Web and gRPC over the service's gRPC server. + ConnectEndpointSetting = "connect-endpoint" ) // defaultProtoPath is where the service-owned proto contract lives, relative @@ -78,6 +82,15 @@ type Settings struct { // process as the FastAPI app (see grpc-server:). Disabled by default. GRPCServer GRPCServerSettings `yaml:"grpc-server"` + // ConnectEndpoint serves the service's gRPC API on a second listener that + // speaks Connect and gRPC-Web (HTTP/1.1) and gRPC (HTTP/2) on one port: + // the face a browser reaches through an L7 hop. Every call is transcoded + // onto the grpc.aio server, so it needs grpc-server and adds no API of its + // own. The agent writes the face (code/src/rpc/connect_gen.py) at sync and + // hands the process its port as CODEFLY_CONNECT_PORT; the app's lifespan + // starts it beside the gRPC server. Off by default. + ConnectEndpoint bool `yaml:"connect-endpoint"` + // RuntimeImage overrides the default codefly-built runtime image. // Format: "name:tag". Plain "name" and ":latest" are rejected — // pinning is enforced. Leave empty to use ghcr.io/codefly-dev/python: @@ -155,6 +168,19 @@ type Service struct { // GRPCEndpoint is the service-owned gRPC endpoint, present only when // Settings.GRPCServer.Enabled. Nil keeps the REST-only path untouched. GRPCEndpoint *v0.Endpoint + + // ConnectEndpoint is the Connect-family endpoint, present only when + // Settings.ConnectEndpoint. + ConnectEndpoint *v0.Endpoint +} + +// connectSettingError refuses a Connect face with no gRPC server to transcode +// onto: the face serves the gRPC server's API, never one of its own. +func (s *Settings) connectSettingError() error { + if s.ConnectEndpoint && !s.GRPCServer.Enabled { + return fmt.Errorf("%s needs %s.enabled: the Connect face serves the gRPC server's API", ConnectEndpointSetting, GRPCServer) + } + return nil } // GetAgentInformation overrides the generic info to advertise HTTP protocol @@ -178,8 +204,8 @@ func (s *Service) GetAgentInformation(ctx context.Context, _ *agentv0.AgentInfor HotReload: true, Languages: []agentv0.Language_Type{agentv0.Language_PYTHON}, // The agent can serve HTTP always and gRPC when a service opts in via - // grpc-server; advertising both declares the capability, not that every - // service exposes both. + // grpc-server (and Connect beside it, over HTTP); advertising both + // declares the capability, not that every service exposes both. Protocols: []agentv0.Protocol_Type{agentv0.Protocol_HTTP, agentv0.Protocol_GRPC}, ReadMe: readme, }.Build() diff --git a/runtime.go b/runtime.go index 988a957..6709780 100644 --- a/runtime.go +++ b/runtime.go @@ -150,6 +150,10 @@ type Runtime struct { // to the Python process as CODEFLY_GRPC_PORT. Zero when gRPC is disabled. grpcPort uint16 + // connectPort is the mapped port for the Connect face, passed to the + // Python process as CODEFLY_CONNECT_PORT. Zero when Connect is disabled. + connectPort uint16 + cacheLocation string // destroyed marks the runtime as torn down, guarded by runnerMu. Destroy @@ -202,6 +206,19 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim } } + if err := s.FastAPI.Settings.connectSettingError(); err != nil { + return s.Base.Runtime.LoadError(err) + } + if s.FastAPI.Settings.ConnectEndpoint { + s.FastAPI.ConnectEndpoint, err = resources.FindConnectEndpoint(ctx, s.Endpoints) + if err != nil { + return s.Base.Runtime.LoadError(err) + } + if s.FastAPI.ConnectEndpoint == nil { + return s.Base.Runtime.LoadError(fmt.Errorf("%s is on but the service declares no endpoint with api: connect", ConnectEndpointSetting)) + } + } + // Inherit the persistent Python REPL commands (exec, repl-reset) // from the generic python runtime. FastAPI adds no REPL-specific // behavior on top — same pattern go-grpc uses when inheriting from @@ -289,6 +306,14 @@ func (s *Runtime) CreateRunnerEnvironment(ctx context.Context) (runners.RunnerEn dockerEnv.WithPort(ctx, uint16(grpcInstance.Port)) } + if s.FastAPI.ConnectEndpoint != nil { + connectInstance, connectErr := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.ConnectEndpoint, resources.NewNativeNetworkAccess()) + if connectErr != nil { + return nil, "", s.Wool.Wrapf(connectErr, "cannot find connect network instance") + } + dockerEnv.WithPort(ctx, uint16(connectInstance.Port)) + } + envPath := s.DockerEnvPath() if _, err = shared.CheckDirectoryOrCreate(ctx, envPath); err != nil { return nil, "", s.Wool.Wrapf(err, "cannot create docker venv environment") @@ -475,6 +500,15 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim s.Infof("grpc will run on %s", grpcNet.Address) } + if s.FastAPI.ConnectEndpoint != nil { + connectNet, connectErr := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.ConnectEndpoint, resources.NewNativeNetworkAccess()) + if connectErr != nil { + return s.Base.Runtime.InitError(connectErr) + } + s.connectPort = uint16(connectNet.Port) + s.Infof("connect will run on %s", connectNet.Address) + } + hasPyProject, err := shared.FileExists(ctx, path.Join(s.Service.SourceLocation, "pyproject.toml")) if err != nil { return s.Base.Runtime.InitError(err) @@ -559,7 +593,7 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt } inputs := startInputs(req) - launch := launchInputs(inputs, s.port, s.grpcPort, s.FastAPI.Settings.HotReload) + fmt.Sprintf("\ninit-fixture %q", s.initFixture) + launch := launchInputs(inputs, s.port, s.grpcPort, s.connectPort, s.FastAPI.Settings.HotReload) + fmt.Sprintf("\ninit-fixture %q", s.initFixture) if s.runner != nil { switch { @@ -631,6 +665,11 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt // `--set` naming the same key. proc.WithEnvironmentVariables(ctx, resources.Env("CODEFLY_GRPC_PORT", s.grpcPort)) } + if s.connectPort != 0 { + // The same for the Connect face (src/rpc/connect_gen.py), which the + // lifespan starts over the gRPC server; its fixed fallback is 8081. + proc.WithEnvironmentVariables(ctx, resources.Env("CODEFLY_CONNECT_PORT", s.connectPort)) + } // The process must outlive this RPC, so it runs under a background context // — cancellable, so Stop, Destroy and a replacement can tell the supervisor @@ -938,8 +977,8 @@ func startInputs(req *runtimev0.StartRequest) string { // It is deliberately separate from startInputs: that one gates applyStartInputs, // which must fire only when the request itself changed, because the environment // manager appends overrides and endpoints without ever removing any. -func launchInputs(requestInputs string, port uint16, grpcPort uint16, hotReload bool) string { - return fmt.Sprintf("%s\nport %d\ngrpc-port %d\nhot-reload %t", requestInputs, port, grpcPort, hotReload) +func launchInputs(requestInputs string, port uint16, grpcPort uint16, connectPort uint16, hotReload bool) string { + return fmt.Sprintf("%s\nport %d\ngrpc-port %d\nconnect-port %d\nhot-reload %t", requestInputs, port, grpcPort, connectPort, hotReload) } // applyStartInputs folds the StartRequest into the environment manager. Callers diff --git a/templates/connect/connect_gen.py b/templates/connect/connect_gen.py new file mode 100644 index 0000000..73f8d19 --- /dev/null +++ b/templates/connect/connect_gen.py @@ -0,0 +1,706 @@ +"""The Connect face: Connect, gRPC-Web and gRPC on one listener. + +This code is generated by the python-fastapi agent (``connect-endpoint: true``) and +rewritten by every sync. Do not edit this file! + +``serve(port, grpc_port)`` listens on ``port``, and the service's own grpc.aio server on +``grpc_port``, in this process, answers every call made there: + +- a gRPC call (HTTP/2 with prior knowledge) is relayed to that server as it came; +- a Connect call (unary and server streaming, binary and JSON) and a gRPC-Web call + (HTTP/1.1) are transcoded to gRPC and dispatched through it. + +Dispatch therefore has one source. Every interceptor the service installs on its gRPC +server runs on every protocol, and the service never writes a second, Connect-shaped copy +of its API. A request's headers reach the service as gRPC metadata, the gRPC status and +its details come back as the protocol's error, and the service's header and trailer +metadata come back as headers and trailers. Client and bidirectional streams need HTTP/2 +end to end, so they are served on the gRPC protocol only. + +Request bodies are bounded by ``max_message_bytes`` (gRPC's 4 MiB unless the service +raises it) before they are read, and every read is bounded in time. + +The service starts it from its lifespan, beside its gRPC server, on the port the agent +hands the process as ``CODEFLY_CONNECT_PORT`` (``DEFAULT_PORT`` where the mapping is +fixed). It needs ``grpcio``, ``protobuf`` and ``h11`` (uvicorn's own HTTP/1.1 parser) in +the service's dependencies. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import re +import struct +import zlib +from collections.abc import Iterable +from urllib.parse import quote + +import grpc +import h11 +from google.protobuf import any_pb2, descriptor_pool, json_format, message_factory + +__all__ = ["ConnectFace", "serve"] + +PORT_ENV = "CODEFLY_CONNECT_PORT" +DEFAULT_PORT = 8081 +DEFAULT_MAX_MESSAGE_BYTES = 4 * 1024 * 1024 +DEFAULT_READ_TIMEOUT_S = 30.0 +DEFAULT_IDLE_TIMEOUT_S = 120.0 + +_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" +_ENVELOPE = struct.Struct(">BI") +_COMPRESSED, _END_STREAM, _TRAILERS = 0x01, 0x02, 0x80 +_STATUS_DETAILS = "grpc-status-details-bin" +# Headers that describe the HTTP exchange rather than the call: never metadata. +_TRANSPORT = frozenset( + { + "accept", + "accept-encoding", + "connection", + "content-encoding", + "content-length", + "content-type", + "host", + "keep-alive", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + } +) +_TRANSPORT_PREFIXES = ("connect-", "grpc-", "x-grpc-web", ":") +_GRPC_TIMEOUT = re.compile(r"^(\d{1,8})([HMSmun])$") +_GRPC_UNITS = {"H": 3600.0, "M": 60.0, "S": 1.0, "m": 1e-3, "u": 1e-6, "n": 1e-9} +_CODES = { + grpc.StatusCode.CANCELLED: ("canceled", 499), + grpc.StatusCode.UNKNOWN: ("unknown", 500), + grpc.StatusCode.INVALID_ARGUMENT: ("invalid_argument", 400), + grpc.StatusCode.DEADLINE_EXCEEDED: ("deadline_exceeded", 504), + grpc.StatusCode.NOT_FOUND: ("not_found", 404), + grpc.StatusCode.ALREADY_EXISTS: ("already_exists", 409), + grpc.StatusCode.PERMISSION_DENIED: ("permission_denied", 403), + grpc.StatusCode.RESOURCE_EXHAUSTED: ("resource_exhausted", 429), + grpc.StatusCode.FAILED_PRECONDITION: ("failed_precondition", 400), + grpc.StatusCode.ABORTED: ("aborted", 409), + grpc.StatusCode.OUT_OF_RANGE: ("out_of_range", 400), + grpc.StatusCode.UNIMPLEMENTED: ("unimplemented", 501), + grpc.StatusCode.INTERNAL: ("internal", 500), + grpc.StatusCode.UNAVAILABLE: ("unavailable", 503), + grpc.StatusCode.DATA_LOSS: ("data_loss", 500), + grpc.StatusCode.UNAUTHENTICATED: ("unauthenticated", 401), +} + +_log = logging.getLogger("codefly.connect") + + +class CallError(Exception): + """A call answered with a gRPC status other than OK, before or by the service.""" + + def __init__(self, code: grpc.StatusCode, message: str, header=(), trailer=()): + super().__init__(message) + self.code, self.message = code, message + self.header, self.trailer = tuple(header), tuple(trailer) + + @classmethod + def of(cls, error: grpc.aio.AioRpcError) -> CallError: + return cls( + error.code(), + error.details() or "", + error.initial_metadata() or (), + error.trailing_metadata() or (), + ) + + +class _Plain(Exception): + """A request refused at the HTTP layer, before any protocol is spoken.""" + + def __init__(self, status: int, reason: str, headers=()): + super().__init__(reason) + self.status, self.reason, self.headers = status, reason, list(headers) + + +class ConnectFace: + """The listener ``serve`` starts; ``stop`` closes it and every open connection.""" + + def __init__(self, server, channel, port: int): + self._server, self._channel, self.port = server, channel, port + self._connections: set[asyncio.Task] = set() + + async def stop(self, grace: float | None = 5.0) -> None: + self._server.close() + for task in list(self._connections): + task.cancel() + if self._connections: + await asyncio.wait(self._connections, timeout=grace) + await self._channel.close() + + +async def serve( + port: int, + grpc_port: int, + *, + host: str = "0.0.0.0", + grpc_host: str = "127.0.0.1", + max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES, + read_timeout: float = DEFAULT_READ_TIMEOUT_S, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT_S, +) -> ConnectFace: + """Start the Connect face on ``host``:``port`` (0 for an ephemeral one) over the + grpc.aio server listening on ``grpc_host``:``grpc_port``, and return it started. + Call it on the loop that serves the gRPC server, once that server listens.""" + target = f"{grpc_host}:{grpc_port}" + channel = grpc.aio.insecure_channel( + target, + options=[ + ("grpc.max_receive_message_length", max_message_bytes), + ("grpc.max_send_message_length", max_message_bytes), + ], + ) + connection = _Connection( + channel, (grpc_host, grpc_port), max_message_bytes, read_timeout, idle_timeout + ) + face: ConnectFace + + async def accepted(reader, writer): + task = asyncio.current_task() + face._connections.add(task) + try: + await connection.serve(reader, writer) + except asyncio.CancelledError: + pass + except Exception: # one connection's failure never stops the listener + _log.exception("connect face: connection failed") + finally: + face._connections.discard(task) + writer.close() + + server = await asyncio.start_server(accepted, host, port) + bound = server.sockets[0].getsockname()[1] + face = ConnectFace(server, channel, bound) + return face + + +class _Connection: + def __init__(self, channel, grpc_address, limit, read_timeout, idle_timeout): + self.channel, self.grpc_address, self.limit = channel, grpc_address, limit + self.read_timeout, self.idle_timeout = read_timeout, idle_timeout + + async def serve(self, reader, writer): + head = b"" + async with asyncio.timeout(self.read_timeout): + while len(head) < len(_PREFACE) and _PREFACE.startswith(head): + chunk = await reader.read(len(_PREFACE) - len(head)) + if not chunk: + break + head += chunk + if head == _PREFACE: + await self._relay(reader, writer, head) + elif head: + await self._http1(reader, writer, head) + + async def _relay(self, reader, writer, head): + """gRPC over HTTP/2: the bytes go to the gRPC server as they came.""" + upstream_reader, upstream_writer = await asyncio.open_connection(*self.grpc_address) + upstream_writer.write(head) + + async def pump(source, sink): + try: + while chunk := await source.read(65536): + sink.write(chunk) + await sink.drain() + if sink.can_write_eof(): + sink.write_eof() + except (ConnectionError, OSError): + pass + + try: + await asyncio.gather(pump(reader, upstream_writer), pump(upstream_reader, writer)) + finally: + upstream_writer.close() + + async def _read(self, reader, timeout): + async with asyncio.timeout(timeout): + return await reader.read(65536) + + async def _event(self, conn, reader, timeout): + while True: + event = conn.next_event() + if event is not h11.NEED_DATA: + return event + conn.receive_data(await self._read(reader, timeout)) + + async def _http1(self, reader, writer, head): + conn = h11.Connection(h11.SERVER) + conn.receive_data(head) + timeout = self.read_timeout + while True: + try: + event = await self._event(conn, reader, timeout) + except (TimeoutError, h11.RemoteProtocolError, ConnectionError): + return + if not isinstance(event, h11.Request): + return + exchange = _Exchange(conn, writer) + try: + body = await self._body(conn, reader, event) + await self._dispatch(exchange, event, body) + except _Plain as refusal: + await exchange.plain(refusal) + return # the body may be unread: the connection cannot be reused + except (TimeoutError, h11.RemoteProtocolError): + return + if conn.our_state is not h11.DONE or conn.their_state is not h11.DONE: + return + conn.start_next_cycle() + timeout = self.idle_timeout + + async def _body(self, conn, reader, request): + bound = self.limit + _ENVELOPE.size + declared = next((v for k, v in request.headers if k == b"content-length"), None) + if declared is not None and int(declared) > bound: + raise _Plain(413, f"content length ({int(declared)}) exceeds the message bound") + body = bytearray() + while True: + event = await self._event(conn, reader, self.read_timeout) + if isinstance(event, h11.Data): + body += event.data + if len(body) > bound: + raise _Plain(413, "request body exceeds the message bound") + elif isinstance(event, h11.EndOfMessage): + return bytes(body) + else: + raise h11.RemoteProtocolError("connection closed inside a request body") + + async def _dispatch(self, exchange, request, body): + if request.method != b"POST": + raise _Plain(405, "only POST is served", [(b"allow", b"POST")]) + path = request.target.split(b"?", 1)[0].decode("latin-1") + method = _method(path) + if method is None: + raise _Plain(404, f"no procedure {path}") + headers = {} + for name, value in request.headers: + headers.setdefault(name.decode("latin-1"), value.decode("latin-1")) + content_type = headers.get("content-type", "") + media = content_type.split(";", 1)[0].strip().lower() + if media.startswith("application/grpc-web"): + protocol = _GRPCWeb(media) + elif media.startswith("application/connect+"): + protocol = _ConnectStream(media) + elif media in ("application/proto", "application/json"): + protocol = _ConnectUnary(media) + else: + raise _Plain( + 415, + f"unsupported content type {content_type!r}", + [(b"accept-post", b"application/proto, application/json")], + ) + codec = protocol.codec + if codec is None: + raise _Plain(415, f"unsupported codec in {content_type!r}") + call = _Call(self.channel, path, method, codec, request.headers, headers, self.limit) + await protocol.answer(exchange, call, body, headers) + + +def _method(path): + service, _, name = path.lstrip("/").partition("/") + if not service or not name or "/" in name: + return None + try: + found = descriptor_pool.Default().FindServiceByName(service) + except KeyError: + return None + return found.methods_by_name.get(name) + + +class _Codec: + def __init__(self, name): + self.name = name + + def request(self, cls, data: bytes) -> bytes: + if self.name == "proto": + return data + try: + return json_format.Parse(data or b"{}", cls()).SerializeToString() + except (json_format.ParseError, UnicodeDecodeError, ValueError) as exc: + raise CallError(grpc.StatusCode.INVALID_ARGUMENT, f"unmarshal JSON: {exc}") from exc + + def response(self, cls, data: bytes) -> bytes: + if self.name == "proto": + return data + document = json_format.MessageToDict(cls.FromString(data)) + return json.dumps(document, separators=(",", ":")).encode() + + +def _codec(name): + return _Codec(name) if name in ("proto", "json") else None + + +class _Call: + """One call dispatched through the gRPC server: request bytes in the protocol's + codec, answers out in it.""" + + def __init__(self, channel, path, method, codec, raw_headers, headers, limit): + self.channel, self.path, self.method, self.codec = channel, path, method, codec + self.limit = limit + self.input = message_factory.GetMessageClass(method.input_type) + self.output = message_factory.GetMessageClass(method.output_type) + self.metadata = _metadata(raw_headers) + self.timeout = _timeout(headers) + + def refuse_streams(self): + if self.method.client_streaming: + raise CallError( + grpc.StatusCode.UNIMPLEMENTED, + f"{self.path} streams its requests: it is served on the gRPC protocol only", + ) + + def decompress(self, data: bytes, encoding: str | None) -> bytes: + if encoding in (None, "", "identity"): + payload = data + elif encoding == "gzip": + inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) + try: + payload = inflater.decompress(data, self.limit + 1) + except zlib.error as exc: + raise CallError(grpc.StatusCode.INVALID_ARGUMENT, f"gzip: {exc}") from exc + if inflater.unconsumed_tail: + payload += b"\0" # still more than the bound + else: + raise CallError(grpc.StatusCode.UNIMPLEMENTED, f"unknown compression {encoding!r}") + if len(payload) > self.limit: + raise CallError( + grpc.StatusCode.RESOURCE_EXHAUSTED, + f"message exceeds the message bound ({self.limit})", + ) + return payload + + async def unary(self, message: bytes): + """The answer and the header and trailer metadata of a unary call.""" + request = self.codec.request(self.input, message) + call = self.channel.unary_unary(self.path)( + request, metadata=self.metadata, timeout=self.timeout + ) + try: + answer = await call + return ( + self.codec.response(self.output, answer), + tuple(await call.initial_metadata() or ()), + tuple(await call.trailing_metadata() or ()), + ) + except grpc.aio.AioRpcError as exc: + raise CallError.of(exc) from None + + def stream(self, message: bytes): + request = self.codec.request(self.input, message) + return self.channel.unary_stream(self.path)( + request, metadata=self.metadata, timeout=self.timeout + ) + + +class _Exchange: + """The response side of one HTTP/1.1 exchange.""" + + def __init__(self, conn, writer): + self.conn, self.writer = conn, writer + + async def send(self, *events): + for event in events: + data = self.conn.send(event) + if data: + self.writer.write(data) + await self.writer.drain() + + async def whole(self, status, headers, body: bytes): + headers = [*headers, (b"content-length", str(len(body)).encode())] + await self.send( + h11.Response(status_code=status, headers=headers), + h11.Data(data=body), + h11.EndOfMessage(), + ) + + async def plain(self, refusal: _Plain): + if self.conn.our_state is not h11.SEND_RESPONSE: + return + headers = [(b"content-type", b"text/plain; charset=utf-8"), (b"connection", b"close")] + await self.whole(refusal.status, headers + refusal.headers, refusal.reason.encode()) + + +class _ConnectUnary: + def __init__(self, media): + self.media = media + self.codec = _codec(media.removeprefix("application/")) + + async def answer(self, exchange, call, body, headers): + response_type = [(b"content-type", self.media.encode())] + try: + call.refuse_streams() + if call.method.server_streaming: + raise CallError( + grpc.StatusCode.UNIMPLEMENTED, + f"{call.path} streams its answers: call it with application/connect+", + ) + message = call.decompress(body, headers.get("content-encoding")) + answer, header, trailer = await call.unary(message) + except CallError as error: + status = _CODES.get(error.code, ("unknown", 500))[1] + out = [(b"content-type", b"application/json")] + out += _headers(error.header) + _headers(_without_details(error.trailer), b"trailer-") + await exchange.whole(status, out, json.dumps(_error(error)).encode()) + return + out = response_type + _headers(header) + _headers(trailer, b"trailer-") + await exchange.whole(200, out, answer) + + +class _ConnectStream: + def __init__(self, media): + self.media = media + self.codec = _codec(media.removeprefix("application/connect+")) + + async def answer(self, exchange, call, body, headers): + stream, header = await _open(call, body, headers.get("connect-content-encoding")) + await exchange.send( + h11.Response( + status_code=200, + headers=[(b"content-type", self.media.encode()), *_headers(header)], + ) + ) + outcome = await _relay(exchange, call, stream) + if isinstance(outcome, CallError): + end = _end_stream(outcome) + else: + end = {"metadata": _metadata_document(outcome)} if outcome else {} + frame = json.dumps(end, separators=(",", ":")).encode() + await exchange.send( + h11.Data(data=_ENVELOPE.pack(_END_STREAM, len(frame)) + frame), h11.EndOfMessage() + ) + + +class _GRPCWeb: + def __init__(self, media): + self.media = media + subtype = media.removeprefix("application/grpc-web") + self.codec = _codec(subtype.removeprefix("+") or "proto") + + async def answer(self, exchange, call, body, headers): + stream, header = await _open(call, body, headers.get("grpc-encoding")) + await exchange.send( + h11.Response( + status_code=200, + headers=[(b"content-type", self.media.encode()), *_headers(header)], + ) + ) + outcome = await _relay(exchange, call, stream) + if isinstance(outcome, CallError): + code, message, trailer = outcome.code, outcome.message, outcome.trailer + else: + code, message, trailer = grpc.StatusCode.OK, "", outcome + lines = [f"grpc-status: {code.value[0]}"] + if message: + lines.append(f"grpc-message: {_percent(message)}") + lines += [f"{k.decode()}: {v.decode('latin-1')}" for k, v in _headers(trailer)] + if code is not grpc.StatusCode.OK and (details := _status_details(trailer)): + lines.append(f"{_STATUS_DETAILS}: {details.decode()}") + block = ("\r\n".join(lines) + "\r\n").encode("latin-1") + await exchange.send( + h11.Data(data=_ENVELOPE.pack(_TRAILERS, len(block)) + block), h11.EndOfMessage() + ) + + +class _Answered: + """A unary answer, relayed as a stream of one.""" + + def __init__(self, answer, trailer): + self.answer, self.trailer = answer, trailer + + +class _Refused: + """A call refused before its first answer.""" + + def __init__(self, error: CallError): + self.error = error + + +async def _open(call, body, encoding): + """Start the call an enveloped request asks for; return what to relay and the header + metadata to answer with. A refusal is relayed too: these protocols always answer 200 + and carry the status at the end of the stream.""" + try: + call.refuse_streams() + payload, compressed = _one_message(body) + message = call.decompress(payload, encoding if compressed else None) + if call.method.server_streaming: + stream = call.stream(message) + return stream, tuple(await stream.initial_metadata() or ()) + answer, header, trailer = await call.unary(message) + return _Answered(answer, trailer), header + except CallError as error: + return _Refused(error), error.header + + +async def _relay(exchange, call, stream): + """Write every answer enveloped; return the trailer metadata, or the CallError the + call ended with.""" + if isinstance(stream, _Refused): + return stream.error + if isinstance(stream, _Answered): + frame = _ENVELOPE.pack(0, len(stream.answer)) + stream.answer + await exchange.send(h11.Data(data=frame)) + return stream.trailer + try: + async for answer in stream: + data = call.codec.response(call.output, answer) + await exchange.send(h11.Data(data=_ENVELOPE.pack(0, len(data)) + data)) + return tuple(await stream.trailing_metadata() or ()) + except grpc.aio.AioRpcError as exc: + return CallError.of(exc) + finally: + stream.cancel() + + +def _end_stream(error: CallError) -> dict: + end: dict = {"error": _error(error)} + trailer = _without_details(error.trailer) + if trailer: + end["metadata"] = _metadata_document(trailer) + return end + + +def _one_message(body: bytes): + if len(body) < _ENVELOPE.size: + raise CallError(grpc.StatusCode.INVALID_ARGUMENT, "truncated message envelope") + flags, size = _ENVELOPE.unpack_from(body) + payload = body[_ENVELOPE.size :] + if len(payload) != size: + raise CallError(grpc.StatusCode.INVALID_ARGUMENT, "exactly one request message expected") + return payload, bool(flags & _COMPRESSED) + + +def _metadata(raw_headers): + metadata = [] + for raw_name, raw_value in raw_headers: + name = raw_name.decode("latin-1").lower() + if name in _TRANSPORT or name.startswith(_TRANSPORT_PREFIXES): + continue + value = raw_value.decode("latin-1") + if name.endswith("-bin"): + try: + metadata.append((name, base64.b64decode(value + "=" * (-len(value) % 4)))) + except ValueError: + raise _Plain(400, f"header {name} is not base64") from None + else: + metadata.append((name, value)) + return tuple(metadata) + + +def _timeout(headers): + if (millis := headers.get("connect-timeout-ms")) is not None: + if not millis.isdigit() or len(millis) > 10: + raise _Plain(400, "connect-timeout-ms is not a duration in milliseconds") + return int(millis) / 1000 + if (grpc_timeout := headers.get("grpc-timeout")) is not None: + match = _GRPC_TIMEOUT.match(grpc_timeout) + if match is None: + raise _Plain(400, "grpc-timeout is not a gRPC duration") + return int(match.group(1)) * _GRPC_UNITS[match.group(2)] + return None + + +def _header_value(key: str, value) -> bytes: + if key.endswith("-bin") or isinstance(value, bytes): + raw = value if isinstance(value, bytes) else value.encode() + return base64.b64encode(raw).rstrip(b"=") + return value.encode("latin-1", "replace") + + +def _headers(metadata: Iterable, prefix: bytes = b""): + out = [] + for key, value in metadata: + if key.startswith(("grpc-", ":")) or key == "content-type": + continue + out.append((prefix + key.encode("latin-1"), _header_value(key, value))) + return out + + +def _metadata_document(metadata) -> dict: + document: dict[str, list[str]] = {} + for key, value in metadata: + if key.startswith(("grpc-", ":")): + continue + document.setdefault(key, []).append(_header_value(key, value).decode("latin-1")) + return document + + +def _without_details(trailer): + return tuple((k, v) for k, v in trailer if k != _STATUS_DETAILS) + + +def _error(error: CallError) -> dict: + document: dict = {"code": _CODES.get(error.code, ("unknown", 500))[0]} + if error.message: + document["message"] = error.message + details = _details(error.trailer) + if details: + document["details"] = details + return document + + +def _status_details(trailer) -> bytes | None: + """The google.rpc.Status a service attached, base64 as a header carries it.""" + raw = next((v for k, v in trailer if k == _STATUS_DETAILS), None) + return base64.b64encode(raw).rstrip(b"=") if isinstance(raw, bytes) else None + + +def _details(trailer): + """The google.rpc.Status details a service attached, as Connect's error details.""" + raw = next((v for k, v in trailer if k == _STATUS_DETAILS), None) + if not isinstance(raw, bytes): + return [] + details, at = [], 0 + try: + while at < len(raw): + key, at = _varint(raw, at) + field, wire = key >> 3, key & 7 + if wire == 0: + _, at = _varint(raw, at) + elif wire == 2: + size, at = _varint(raw, at) + chunk, at = raw[at : at + size], at + size + if field == 3: + detail = any_pb2.Any.FromString(chunk) + details.append( + { + "type": detail.type_url.rsplit("/", 1)[-1], + "value": base64.b64encode(detail.value).rstrip(b"=").decode(), + } + ) + elif wire == 1: + at += 8 + elif wire == 5: + at += 4 + else: + break + except (IndexError, ValueError): + _log.warning("connect face: unreadable %s trailer", _STATUS_DETAILS) + return details + + +def _varint(data: bytes, at: int): + value = shift = 0 + while True: + byte = data[at] + at += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, at + shift += 7 + + +def _percent(message: str) -> str: + # gRPC's grpc-message encoding: printable ASCII but "%" as is, the rest percent-encoded. + return quote(message, safe="".join(chr(c) for c in range(0x20, 0x7F) if c != 0x25)) diff --git a/templates/deployment/kustomize/base/deployment.yaml.tmpl b/templates/deployment/kustomize/base/deployment.yaml.tmpl index 5ea08e7..faa9d0f 100644 --- a/templates/deployment/kustomize/base/deployment.yaml.tmpl +++ b/templates/deployment/kustomize/base/deployment.yaml.tmpl @@ -30,6 +30,10 @@ spec: {{- if .Deployment.Parameters.GRPCEnabled }} - name: grpc containerPort: {{ .Deployment.Parameters.GRPCPort }} +{{- end }} +{{- if .Deployment.Parameters.ConnectEnabled }} + - name: connect + containerPort: {{ .Deployment.Parameters.ConnectPort }} {{- end }} securityContext: allowPrivilegeEscalation: false diff --git a/templates/deployment/kustomize/base/service.yaml.tmpl b/templates/deployment/kustomize/base/service.yaml.tmpl index 85158be..6f4edbb 100644 --- a/templates/deployment/kustomize/base/service.yaml.tmpl +++ b/templates/deployment/kustomize/base/service.yaml.tmpl @@ -17,3 +17,14 @@ spec: port: {{ .Deployment.Parameters.GRPCPort }} targetPort: {{ .Deployment.Parameters.GRPCPort }} {{- end }} +{{- if .Deployment.Parameters.ConnectEnabled }} + - protocol: TCP + name: connect-port + # Connect and gRPC-Web are HTTP/1.1 on this listener (gRPC is HTTP/2 with + # prior knowledge, relayed to the gRPC port). An L7 hop that upgraded a + # Connect call to HTTP/2 would reach the relay, which answers gRPC only, + # so the port declares http. + appProtocol: http + port: {{ .Deployment.Parameters.ConnectPort }} + targetPort: {{ .Deployment.Parameters.ConnectPort }} +{{- end }} diff --git a/testdata/connect/test_connect_gen.py b/testdata/connect/test_connect_gen.py new file mode 100644 index 0000000..3459d33 --- /dev/null +++ b/testdata/connect/test_connect_gen.py @@ -0,0 +1,286 @@ +"""The generated Connect face (templates/connect/connect_gen.py) against a real grpc.aio +server: every protocol it serves answers through that server, interceptors included.""" + +import asyncio +import base64 +import json +import struct + +import connect_gen +import grpc +import httpx +import pytest +from google.protobuf import descriptor_pb2, descriptor_pool, message_factory + +ENVELOPE = struct.Struct(">BI") + + +def _register(): + file = descriptor_pb2.FileDescriptorProto( + name="codefly_connect_test/echo.proto", package="echo.v1", syntax="proto3" + ) + msg = file.message_type.add(name="Msg") + msg.field.add(name="text", number=1, type=9, label=1, json_name="text") + msg.field.add(name="count", number=2, type=5, label=1, json_name="count") + service = file.service.add(name="Echo") + for name, client, server in ( + ("Say", False, False), + ("Count", False, True), + ("Collect", True, False), + ): + service.method.add( + name=name, + input_type=".echo.v1.Msg", + output_type=".echo.v1.Msg", + client_streaming=client, + server_streaming=server, + ) + pool = descriptor_pool.Default() + pool.AddSerializedFile(file.SerializeToString()) + return message_factory.GetMessageClass(pool.FindMessageTypeByName("echo.v1.Msg")) + + +Msg = _register() + + +class Proof(grpc.aio.ServerInterceptor): + """What a hop-stamped service installs: a call without the proof never reaches it.""" + + async def intercept_service(self, continuation, details): + if dict(details.invocation_metadata).get("x-proof") != "stamp": + + async def refuse(request, context): + await context.abort(grpc.StatusCode.UNAUTHENTICATED, "no proof") + + return grpc.unary_unary_rpc_method_handler(refuse) + return await continuation(details) + + +async def say(request, context): + metadata = dict(context.invocation_metadata()) + if request.text == "deny": + context.set_trailing_metadata((("model-error", '{"code":"Denied"}'),)) + await context.abort(grpc.StatusCode.PERMISSION_DENIED, "denied: not yours") + if request.text == "slow": + await asyncio.sleep(2) + await context.send_initial_metadata((("x-answer", "header"),)) + context.set_trailing_metadata((("x-trail", "trailer"), ("x-raw-bin", b"\x00\xff"))) + return Msg(text=f"{request.text}|{metadata.get('x-caller', '')}", count=request.count) + + +async def count(request, context): + for n in range(request.count): + yield Msg(text=request.text, count=n) + if request.text == "fail": + await context.abort(grpc.StatusCode.UNAVAILABLE, "gone") + + +async def collect(requests, context): + texts = [r.text async for r in requests] + return Msg(text=",".join(texts), count=len(texts)) + + +def _handler(): + codec = {"request_deserializer": Msg.FromString, "response_serializer": Msg.SerializeToString} + return grpc.method_handlers_generic_handler( + "echo.v1.Echo", + { + "Say": grpc.unary_unary_rpc_method_handler(say, **codec), + "Count": grpc.unary_stream_rpc_method_handler(count, **codec), + "Collect": grpc.stream_unary_rpc_method_handler(collect, **codec), + }, + ) + + +@pytest.fixture +async def face(): + server = grpc.aio.server(interceptors=(Proof(),)) + server.add_generic_rpc_handlers((_handler(),)) + grpc_port = server.add_insecure_port("127.0.0.1:0") + await server.start() + listener = await connect_gen.serve( + 0, grpc_port, host="127.0.0.1", max_message_bytes=1024, read_timeout=2 + ) + try: + yield listener + finally: + await listener.stop() + await server.stop(None) + + +def _url(face, method): + return f"http://127.0.0.1:{face.port}/echo.v1.Echo/{method}" + + +STAMP = {"x-proof": "stamp", "x-caller": "maya"} + + +def envelope(data, flags=0): + return ENVELOPE.pack(flags, len(data)) + data + + +def frames(body): + out = [] + while body: + flags, size = ENVELOPE.unpack_from(body) + out.append((flags, body[ENVELOPE.size : ENVELOPE.size + size])) + body = body[ENVELOPE.size + size :] + return out + + +async def test_connect_unary_binary_relays_metadata_both_ways(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), + content=Msg(text="hi", count=2).SerializeToString(), + headers={"content-type": "application/proto", **STAMP}, + ) + assert r.status_code == 200, r.text + assert Msg.FromString(r.content) == Msg(text="hi|maya", count=2) + assert r.headers["x-answer"] == "header" + assert r.headers["trailer-x-trail"] == "trailer" + assert base64.b64decode(r.headers["trailer-x-raw-bin"] + "==") == b"\x00\xff" + + +async def test_connect_unary_json(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), + json={"text": "hi", "count": 3}, + headers={"content-type": "application/json", **STAMP}, + ) + assert r.status_code == 200 + assert r.json() == {"text": "hi|maya", "count": 3} + + +async def test_every_protocol_passes_the_services_interceptors(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), json={"text": "hi"}, headers={"content-type": "application/json"} + ) + assert r.status_code == 401 + assert r.json() == {"code": "unauthenticated", "message": "no proof"} + + +async def test_connect_unary_error_carries_code_and_trailers(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), + json={"text": "deny"}, + headers={"content-type": "application/json", **STAMP}, + ) + assert r.status_code == 403 + assert r.json() == {"code": "permission_denied", "message": "denied: not yours"} + assert json.loads(r.headers["trailer-model-error"]) == {"code": "Denied"} + + +async def test_connect_timeout_is_the_calls_deadline(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), + json={"text": "slow"}, + headers={"content-type": "application/json", "connect-timeout-ms": "50", **STAMP}, + ) + assert r.status_code == 504 + assert r.json()["code"] == "deadline_exceeded" + + +async def test_connect_server_stream(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Count"), + content=envelope(Msg(text="x", count=3).SerializeToString()), + headers={"content-type": "application/connect+proto", **STAMP}, + ) + assert r.status_code == 200 + out = frames(r.content) + assert [Msg.FromString(m).count for f, m in out[:-1] if f == 0] == [0, 1, 2] + assert out[-1][0] == 0x02 and json.loads(out[-1][1]) == {} + + +async def test_connect_server_stream_error_ends_the_stream(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Count"), + content=envelope(json.dumps({"text": "fail", "count": 1}).encode()), + headers={"content-type": "application/connect+json", **STAMP}, + ) + out = frames(r.content) + assert json.loads(out[0][1]) == {"text": "fail"} + assert json.loads(out[-1][1])["error"] == {"code": "unavailable", "message": "gone"} + + +async def test_connect_refuses_a_client_stream_over_http1(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Collect"), + content=envelope(Msg(text="a").SerializeToString()), + headers={"content-type": "application/connect+proto", **STAMP}, + ) + end = frames(r.content)[-1] + assert end[0] == 0x02 and json.loads(end[1])["error"]["code"] == "unimplemented" + + +async def test_grpc_web_unary_and_trailers(face): + async with httpx.AsyncClient() as client: + r = await client.post( + _url(face, "Say"), + content=envelope(Msg(text="web").SerializeToString()), + headers={"content-type": "application/grpc-web+proto", **STAMP}, + ) + out = frames(r.content) + assert Msg.FromString(out[0][1]).text == "web|maya" + assert out[-1][0] == 0x80 + trailers = dict(line.split(": ", 1) for line in out[-1][1].decode().strip().split("\r\n")) + assert trailers["grpc-status"] == "0" and trailers["x-trail"] == "trailer" + + +async def test_grpc_on_the_same_listener(face): + async with grpc.aio.insecure_channel(f"127.0.0.1:{face.port}") as channel: + metadata = tuple(STAMP.items()) + say_call = channel.unary_unary( + "/echo.v1.Echo/Say", + request_serializer=Msg.SerializeToString, + response_deserializer=Msg.FromString, + ) + assert (await say_call(Msg(text="g"), metadata=metadata)).text == "g|maya" + collect_call = channel.stream_unary( + "/echo.v1.Echo/Collect", + request_serializer=Msg.SerializeToString, + response_deserializer=Msg.FromString, + ) + answer = await collect_call(iter([Msg(text="a"), Msg(text="b")]), metadata=metadata) + assert answer == Msg(text="a,b", count=2) + + +async def test_http_refusals(face): + async with httpx.AsyncClient() as client: + unknown = await client.post( + _url(face, "Nope"), content=b"", headers={"content-type": "application/proto"} + ) + get = await client.get(_url(face, "Say")) + media = await client.post( + _url(face, "Say"), content=b"", headers={"content-type": "text/plain"} + ) + big = await client.post( + _url(face, "Say"), content=b"x" * 4096, headers={"content-type": "application/proto"} + ) + assert (unknown.status_code, get.status_code, media.status_code) == (404, 405, 415) + assert big.status_code == 413 + + +async def test_one_connection_serves_several_calls(face): + reader, writer = await asyncio.open_connection("127.0.0.1", face.port) + body = json.dumps({"text": "k"}).encode() + request = ( + f"POST /echo.v1.Echo/Say HTTP/1.1\r\nhost: x\r\ncontent-type: application/json\r\n" + f"x-proof: stamp\r\ncontent-length: {len(body)}\r\n\r\n" + ).encode() + body + writer.write(request + request) + await writer.drain() + seen = b"" + while seen.count(b"HTTP/1.1 200") < 2: + chunk = await asyncio.wait_for(reader.read(65536), 2) + assert chunk, seen + seen += chunk + writer.close()