From 2ff5d38ba4f8024c71bbb69b442b12e67781e9ca Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 25 Aug 2026 16:42:33 -0400 Subject: [PATCH 1/2] feat: add optional Python gRPC server and proto scaffolding (#14) Add an opt-in, service-owned grpc.aio listener to the generated FastAPI service. It runs in the same process as the FastAPI app (booted from the lifespan), on its own internal port, and stays entirely off by default so existing REST-only services and their generated layout are unchanged. - grpc-server settings (enabled/proto) + a creation question, disabled by default. - Scaffold proto/api.proto, Buf config, a grpc.aio server, a gRPC health service, and a user-owned servicer seam when enabled. - Regenerate the Python protobuf + gRPC stubs from the proto during Sync. - Create/load a Codefly gRPC endpoint in Builder and Runtime; wire native, container, and Kubernetes port mappings. Co-Authored-By: Claude Opus 4.8 --- builder.go | 106 ++++++++++- grpc_test.go | 165 ++++++++++++++++++ main.go | 32 +++- runtime.go | 34 ++++ .../kustomize/base/deployment.yaml.tmpl | 4 + .../kustomize/base/service.yaml.tmpl | 6 + templates/factory/code/pyproject.toml.tmpl | 8 + templates/factory/code/src/main.py.tmpl | 22 +++ templates/grpc/code/.gitignore.tmpl | 2 + templates/grpc/code/proto/api.proto.tmpl | 17 ++ templates/grpc/code/proto/buf.gen.yaml.tmpl | 6 + templates/grpc/code/proto/buf.yaml.tmpl | 9 + templates/grpc/code/src/rpc/__init__.py.tmpl | 8 + templates/grpc/code/src/rpc/server.py.tmpl | 39 +++++ templates/grpc/code/src/rpc/servicer.py.tmpl | 9 + .../grpc/code/tests/rpc/__init__.py.tmpl | 0 .../grpc/code/tests/rpc/conftest.py.tmpl | 30 ++++ .../grpc/code/tests/rpc/test_grpc.py.tmpl | 38 ++++ 18 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 grpc_test.go create mode 100644 templates/grpc/code/.gitignore.tmpl create mode 100644 templates/grpc/code/proto/api.proto.tmpl create mode 100644 templates/grpc/code/proto/buf.gen.yaml.tmpl create mode 100644 templates/grpc/code/proto/buf.yaml.tmpl create mode 100644 templates/grpc/code/src/rpc/__init__.py.tmpl create mode 100644 templates/grpc/code/src/rpc/server.py.tmpl create mode 100644 templates/grpc/code/src/rpc/servicer.py.tmpl create mode 100644 templates/grpc/code/tests/rpc/__init__.py.tmpl create mode 100644 templates/grpc/code/tests/rpc/conftest.py.tmpl create mode 100644 templates/grpc/code/tests/rpc/test_grpc.py.tmpl diff --git a/builder.go b/builder.go index aa8940c..a5e4b09 100644 --- a/builder.go +++ b/builder.go @@ -38,7 +38,8 @@ import ( // Inherited: Init. // Overridden: Load (fastapi puts source under ./code, discovers REST // endpoint), Update (applies builder templates), Sync (gRPC codegen for -// declared dependencies), Build (custom DockerTemplating + docker build), +// declared dependencies and the optional service-owned gRPC server), Build +// (custom DockerTemplating + docker build), // Deploy (k8s), Create (two-question Communicate + REST endpoint). type Builder struct { *pythonbuilder.Builder @@ -129,9 +130,30 @@ func (s *Builder) Sync(ctx context.Context, _ *builderv0.SyncRequest) (*builderv return s.Base.Builder.SyncError(err) } } + + if s.FastAPI.Settings.GRPCServer.Enabled { + if err := s.syncGRPCServer(ctx); err != nil { + return s.Base.Builder.SyncError(err) + } + } return s.Base.Builder.SyncResponse() } +// syncGRPCServer regenerates the Python protobuf + grpc.aio server stubs from +// the service-owned proto contract via Buf. Generation is cached on the proto +// tree, so it re-runs deterministically only when the contract changes. +func (s *Builder) syncGRPCServer(ctx context.Context) error { + buf, err := proto.NewBuf(ctx, s.Local("code")) + if err != nil { + return s.Wool.Wrapf(err, "cannot create proto generator") + } + buf.WithGeneratedDirs(s.Local("code/src/rpc/_generated")) + if err := buf.Generate(ctx); err != nil { + return s.Wool.Wrapf(err, "cannot generate grpc server code") + } + return nil +} + // Env + DockerTemplating structs are the template context for the // builder Dockerfile. type Env struct { @@ -247,7 +269,13 @@ func (s *Builder) Upgrade(ctx context.Context, req *builderv0.UpgradeRequest) (* } // Parameters is the template parameter set for the k8s deployment. -type Parameters struct{} +type Parameters struct { + // GRPCEnabled adds the gRPC containerPort and Service port to the rendered + // manifests. GRPCPort is the fixed in-cluster port the grpc.aio listener + // binds (the app defaults to it when CODEFLY_GRPC_PORT is unset). + GRPCEnabled bool + GRPCPort int +} // Deploy renders and applies k8s manifests. func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) (*builderv0.DeploymentResponse, error) { @@ -260,7 +288,10 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) OwnConfiguration: true, DependencyConfigurations: true, }, - Parameters: Parameters{}, + Parameters: Parameters{ + GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled, + GRPCPort: int(standards.Port(standards.GRPC)), + }, }) } @@ -269,6 +300,7 @@ func (s *Builder) Options() []*agentv0.Question { return []*agentv0.Question{ communicate.NewConfirm(&agentv0.Message{Name: PublicEndpoint, Message: "Expose API as public", Description: "is that directly accessible from the internet?"}, true), communicate.NewConfirm(&agentv0.Message{Name: HotReload, Message: "Code hot-reload (Recommended)?", Description: "codefly can restart your service when code changes are detected 🔎"}, true), + communicate.NewConfirm(&agentv0.Message{Name: GRPCServer, Message: "Add a gRPC server?", Description: "runs a grpc.aio listener alongside FastAPI with a proto contract ⚙️"}, false), } } @@ -277,6 +309,10 @@ type CreateConfiguration struct { *services.Information Image *resources.DockerImage Envs []string + + // GRPCEnabled gates the gRPC boot in src/main.py and the grpcio + // dependencies in pyproject.toml. False keeps the REST-only scaffold. + GRPCEnabled bool } // Create applies factory templates, scaffolds src/tests dirs, and @@ -295,11 +331,21 @@ func (s *Builder) Create(ctx context.Context, _ *builderv0.CreateRequest) (*buil } } - create := CreateConfiguration{Information: s.Information, Envs: []string{}} + create := CreateConfiguration{Information: s.Information, Envs: []string{}, GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled} if err := s.Base.Templates(ctx, create, services.WithFactory(factoryFS)); err != nil { return s.Base.Builder.CreateError(err) } + // The gRPC scaffold (proto contract, buf config, grpc.aio server + user + // servicer seam) is applied from a separate tree only when opted in, so a + // REST-only service keeps its generated layout untouched. + if s.FastAPI.Settings.GRPCServer.Enabled { + grpc := services.WithTemplate(grpcFS, "grpc", "").WithOverride(shared.SkipAll()) + if err := s.Base.Templates(ctx, create, grpc); err != nil { + return s.Base.Builder.CreateError(err) + } + } + // Scaffold package + tests dirs with empty __init__.py. if _, err := shared.CheckDirectoryOrCreate(ctx, s.Local("code/src")); err != nil { return s.Base.Builder.CreateError(err) @@ -347,9 +393,39 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error { } s.FastAPI.RestEndpoint = api s.Endpoints = []*basev0.Endpoint{s.FastAPI.RestEndpoint} + + if s.FastAPI.Settings.GRPCServer.Enabled { + grpcEndpoint, grpcErr := s.grpcEndpoint(ctx) + if grpcErr != nil { + return grpcErr + } + s.FastAPI.GRPCEndpoint = grpcEndpoint + s.Endpoints = append(s.Endpoints, grpcEndpoint) + } return nil } +// grpcEndpoint builds the service-owned gRPC endpoint from the proto contract. +// The proto path is resolved relative to the Python source dir (code/), where +// the scaffolded proto/ tree and the generated stubs both live. It inherits the +// same public/private visibility as the REST endpoint. +func (s *Builder) grpcEndpoint(ctx context.Context) (*basev0.Endpoint, error) { + protoPath := s.Local("code/%s", s.FastAPI.Settings.GRPCServer.Proto) + grpc, err := resources.LoadGrpcAPI(ctx, shared.Pointer(protoPath)) + if err != nil { + return nil, s.Wool.Wrapf(err, "cannot load grpc proto %q", protoPath) + } + endpoint := s.Base.BaseEndpoint(standards.GRPC) + if s.FastAPI.Settings.PublicEndpoint { + endpoint.Visibility = resources.VisibilityPublic + } + api, err := resources.NewAPI(ctx, endpoint, resources.ToGrpcAPI(grpc)) + if err != nil { + return nil, s.Wool.Wrapf(err, "cannot create grpc api") + } + return api, nil +} + // isFileNotExistErr matches the bespoke "file does not exist" error string // that resources.LoadRestAPI returns for missing files — it doesn't wrap // os.ErrNotExist, so errors.Is can't catch it directly. @@ -365,9 +441,21 @@ func (s *Builder) populateSettingsFromAnswers() error { if s.FastAPI.Settings.PublicEndpoint, err = communicate.Confirm(s.answers, PublicEndpoint); err != nil { return err } + if s.FastAPI.Settings.GRPCServer.Enabled, err = communicate.Confirm(s.answers, GRPCServer); err != nil { + return err + } + s.applyGRPCDefaults() return nil } +// applyGRPCDefaults fills the proto path when the server is enabled but the +// path was left blank (interactive answers and defaults never set it). +func (s *Builder) applyGRPCDefaults() { + if s.FastAPI.Settings.GRPCServer.Enabled && s.FastAPI.Settings.GRPCServer.Proto == "" { + s.FastAPI.Settings.GRPCServer.Proto = defaultProtoPath + } +} + func (s *Builder) populateSettingsFromDefaults() error { opts := s.Options() var err error @@ -377,6 +465,10 @@ func (s *Builder) populateSettingsFromDefaults() error { if s.FastAPI.Settings.PublicEndpoint, err = communicate.GetDefaultConfirm(opts, PublicEndpoint); err != nil { return err } + if s.FastAPI.Settings.GRPCServer.Enabled, err = communicate.GetDefaultConfirm(opts, GRPCServer); err != nil { + return err + } + s.applyGRPCDefaults() return nil } @@ -400,6 +492,12 @@ func renderFromFactory(ctx context.Context, info *services.Information) (string, //go:embed templates/factory var factoryFS embed.FS +// all: so the scaffold's dotfiles (code/.gitignore) are embedded — go:embed +// skips names beginning with "." without it. +// +//go:embed all:templates/grpc +var grpcFS embed.FS + //go:embed templates/builder var builderFS embed.FS diff --git a/grpc_test.go b/grpc_test.go new file mode 100644 index 0000000..efbfb21 --- /dev/null +++ b/grpc_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + 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/codefly-dev/core/resources" + "github.com/codefly-dev/core/shared" + "github.com/codefly-dev/core/standards" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + agenttesting "github.com/codefly-dev/core/agents/testing" +) + +// TestSettingsParseGRPCServer proves the nested grpc-server block round-trips +// through the flat inline-embedded Settings and defaults to disabled. +func TestSettingsParseGRPCServer(t *testing.T) { + var enabled Settings + require.NoError(t, yaml.Unmarshal([]byte(` +python-version: "3.12" +grpc-server: + enabled: true + proto: proto/api.proto +`), &enabled)) + require.True(t, enabled.GRPCServer.Enabled) + require.Equal(t, "proto/api.proto", enabled.GRPCServer.Proto) + + var absent Settings + require.NoError(t, yaml.Unmarshal([]byte(`python-version: "3.12"`), &absent)) + require.False(t, absent.GRPCServer.Enabled) +} + +func confirmAnswer(value bool) *agentv0.Answer { + return &agentv0.Answer{Value: &agentv0.Answer_Confirm{Confirm: &agentv0.ConfirmAnswer{Confirmed: value}}} +} + +// createServiceForTest drives Load + Create in a temp workspace, mirroring the +// setup in main_test but stopping before Runtime so it needs no docker/network. +func createServiceForTest(t *testing.T, answers map[string]*agentv0.Answer) (*Builder, string) { + t.Helper() + ctx := context.Background() + tmpDir, err := os.MkdirTemp("testdata", "grpc") + require.NoError(t, err) + tmpDir = shared.MustSolvePath(tmpDir) + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + serviceName := fmt.Sprintf("svc-%v", time.Now().UnixMilli()) + service := resources.Service{Name: serviceName, Version: "0.0.0"} + require.NoError(t, service.SaveAtDir(ctx, path.Join(tmpDir, fmt.Sprintf("mod/%s", service.Name)))) + + identity := &basev0.ServiceIdentity{ + Name: service.Name, + Version: service.Version, + Module: "mod", + Workspace: "test", + WorkspacePath: tmpDir, + RelativeToWorkspace: fmt.Sprintf("mod/%s", service.Name), + } + + svc := NewService() + builder := NewBuilder(svc) + + communicate := answers != nil + _, err = builder.Load(ctx, &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: communicate}}) + require.NoError(t, err) + if communicate { + builder.answers = answers + } + + _, err = builder.Create(ctx, &builderv0.CreateRequest{}) + require.NoError(t, err) + return builder, path.Join(tmpDir, fmt.Sprintf("mod/%s", service.Name)) +} + +// TestCreateRESTOnlyByDefault pins the untouched REST-only layout: one +// endpoint, no proto tree, and no gRPC dependencies or boot code leaking into +// the scaffold. +func TestCreateRESTOnlyByDefault(t *testing.T) { + builder, root := createServiceForTest(t, nil) + + require.False(t, builder.FastAPI.Settings.GRPCServer.Enabled) + require.Equal(t, 1, len(builder.Endpoints)) + require.Nil(t, builder.FastAPI.GRPCEndpoint) + + _, err := os.Stat(filepath.Join(root, "code/proto/api.proto")) + require.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(root, "code/src/rpc/server.py")) + require.True(t, os.IsNotExist(err)) + + pyproject, err := os.ReadFile(filepath.Join(root, "code/pyproject.toml")) + require.NoError(t, err) + require.NotContains(t, string(pyproject), "grpcio") + + main, err := os.ReadFile(filepath.Join(root, "code/src/main.py")) + require.NoError(t, err) + require.NotContains(t, string(main), "CODEFLY_GRPC_PORT") +} + +// TestCreateWithGRPCServer proves opting in scaffolds the proto contract, the +// server/servicer seam, the dependencies and boot code, and advertises a +// second gRPC endpoint alongside REST. +func TestCreateWithGRPCServer(t *testing.T) { + builder, root := createServiceForTest(t, map[string]*agentv0.Answer{ + HotReload: confirmAnswer(false), + PublicEndpoint: confirmAnswer(false), + GRPCServer: confirmAnswer(true), + }) + + require.True(t, builder.FastAPI.Settings.GRPCServer.Enabled) + require.Equal(t, defaultProtoPath, builder.FastAPI.Settings.GRPCServer.Proto) + + require.Equal(t, 2, len(builder.Endpoints)) + require.NotNil(t, builder.FastAPI.GRPCEndpoint) + require.Equal(t, standards.GRPC, builder.FastAPI.GRPCEndpoint.Api) + + for _, rel := range []string{ + "code/proto/api.proto", + "code/proto/buf.gen.yaml", + "code/src/rpc/server.py", + "code/src/rpc/servicer.py", + "code/tests/rpc/test_grpc.py", + "code/.gitignore", + } { + _, err := os.Stat(filepath.Join(root, rel)) + require.NoError(t, err, "expected scaffolded file %s", rel) + } + + pyproject, err := os.ReadFile(filepath.Join(root, "code/pyproject.toml")) + require.NoError(t, err) + require.Contains(t, string(pyproject), "grpcio") + require.Contains(t, string(pyproject), "grpcio-tools") + + main, err := os.ReadFile(filepath.Join(root, "code/src/main.py")) + require.NoError(t, err) + require.Contains(t, string(main), "CODEFLY_GRPC_PORT") + require.Contains(t, string(main), "from src.rpc.server import serve") +} + +// TestDeploymentRendersGRPCPort proves the gRPC container/service ports appear +// only when the deployment parameters opt in. +func TestDeploymentRendersGRPCPort(t *testing.T) { + enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{GRPCEnabled: true, GRPCPort: 9090}) + svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml")) + require.NoError(t, err) + require.Contains(t, string(svc), "grpc-port") + require.Contains(t, string(svc), "9090") + + deployment, err := os.ReadFile(filepath.Join(enabled, "base", "deployment.yaml")) + require.NoError(t, err) + require.Contains(t, string(deployment), "containerPort: 9090") + + disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{}) + svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml")) + require.NoError(t, err) + require.NotContains(t, string(svc), "grpc-port") +} diff --git a/main.go b/main.go index a4037cd..58b0640 100644 --- a/main.go +++ b/main.go @@ -39,8 +39,25 @@ var requirements = builders.NewDependencies(agent.Name, const ( HotReload = "hot-reload" PublicEndpoint = "public-endpoint" + GRPCServer = "grpc-server" ) +// defaultProtoPath is where the service-owned proto contract lives, relative +// to the Python source dir (code/). Mirrors how openapi/api.swagger.json sits +// beside the source. Buf generation and the gRPC endpoint both resolve it. +const defaultProtoPath = "proto/api.proto" + +// GRPCServerSettings configures the optional service-owned grpc.aio listener. +// Disabled by default: an unset grpc-server block leaves the service REST-only +// and its generated layout unchanged. +type GRPCServerSettings struct { + Enabled bool `yaml:"enabled"` + + // Proto is the proto contract path relative to the Python source dir. + // Defaults to proto/api.proto when the server is enabled. + Proto string `yaml:"proto"` +} + // Settings inherits the generic Python Settings (PythonVersion) and adds // FastAPI-specific fields. `yaml:",inline"` means the YAML shape is flat: // @@ -53,6 +70,10 @@ type Settings struct { HotReload bool `yaml:"hot-reload"` PublicEndpoint bool `yaml:"public-endpoint"` + // GRPCServer opts the service into a grpc.aio listener running in the same + // process as the FastAPI app (see grpc-server:). Disabled by default. + GRPCServer GRPCServerSettings `yaml:"grpc-server"` + // RuntimeImage overrides the default codefly-built runtime image. // Format: "name:tag". Plain "name" and ":latest" are rejected — // pinning is enforced. Leave empty to use codeflydev/python: @@ -87,6 +108,10 @@ type Service struct { Settings *Settings RestEndpoint *v0.Endpoint + + // GRPCEndpoint is the service-owned gRPC endpoint, present only when + // Settings.GRPCServer.Enabled. Nil keeps the REST-only path untouched. + GRPCEndpoint *v0.Endpoint } // GetAgentInformation overrides the generic info to advertise HTTP protocol @@ -109,8 +134,11 @@ func (s *Service) GetAgentInformation(ctx context.Context, _ *agentv0.AgentInfor Toolchains: []agentv0.Toolchain_Type{agentv0.Toolchain_PYTHON}, HotReload: true, Languages: []agentv0.Language_Type{agentv0.Language_PYTHON}, - Protocols: []agentv0.Protocol_Type{agentv0.Protocol_HTTP}, - ReadMe: readme, + // 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. + Protocols: []agentv0.Protocol_Type{agentv0.Protocol_HTTP, agentv0.Protocol_GRPC}, + ReadMe: readme, }.Build(), nil } diff --git a/runtime.go b/runtime.go index 3a2d65a..565a297 100644 --- a/runtime.go +++ b/runtime.go @@ -56,6 +56,10 @@ type Runtime struct { port uint16 + // grpcPort is the mapped port for the service-owned gRPC listener, passed + // to the Python process as CODEFLY_GRPC_PORT. Zero when gRPC is disabled. + grpcPort uint16 + cacheLocation string } @@ -92,6 +96,13 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim return s.Base.Runtime.LoadError(err) } + if s.FastAPI.Settings.GRPCServer.Enabled { + s.FastAPI.GRPCEndpoint, err = resources.FindGRPCEndpoint(ctx, s.Endpoints) + if err != nil { + return s.Base.Runtime.LoadError(err) + } + } + // 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 @@ -140,6 +151,14 @@ func (s *Runtime) CreateRunnerEnvironment(ctx context.Context) error { } dockerEnv.WithPort(ctx, uint16(instance.Port)) + if s.FastAPI.GRPCEndpoint != nil { + grpcInstance, grpcErr := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.GRPCEndpoint, resources.NewNativeNetworkAccess()) + if grpcErr != nil { + return s.Wool.Wrapf(grpcErr, "cannot find grpc network instance") + } + dockerEnv.WithPort(ctx, uint16(grpcInstance.Port)) + } + envPath := s.DockerEnvPath() if _, err = shared.CheckDirectoryOrCreate(ctx, envPath); err != nil { return s.Wool.Wrapf(err, "cannot create docker venv environment") @@ -260,6 +279,15 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim s.Infof("will run on %s", net.Address) s.port = uint16(net.Port) + if s.FastAPI.GRPCEndpoint != nil { + grpcNet, grpcErr := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.GRPCEndpoint, resources.NewNativeNetworkAccess()) + if grpcErr != nil { + return s.Base.Runtime.InitError(grpcErr) + } + s.grpcPort = uint16(grpcNet.Port) + s.Infof("grpc will run on %s", grpcNet.Address) + } + hasPyProject, err := shared.FileExists(ctx, path.Join(s.Service.SourceLocation, "pyproject.toml")) if err != nil { return s.Base.Runtime.InitError(err) @@ -353,6 +381,12 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt } proc.WithEnvironmentVariables(ctx, startEnvs...) proc.WithEnvironmentVariables(ctx, s.EnvironmentVariables.Secrets()...) + if s.grpcPort != 0 { + // The FastAPI lifespan boots the grpc.aio listener on this port + // (src/rpc/server.py); in container/k8s the app falls back to the + // standard gRPC port when the variable is unset. + proc.WithEnvironmentVariables(ctx, resources.Env("CODEFLY_GRPC_PORT", s.grpcPort)) + } s.runner = proc diff --git a/templates/deployment/kustomize/base/deployment.yaml.tmpl b/templates/deployment/kustomize/base/deployment.yaml.tmpl index ebadcca..b2f3d60 100644 --- a/templates/deployment/kustomize/base/deployment.yaml.tmpl +++ b/templates/deployment/kustomize/base/deployment.yaml.tmpl @@ -27,6 +27,10 @@ spec: image: {{ .Image.FullName }} ports: - containerPort: 8080 +{{- if .Deployment.Parameters.GRPCEnabled }} + - name: grpc + containerPort: {{ .Deployment.Parameters.GRPCPort }} +{{- end }} securityContext: allowPrivilegeEscalation: false runAsNonRoot: true diff --git a/templates/deployment/kustomize/base/service.yaml.tmpl b/templates/deployment/kustomize/base/service.yaml.tmpl index ea3bc1d..85158be 100644 --- a/templates/deployment/kustomize/base/service.yaml.tmpl +++ b/templates/deployment/kustomize/base/service.yaml.tmpl @@ -11,3 +11,9 @@ spec: name: http-port port: 8080 targetPort: 8080 +{{- if .Deployment.Parameters.GRPCEnabled }} + - protocol: TCP + name: grpc-port + port: {{ .Deployment.Parameters.GRPCPort }} + targetPort: {{ .Deployment.Parameters.GRPCPort }} +{{- end }} diff --git a/templates/factory/code/pyproject.toml.tmpl b/templates/factory/code/pyproject.toml.tmpl index bc682fa..e124d86 100644 --- a/templates/factory/code/pyproject.toml.tmpl +++ b/templates/factory/code/pyproject.toml.tmpl @@ -8,6 +8,11 @@ dependencies = [ "uvicorn>=0.25.0", "codefly-sdk>=0.0.14", "pydantic>=2.9.2", +{{- if .GRPCEnabled }} + "grpcio>=1.60.0", + "grpcio-health-checking>=1.60.0", + "protobuf>=4.25.0", +{{- end }} ] [dependency-groups] @@ -17,6 +22,9 @@ dev = [ "pytest-asyncio>=0.23.6", "codefly-cli>=0.0.19", "ruff>=0.6.0", +{{- if .GRPCEnabled }} + "grpcio-tools>=1.60.0", +{{- end }} ] [tool.uv] diff --git a/templates/factory/code/src/main.py.tmpl b/templates/factory/code/src/main.py.tmpl index 6445c19..64ea12e 100644 --- a/templates/factory/code/src/main.py.tmpl +++ b/templates/factory/code/src/main.py.tmpl @@ -51,6 +51,28 @@ for plugin in plugins: app.add_event_handler("startup", plugin.startup) if plugin.shutdown: app.add_event_handler("shutdown", plugin.shutdown) +{{- if .GRPCEnabled }} + +# gRPC server — runs a grpc.aio listener in this process alongside FastAPI. +# The port is provided by codefly at runtime; it falls back to the standard +# gRPC port in container/Kubernetes where the mapping is fixed. +from src.rpc.server import serve as _grpc_serve + +_grpc_server = None + + +@app.on_event("startup") +async def _start_grpc(): + global _grpc_server + port = int(os.environ.get("CODEFLY_GRPC_PORT", "9090")) + _grpc_server = await _grpc_serve(port) + + +@app.on_event("shutdown") +async def _stop_grpc(): + if _grpc_server is not None: + await _grpc_server.stop(grace=5) +{{- end }} if __name__ == "__main__": import uvicorn diff --git a/templates/grpc/code/.gitignore.tmpl b/templates/grpc/code/.gitignore.tmpl new file mode 100644 index 0000000..eda71de --- /dev/null +++ b/templates/grpc/code/.gitignore.tmpl @@ -0,0 +1,2 @@ +# Protobuf + gRPC stubs regenerated by codefly Sync. +src/rpc/_generated/ diff --git a/templates/grpc/code/proto/api.proto.tmpl b/templates/grpc/code/proto/api.proto.tmpl new file mode 100644 index 0000000..787ed75 --- /dev/null +++ b/templates/grpc/code/proto/api.proto.tmpl @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package api; + +// EchoService is a starter contract. Replace it with your own RPCs — Sync +// regenerates the Python stubs and the grpc.aio server picks them up. +service EchoService { + rpc Echo(EchoRequest) returns (EchoResponse); +} + +message EchoRequest { + string message = 1; +} + +message EchoResponse { + string message = 1; +} diff --git a/templates/grpc/code/proto/buf.gen.yaml.tmpl b/templates/grpc/code/proto/buf.gen.yaml.tmpl new file mode 100644 index 0000000..da510bf --- /dev/null +++ b/templates/grpc/code/proto/buf.gen.yaml.tmpl @@ -0,0 +1,6 @@ +version: v1 +plugins: + - plugin: buf.build/protocolbuffers/python + out: ../src/rpc/_generated + - plugin: buf.build/grpc/python + out: ../src/rpc/_generated diff --git a/templates/grpc/code/proto/buf.yaml.tmpl b/templates/grpc/code/proto/buf.yaml.tmpl new file mode 100644 index 0000000..428cfb7 --- /dev/null +++ b/templates/grpc/code/proto/buf.yaml.tmpl @@ -0,0 +1,9 @@ +version: v1 +lint: + use: + - DEFAULT + ignore: + - PACKAGE_UNUSED +breaking: + use: + - FILE diff --git a/templates/grpc/code/src/rpc/__init__.py.tmpl b/templates/grpc/code/src/rpc/__init__.py.tmpl new file mode 100644 index 0000000..f1ee90b --- /dev/null +++ b/templates/grpc/code/src/rpc/__init__.py.tmpl @@ -0,0 +1,8 @@ +import os +import sys + +# The gRPC Python plugin emits flat, top-level imports (``import api_pb2``), so +# the generated directory must be importable directly rather than as a package. +_GENERATED = os.path.join(os.path.dirname(__file__), "_generated") +if os.path.isdir(_GENERATED) and _GENERATED not in sys.path: + sys.path.insert(0, _GENERATED) diff --git a/templates/grpc/code/src/rpc/server.py.tmpl b/templates/grpc/code/src/rpc/server.py.tmpl new file mode 100644 index 0000000..7d62903 --- /dev/null +++ b/templates/grpc/code/src/rpc/server.py.tmpl @@ -0,0 +1,39 @@ +import grpc +from grpc_health.v1 import health_pb2, health_pb2_grpc + +import api_pb2 as pb2 +import api_pb2_grpc as pb2_grpc + +from src.rpc.servicer import EchoServicer + + +class _HealthServicer(health_pb2_grpc.HealthServicer): + async def Check(self, request, context): + return health_pb2.HealthCheckResponse( + status=health_pb2.HealthCheckResponse.SERVING + ) + + async def Watch(self, request, context): + await context.write( + health_pb2.HealthCheckResponse( + status=health_pb2.HealthCheckResponse.SERVING + ) + ) + + +def register(server: grpc.aio.Server) -> None: + """Register the service and health servicers on a grpc.aio server. + + Add your own servicers here as you grow the proto contract. + """ + pb2_grpc.add_EchoServiceServicer_to_server(EchoServicer(), server) + health_pb2_grpc.add_HealthServicer_to_server(_HealthServicer(), server) + + +async def serve(port: int) -> grpc.aio.Server: + """Start a grpc.aio server on ``port`` and return it (already started).""" + server = grpc.aio.server() + register(server) + server.add_insecure_port(f"[::]:{port}") + await server.start() + return server diff --git a/templates/grpc/code/src/rpc/servicer.py.tmpl b/templates/grpc/code/src/rpc/servicer.py.tmpl new file mode 100644 index 0000000..45540b4 --- /dev/null +++ b/templates/grpc/code/src/rpc/servicer.py.tmpl @@ -0,0 +1,9 @@ +import api_pb2 +import api_pb2_grpc + + +# EchoServicer is the user-owned implementation seam. Regenerating the proto +# stubs never overwrites this file — implement your RPCs here. +class EchoServicer(api_pb2_grpc.EchoServiceServicer): + async def Echo(self, request, context): + return api_pb2.EchoResponse(message=request.message) diff --git a/templates/grpc/code/tests/rpc/__init__.py.tmpl b/templates/grpc/code/tests/rpc/__init__.py.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/templates/grpc/code/tests/rpc/conftest.py.tmpl b/templates/grpc/code/tests/rpc/conftest.py.tmpl new file mode 100644 index 0000000..7023d4a --- /dev/null +++ b/templates/grpc/code/tests/rpc/conftest.py.tmpl @@ -0,0 +1,30 @@ +import os +import subprocess +import sys + +# The gRPC stubs are produced by `codefly sync` (Buf) before a normal test run. +# Generate them here too so the suite is self-contained when run directly. +_CODE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_PROTO = os.path.join(_CODE, "proto") +_GENERATED = os.path.join(_CODE, "src", "rpc", "_generated") + + +def _ensure_generated() -> None: + if os.path.exists(os.path.join(_GENERATED, "api_pb2_grpc.py")): + return + os.makedirs(_GENERATED, exist_ok=True) + subprocess.run( + [ + sys.executable, + "-m", + "grpc_tools.protoc", + f"-I{_PROTO}", + f"--python_out={_GENERATED}", + f"--grpc_python_out={_GENERATED}", + os.path.join(_PROTO, "api.proto"), + ], + check=True, + ) + + +_ensure_generated() diff --git a/templates/grpc/code/tests/rpc/test_grpc.py.tmpl b/templates/grpc/code/tests/rpc/test_grpc.py.tmpl new file mode 100644 index 0000000..140cff8 --- /dev/null +++ b/templates/grpc/code/tests/rpc/test_grpc.py.tmpl @@ -0,0 +1,38 @@ +import socket + +import grpc +from grpc_health.v1 import health_pb2, health_pb2_grpc + +from src.rpc import server + + +def _free_port() -> int: + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +async def test_echo_roundtrip(): + port = _free_port() + grpc_server = await server.serve(port) + try: + async with grpc.aio.insecure_channel(f"localhost:{port}") as channel: + stub = server.pb2_grpc.EchoServiceStub(channel) + response = await stub.Echo(server.pb2.EchoRequest(message="hello")) + assert response.message == "hello" + finally: + await grpc_server.stop(grace=None) + + +async def test_health_serving(): + port = _free_port() + grpc_server = await server.serve(port) + try: + async with grpc.aio.insecure_channel(f"localhost:{port}") as channel: + health = health_pb2_grpc.HealthStub(channel) + response = await health.Check(health_pb2.HealthCheckRequest()) + assert response.status == health_pb2.HealthCheckResponse.SERVING + finally: + await grpc_server.stop(grace=None) From 07e26d5483936ead06d2e6e7824afced69ef4f7a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 25 Aug 2026 17:02:27 -0400 Subject: [PATCH 2/2] fix: place gRPC proto at core-standard path and make grpc boot import-safe (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on the optional gRPC server. #1 (main.py): the grpc server was imported at module top level, so importing src.main required the generated stubs. Runtime.Init runs GenerateOpenAPI, which imports src.main *before* Sync generates those stubs (and they are git-ignored, so a fresh clone has none) — Init failed with ModuleNotFoundError. The import now lives inside the startup handler; OpenAPI generation never fires startup, so the module imports cleanly without stubs. #2 (proto location): the proto was scaffolded at code/proto/api.proto, but core's LoadEndpoints re-derives the gRPC contract from standards.ProtoPath (proto/api.proto at the service root) and the manifest stores no proto bytes. The endpoint therefore reloaded with zero RPCs and dependent services could not generate clients. The proto now lives at the service-root standard path (Buf and grpcEndpoint read it there), and defaultProtoPath is bound to standards.ProtoPath so it can never drift again. #3 (server.py): add_insecure_port returns 0 instead of raising when a port can't be bound, so the server would "start" listening on nothing while FastAPI stayed healthy. It now raises, which also fails the pod's HTTP readiness probe via the shared lifespan (covers the gRPC-readiness gap). Tests: added a reload regression test asserting the gRPC endpoint keeps its RPCs after LoadEndpoints (would have caught #2), and a check that src.rpc is never imported at module top level (would have caught #1). Co-Authored-By: Claude Opus 4.8 --- builder.go | 17 +++--- grpc_test.go | 57 +++++++++++++++++-- main.go | 9 ++- templates/factory/code/src/main.py.tmpl | 7 ++- templates/grpc/code/src/rpc/server.py.tmpl | 6 +- .../grpc/code/tests/rpc/conftest.py.tmpl | 2 +- .../grpc/{code => }/proto/api.proto.tmpl | 0 .../grpc/{code => }/proto/buf.gen.yaml.tmpl | 4 +- templates/grpc/{code => }/proto/buf.yaml.tmpl | 0 9 files changed, 80 insertions(+), 22 deletions(-) rename templates/grpc/{code => }/proto/api.proto.tmpl (100%) rename templates/grpc/{code => }/proto/buf.gen.yaml.tmpl (58%) rename templates/grpc/{code => }/proto/buf.yaml.tmpl (100%) diff --git a/builder.go b/builder.go index a5e4b09..e93686b 100644 --- a/builder.go +++ b/builder.go @@ -140,10 +140,12 @@ func (s *Builder) Sync(ctx context.Context, _ *builderv0.SyncRequest) (*builderv } // syncGRPCServer regenerates the Python protobuf + grpc.aio server stubs from -// the service-owned proto contract via Buf. Generation is cached on the proto -// tree, so it re-runs deterministically only when the contract changes. +// 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 +// Python source tree. Generation is cached on the proto tree, so it re-runs +// deterministically only when the contract changes. func (s *Builder) syncGRPCServer(ctx context.Context) error { - buf, err := proto.NewBuf(ctx, s.Local("code")) + buf, err := proto.NewBuf(ctx, s.Location) if err != nil { return s.Wool.Wrapf(err, "cannot create proto generator") } @@ -406,11 +408,12 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error { } // grpcEndpoint builds the service-owned gRPC endpoint from the proto contract. -// The proto path is resolved relative to the Python source dir (code/), where -// the scaffolded proto/ tree and the generated stubs both live. It inherits the -// same public/private visibility as the REST endpoint. +// The proto path is resolved relative to the service root — the same location +// core's LoadEndpoints re-reads it from — so the endpoint keeps its RPCs across +// reloads and stays consumable by dependent services. It inherits the same +// public/private visibility as the REST endpoint. func (s *Builder) grpcEndpoint(ctx context.Context) (*basev0.Endpoint, error) { - protoPath := s.Local("code/%s", s.FastAPI.Settings.GRPCServer.Proto) + protoPath := s.Local("%s", s.FastAPI.Settings.GRPCServer.Proto) grpc, err := resources.LoadGrpcAPI(ctx, shared.Pointer(protoPath)) if err != nil { return nil, s.Wool.Wrapf(err, "cannot load grpc proto %q", protoPath) diff --git a/grpc_test.go b/grpc_test.go index efbfb21..3aa92ab 100644 --- a/grpc_test.go +++ b/grpc_test.go @@ -6,6 +6,7 @@ import ( "os" "path" "path/filepath" + "strings" "testing" "time" @@ -91,7 +92,7 @@ func TestCreateRESTOnlyByDefault(t *testing.T) { require.Equal(t, 1, len(builder.Endpoints)) require.Nil(t, builder.FastAPI.GRPCEndpoint) - _, err := os.Stat(filepath.Join(root, "code/proto/api.proto")) + _, err := os.Stat(filepath.Join(root, "proto/api.proto")) require.True(t, os.IsNotExist(err)) _, err = os.Stat(filepath.Join(root, "code/src/rpc/server.py")) require.True(t, os.IsNotExist(err)) @@ -123,8 +124,8 @@ func TestCreateWithGRPCServer(t *testing.T) { require.Equal(t, standards.GRPC, builder.FastAPI.GRPCEndpoint.Api) for _, rel := range []string{ - "code/proto/api.proto", - "code/proto/buf.gen.yaml", + "proto/api.proto", + "proto/buf.gen.yaml", "code/src/rpc/server.py", "code/src/rpc/servicer.py", "code/tests/rpc/test_grpc.py", @@ -139,10 +140,54 @@ func TestCreateWithGRPCServer(t *testing.T) { require.Contains(t, string(pyproject), "grpcio") require.Contains(t, string(pyproject), "grpcio-tools") - main, err := os.ReadFile(filepath.Join(root, "code/src/main.py")) + assertMainImportsGRPCLazily(t, filepath.Join(root, "code/src/main.py")) +} + +// assertMainImportsGRPCLazily guards the fix for the eager-import bug: importing +// src.main (which openapi.py does at Init, before Sync generates the stubs) must +// not require the generated gRPC package. The server import therefore lives +// inside the startup handler (indented), never at module top level. +func assertMainImportsGRPCLazily(t *testing.T, mainPath string) { + t.Helper() + content, err := os.ReadFile(mainPath) + require.NoError(t, err) + main := string(content) + require.Contains(t, main, "CODEFLY_GRPC_PORT") + require.Contains(t, main, "from src.rpc.server import serve") + for _, line := range strings.Split(main, "\n") { + if strings.HasPrefix(line, "from src.rpc.server import") || strings.HasPrefix(line, "import src.rpc") { + t.Fatalf("src.rpc must not be imported at module top level (breaks openapi generation before sync): %q", line) + } + } +} + +// TestGRPCEndpointReloadsContract is the regression guard for the proto +// location: core's LoadEndpoints re-derives the gRPC contract from disk at +// standards.ProtoPath (service root). If the scaffolded proto lived anywhere +// else, the reloaded endpoint would silently carry zero RPCs and dependent +// services could not generate clients. +func TestGRPCEndpointReloadsContract(t *testing.T) { + builder, _ := createServiceForTest(t, map[string]*agentv0.Answer{ + HotReload: confirmAnswer(false), + PublicEndpoint: confirmAnswer(false), + GRPCServer: confirmAnswer(true), + }) + ctx := context.Background() + + endpoints, err := builder.Base.Service.LoadEndpoints(ctx) require.NoError(t, err) - require.Contains(t, string(main), "CODEFLY_GRPC_PORT") - require.Contains(t, string(main), "from src.rpc.server import serve") + + grpcEndpoint, err := resources.FindGRPCEndpoint(ctx, endpoints) + require.NoError(t, err) + grpc := resources.IsGRPC(ctx, grpcEndpoint) + require.NotNil(t, grpc) + require.NotEmpty(t, grpc.Rpcs, "reloaded gRPC endpoint lost its RPCs — proto not at core-standard path") + + var names []string + for _, rpc := range grpc.Rpcs { + names = append(names, rpc.Name) + } + require.Contains(t, names, "Echo") } // TestDeploymentRendersGRPCPort proves the gRPC container/service ports appear diff --git a/main.go b/main.go index 58b0640..a885442 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( runnersbase "github.com/codefly-dev/core/runners/base" pythonrunner "github.com/codefly-dev/core/runners/python" "github.com/codefly-dev/core/shared" + "github.com/codefly-dev/core/standards" "github.com/codefly-dev/core/templates" "github.com/codefly-dev/core/toolbox/lang" @@ -43,9 +44,11 @@ const ( ) // defaultProtoPath is where the service-owned proto contract lives, relative -// to the Python source dir (code/). Mirrors how openapi/api.swagger.json sits -// beside the source. Buf generation and the gRPC endpoint both resolve it. -const defaultProtoPath = "proto/api.proto" +// to the service root. It is core's standards.ProtoPath: LoadEndpoints +// (Builder + Runtime) and any dependent service re-derive the gRPC contract +// from that exact location, so the proto has to sit there — the same way the +// REST contract lives at openapi/api.swagger.json. +const defaultProtoPath = standards.ProtoPath // GRPCServerSettings configures the optional service-owned grpc.aio listener. // Disabled by default: an unset grpc-server block leaves the service REST-only diff --git a/templates/factory/code/src/main.py.tmpl b/templates/factory/code/src/main.py.tmpl index 64ea12e..470af7c 100644 --- a/templates/factory/code/src/main.py.tmpl +++ b/templates/factory/code/src/main.py.tmpl @@ -56,14 +56,17 @@ for plugin in plugins: # gRPC server — runs a grpc.aio listener in this process alongside FastAPI. # The port is provided by codefly at runtime; it falls back to the standard # gRPC port in container/Kubernetes where the mapping is fixed. -from src.rpc.server import serve as _grpc_serve - _grpc_server = None @app.on_event("startup") async def _start_grpc(): global _grpc_server + # Imported inside the handler, not at module load: the generated stubs + # are produced by `codefly sync` and importing this module must not depend + # on them (openapi.py imports src.main to emit the schema before sync). + from src.rpc.server import serve as _grpc_serve + port = int(os.environ.get("CODEFLY_GRPC_PORT", "9090")) _grpc_server = await _grpc_serve(port) diff --git a/templates/grpc/code/src/rpc/server.py.tmpl b/templates/grpc/code/src/rpc/server.py.tmpl index 7d62903..736a30c 100644 --- a/templates/grpc/code/src/rpc/server.py.tmpl +++ b/templates/grpc/code/src/rpc/server.py.tmpl @@ -34,6 +34,10 @@ async def serve(port: int) -> grpc.aio.Server: """Start a grpc.aio server on ``port`` and return it (already started).""" server = grpc.aio.server() register(server) - server.add_insecure_port(f"[::]:{port}") + # add_insecure_port returns 0 (it does not raise) when the port can't be + # bound; without this check the server would "start" listening on nothing + # while FastAPI stays healthy, masking the failure. + if server.add_insecure_port(f"[::]:{port}") == 0: + raise RuntimeError(f"gRPC server could not bind port {port}") await server.start() return server diff --git a/templates/grpc/code/tests/rpc/conftest.py.tmpl b/templates/grpc/code/tests/rpc/conftest.py.tmpl index 7023d4a..35e366a 100644 --- a/templates/grpc/code/tests/rpc/conftest.py.tmpl +++ b/templates/grpc/code/tests/rpc/conftest.py.tmpl @@ -5,7 +5,7 @@ import sys # The gRPC stubs are produced by `codefly sync` (Buf) before a normal test run. # Generate them here too so the suite is self-contained when run directly. _CODE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -_PROTO = os.path.join(_CODE, "proto") +_PROTO = os.path.abspath(os.path.join(_CODE, "..", "proto")) _GENERATED = os.path.join(_CODE, "src", "rpc", "_generated") diff --git a/templates/grpc/code/proto/api.proto.tmpl b/templates/grpc/proto/api.proto.tmpl similarity index 100% rename from templates/grpc/code/proto/api.proto.tmpl rename to templates/grpc/proto/api.proto.tmpl diff --git a/templates/grpc/code/proto/buf.gen.yaml.tmpl b/templates/grpc/proto/buf.gen.yaml.tmpl similarity index 58% rename from templates/grpc/code/proto/buf.gen.yaml.tmpl rename to templates/grpc/proto/buf.gen.yaml.tmpl index da510bf..e57c495 100644 --- a/templates/grpc/code/proto/buf.gen.yaml.tmpl +++ b/templates/grpc/proto/buf.gen.yaml.tmpl @@ -1,6 +1,6 @@ version: v1 plugins: - plugin: buf.build/protocolbuffers/python - out: ../src/rpc/_generated + out: ../code/src/rpc/_generated - plugin: buf.build/grpc/python - out: ../src/rpc/_generated + out: ../code/src/rpc/_generated diff --git a/templates/grpc/code/proto/buf.yaml.tmpl b/templates/grpc/proto/buf.yaml.tmpl similarity index 100% rename from templates/grpc/code/proto/buf.yaml.tmpl rename to templates/grpc/proto/buf.yaml.tmpl