Skip to content

Commit 2ff5d38

Browse files
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 <noreply@anthropic.com>
1 parent 1dfd267 commit 2ff5d38

18 files changed

Lines changed: 529 additions & 6 deletions

‎builder.go‎

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import (
3838
// Inherited: Init.
3939
// Overridden: Load (fastapi puts source under ./code, discovers REST
4040
// endpoint), Update (applies builder templates), Sync (gRPC codegen for
41-
// declared dependencies), Build (custom DockerTemplating + docker build),
41+
// declared dependencies and the optional service-owned gRPC server), Build
42+
// (custom DockerTemplating + docker build),
4243
// Deploy (k8s), Create (two-question Communicate + REST endpoint).
4344
type Builder struct {
4445
*pythonbuilder.Builder
@@ -129,9 +130,30 @@ func (s *Builder) Sync(ctx context.Context, _ *builderv0.SyncRequest) (*builderv
129130
return s.Base.Builder.SyncError(err)
130131
}
131132
}
133+
134+
if s.FastAPI.Settings.GRPCServer.Enabled {
135+
if err := s.syncGRPCServer(ctx); err != nil {
136+
return s.Base.Builder.SyncError(err)
137+
}
138+
}
132139
return s.Base.Builder.SyncResponse()
133140
}
134141

142+
// syncGRPCServer regenerates the Python protobuf + grpc.aio server stubs from
143+
// the service-owned proto contract via Buf. Generation is cached on the proto
144+
// tree, so it re-runs deterministically only when the contract changes.
145+
func (s *Builder) syncGRPCServer(ctx context.Context) error {
146+
buf, err := proto.NewBuf(ctx, s.Local("code"))
147+
if err != nil {
148+
return s.Wool.Wrapf(err, "cannot create proto generator")
149+
}
150+
buf.WithGeneratedDirs(s.Local("code/src/rpc/_generated"))
151+
if err := buf.Generate(ctx); err != nil {
152+
return s.Wool.Wrapf(err, "cannot generate grpc server code")
153+
}
154+
return nil
155+
}
156+
135157
// Env + DockerTemplating structs are the template context for the
136158
// builder Dockerfile.
137159
type Env struct {
@@ -247,7 +269,13 @@ func (s *Builder) Upgrade(ctx context.Context, req *builderv0.UpgradeRequest) (*
247269
}
248270

249271
// Parameters is the template parameter set for the k8s deployment.
250-
type Parameters struct{}
272+
type Parameters struct {
273+
// GRPCEnabled adds the gRPC containerPort and Service port to the rendered
274+
// manifests. GRPCPort is the fixed in-cluster port the grpc.aio listener
275+
// binds (the app defaults to it when CODEFLY_GRPC_PORT is unset).
276+
GRPCEnabled bool
277+
GRPCPort int
278+
}
251279

252280
// Deploy renders and applies k8s manifests.
253281
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)
260288
OwnConfiguration: true,
261289
DependencyConfigurations: true,
262290
},
263-
Parameters: Parameters{},
291+
Parameters: Parameters{
292+
GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled,
293+
GRPCPort: int(standards.Port(standards.GRPC)),
294+
},
264295
})
265296
}
266297

@@ -269,6 +300,7 @@ func (s *Builder) Options() []*agentv0.Question {
269300
return []*agentv0.Question{
270301
communicate.NewConfirm(&agentv0.Message{Name: PublicEndpoint, Message: "Expose API as public", Description: "is that directly accessible from the internet?"}, true),
271302
communicate.NewConfirm(&agentv0.Message{Name: HotReload, Message: "Code hot-reload (Recommended)?", Description: "codefly can restart your service when code changes are detected 🔎"}, true),
303+
communicate.NewConfirm(&agentv0.Message{Name: GRPCServer, Message: "Add a gRPC server?", Description: "runs a grpc.aio listener alongside FastAPI with a proto contract ⚙️"}, false),
272304
}
273305
}
274306

@@ -277,6 +309,10 @@ type CreateConfiguration struct {
277309
*services.Information
278310
Image *resources.DockerImage
279311
Envs []string
312+
313+
// GRPCEnabled gates the gRPC boot in src/main.py and the grpcio
314+
// dependencies in pyproject.toml. False keeps the REST-only scaffold.
315+
GRPCEnabled bool
280316
}
281317

282318
// Create applies factory templates, scaffolds src/tests dirs, and
@@ -295,11 +331,21 @@ func (s *Builder) Create(ctx context.Context, _ *builderv0.CreateRequest) (*buil
295331
}
296332
}
297333

298-
create := CreateConfiguration{Information: s.Information, Envs: []string{}}
334+
create := CreateConfiguration{Information: s.Information, Envs: []string{}, GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled}
299335
if err := s.Base.Templates(ctx, create, services.WithFactory(factoryFS)); err != nil {
300336
return s.Base.Builder.CreateError(err)
301337
}
302338

339+
// The gRPC scaffold (proto contract, buf config, grpc.aio server + user
340+
// servicer seam) is applied from a separate tree only when opted in, so a
341+
// REST-only service keeps its generated layout untouched.
342+
if s.FastAPI.Settings.GRPCServer.Enabled {
343+
grpc := services.WithTemplate(grpcFS, "grpc", "").WithOverride(shared.SkipAll())
344+
if err := s.Base.Templates(ctx, create, grpc); err != nil {
345+
return s.Base.Builder.CreateError(err)
346+
}
347+
}
348+
303349
// Scaffold package + tests dirs with empty __init__.py.
304350
if _, err := shared.CheckDirectoryOrCreate(ctx, s.Local("code/src")); err != nil {
305351
return s.Base.Builder.CreateError(err)
@@ -347,9 +393,39 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error {
347393
}
348394
s.FastAPI.RestEndpoint = api
349395
s.Endpoints = []*basev0.Endpoint{s.FastAPI.RestEndpoint}
396+
397+
if s.FastAPI.Settings.GRPCServer.Enabled {
398+
grpcEndpoint, grpcErr := s.grpcEndpoint(ctx)
399+
if grpcErr != nil {
400+
return grpcErr
401+
}
402+
s.FastAPI.GRPCEndpoint = grpcEndpoint
403+
s.Endpoints = append(s.Endpoints, grpcEndpoint)
404+
}
350405
return nil
351406
}
352407

408+
// grpcEndpoint builds the service-owned gRPC endpoint from the proto contract.
409+
// The proto path is resolved relative to the Python source dir (code/), where
410+
// the scaffolded proto/ tree and the generated stubs both live. It inherits the
411+
// same public/private visibility as the REST endpoint.
412+
func (s *Builder) grpcEndpoint(ctx context.Context) (*basev0.Endpoint, error) {
413+
protoPath := s.Local("code/%s", s.FastAPI.Settings.GRPCServer.Proto)
414+
grpc, err := resources.LoadGrpcAPI(ctx, shared.Pointer(protoPath))
415+
if err != nil {
416+
return nil, s.Wool.Wrapf(err, "cannot load grpc proto %q", protoPath)
417+
}
418+
endpoint := s.Base.BaseEndpoint(standards.GRPC)
419+
if s.FastAPI.Settings.PublicEndpoint {
420+
endpoint.Visibility = resources.VisibilityPublic
421+
}
422+
api, err := resources.NewAPI(ctx, endpoint, resources.ToGrpcAPI(grpc))
423+
if err != nil {
424+
return nil, s.Wool.Wrapf(err, "cannot create grpc api")
425+
}
426+
return api, nil
427+
}
428+
353429
// isFileNotExistErr matches the bespoke "file does not exist" error string
354430
// that resources.LoadRestAPI returns for missing files — it doesn't wrap
355431
// os.ErrNotExist, so errors.Is can't catch it directly.
@@ -365,9 +441,21 @@ func (s *Builder) populateSettingsFromAnswers() error {
365441
if s.FastAPI.Settings.PublicEndpoint, err = communicate.Confirm(s.answers, PublicEndpoint); err != nil {
366442
return err
367443
}
444+
if s.FastAPI.Settings.GRPCServer.Enabled, err = communicate.Confirm(s.answers, GRPCServer); err != nil {
445+
return err
446+
}
447+
s.applyGRPCDefaults()
368448
return nil
369449
}
370450

451+
// applyGRPCDefaults fills the proto path when the server is enabled but the
452+
// path was left blank (interactive answers and defaults never set it).
453+
func (s *Builder) applyGRPCDefaults() {
454+
if s.FastAPI.Settings.GRPCServer.Enabled && s.FastAPI.Settings.GRPCServer.Proto == "" {
455+
s.FastAPI.Settings.GRPCServer.Proto = defaultProtoPath
456+
}
457+
}
458+
371459
func (s *Builder) populateSettingsFromDefaults() error {
372460
opts := s.Options()
373461
var err error
@@ -377,6 +465,10 @@ func (s *Builder) populateSettingsFromDefaults() error {
377465
if s.FastAPI.Settings.PublicEndpoint, err = communicate.GetDefaultConfirm(opts, PublicEndpoint); err != nil {
378466
return err
379467
}
468+
if s.FastAPI.Settings.GRPCServer.Enabled, err = communicate.GetDefaultConfirm(opts, GRPCServer); err != nil {
469+
return err
470+
}
471+
s.applyGRPCDefaults()
380472
return nil
381473
}
382474

@@ -400,6 +492,12 @@ func renderFromFactory(ctx context.Context, info *services.Information) (string,
400492
//go:embed templates/factory
401493
var factoryFS embed.FS
402494

495+
// all: so the scaffold's dotfiles (code/.gitignore) are embedded — go:embed
496+
// skips names beginning with "." without it.
497+
//
498+
//go:embed all:templates/grpc
499+
var grpcFS embed.FS
500+
403501
//go:embed templates/builder
404502
var builderFS embed.FS
405503

‎grpc_test.go‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path"
8+
"path/filepath"
9+
"testing"
10+
"time"
11+
12+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
13+
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
14+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
15+
"github.com/codefly-dev/core/resources"
16+
"github.com/codefly-dev/core/shared"
17+
"github.com/codefly-dev/core/standards"
18+
"github.com/stretchr/testify/require"
19+
"gopkg.in/yaml.v3"
20+
21+
agenttesting "github.com/codefly-dev/core/agents/testing"
22+
)
23+
24+
// TestSettingsParseGRPCServer proves the nested grpc-server block round-trips
25+
// through the flat inline-embedded Settings and defaults to disabled.
26+
func TestSettingsParseGRPCServer(t *testing.T) {
27+
var enabled Settings
28+
require.NoError(t, yaml.Unmarshal([]byte(`
29+
python-version: "3.12"
30+
grpc-server:
31+
enabled: true
32+
proto: proto/api.proto
33+
`), &enabled))
34+
require.True(t, enabled.GRPCServer.Enabled)
35+
require.Equal(t, "proto/api.proto", enabled.GRPCServer.Proto)
36+
37+
var absent Settings
38+
require.NoError(t, yaml.Unmarshal([]byte(`python-version: "3.12"`), &absent))
39+
require.False(t, absent.GRPCServer.Enabled)
40+
}
41+
42+
func confirmAnswer(value bool) *agentv0.Answer {
43+
return &agentv0.Answer{Value: &agentv0.Answer_Confirm{Confirm: &agentv0.ConfirmAnswer{Confirmed: value}}}
44+
}
45+
46+
// createServiceForTest drives Load + Create in a temp workspace, mirroring the
47+
// setup in main_test but stopping before Runtime so it needs no docker/network.
48+
func createServiceForTest(t *testing.T, answers map[string]*agentv0.Answer) (*Builder, string) {
49+
t.Helper()
50+
ctx := context.Background()
51+
tmpDir, err := os.MkdirTemp("testdata", "grpc")
52+
require.NoError(t, err)
53+
tmpDir = shared.MustSolvePath(tmpDir)
54+
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
55+
56+
serviceName := fmt.Sprintf("svc-%v", time.Now().UnixMilli())
57+
service := resources.Service{Name: serviceName, Version: "0.0.0"}
58+
require.NoError(t, service.SaveAtDir(ctx, path.Join(tmpDir, fmt.Sprintf("mod/%s", service.Name))))
59+
60+
identity := &basev0.ServiceIdentity{
61+
Name: service.Name,
62+
Version: service.Version,
63+
Module: "mod",
64+
Workspace: "test",
65+
WorkspacePath: tmpDir,
66+
RelativeToWorkspace: fmt.Sprintf("mod/%s", service.Name),
67+
}
68+
69+
svc := NewService()
70+
builder := NewBuilder(svc)
71+
72+
communicate := answers != nil
73+
_, err = builder.Load(ctx, &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: communicate}})
74+
require.NoError(t, err)
75+
if communicate {
76+
builder.answers = answers
77+
}
78+
79+
_, err = builder.Create(ctx, &builderv0.CreateRequest{})
80+
require.NoError(t, err)
81+
return builder, path.Join(tmpDir, fmt.Sprintf("mod/%s", service.Name))
82+
}
83+
84+
// TestCreateRESTOnlyByDefault pins the untouched REST-only layout: one
85+
// endpoint, no proto tree, and no gRPC dependencies or boot code leaking into
86+
// the scaffold.
87+
func TestCreateRESTOnlyByDefault(t *testing.T) {
88+
builder, root := createServiceForTest(t, nil)
89+
90+
require.False(t, builder.FastAPI.Settings.GRPCServer.Enabled)
91+
require.Equal(t, 1, len(builder.Endpoints))
92+
require.Nil(t, builder.FastAPI.GRPCEndpoint)
93+
94+
_, err := os.Stat(filepath.Join(root, "code/proto/api.proto"))
95+
require.True(t, os.IsNotExist(err))
96+
_, err = os.Stat(filepath.Join(root, "code/src/rpc/server.py"))
97+
require.True(t, os.IsNotExist(err))
98+
99+
pyproject, err := os.ReadFile(filepath.Join(root, "code/pyproject.toml"))
100+
require.NoError(t, err)
101+
require.NotContains(t, string(pyproject), "grpcio")
102+
103+
main, err := os.ReadFile(filepath.Join(root, "code/src/main.py"))
104+
require.NoError(t, err)
105+
require.NotContains(t, string(main), "CODEFLY_GRPC_PORT")
106+
}
107+
108+
// TestCreateWithGRPCServer proves opting in scaffolds the proto contract, the
109+
// server/servicer seam, the dependencies and boot code, and advertises a
110+
// second gRPC endpoint alongside REST.
111+
func TestCreateWithGRPCServer(t *testing.T) {
112+
builder, root := createServiceForTest(t, map[string]*agentv0.Answer{
113+
HotReload: confirmAnswer(false),
114+
PublicEndpoint: confirmAnswer(false),
115+
GRPCServer: confirmAnswer(true),
116+
})
117+
118+
require.True(t, builder.FastAPI.Settings.GRPCServer.Enabled)
119+
require.Equal(t, defaultProtoPath, builder.FastAPI.Settings.GRPCServer.Proto)
120+
121+
require.Equal(t, 2, len(builder.Endpoints))
122+
require.NotNil(t, builder.FastAPI.GRPCEndpoint)
123+
require.Equal(t, standards.GRPC, builder.FastAPI.GRPCEndpoint.Api)
124+
125+
for _, rel := range []string{
126+
"code/proto/api.proto",
127+
"code/proto/buf.gen.yaml",
128+
"code/src/rpc/server.py",
129+
"code/src/rpc/servicer.py",
130+
"code/tests/rpc/test_grpc.py",
131+
"code/.gitignore",
132+
} {
133+
_, err := os.Stat(filepath.Join(root, rel))
134+
require.NoError(t, err, "expected scaffolded file %s", rel)
135+
}
136+
137+
pyproject, err := os.ReadFile(filepath.Join(root, "code/pyproject.toml"))
138+
require.NoError(t, err)
139+
require.Contains(t, string(pyproject), "grpcio")
140+
require.Contains(t, string(pyproject), "grpcio-tools")
141+
142+
main, err := os.ReadFile(filepath.Join(root, "code/src/main.py"))
143+
require.NoError(t, err)
144+
require.Contains(t, string(main), "CODEFLY_GRPC_PORT")
145+
require.Contains(t, string(main), "from src.rpc.server import serve")
146+
}
147+
148+
// TestDeploymentRendersGRPCPort proves the gRPC container/service ports appear
149+
// only when the deployment parameters opt in.
150+
func TestDeploymentRendersGRPCPort(t *testing.T) {
151+
enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{GRPCEnabled: true, GRPCPort: 9090})
152+
svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml"))
153+
require.NoError(t, err)
154+
require.Contains(t, string(svc), "grpc-port")
155+
require.Contains(t, string(svc), "9090")
156+
157+
deployment, err := os.ReadFile(filepath.Join(enabled, "base", "deployment.yaml"))
158+
require.NoError(t, err)
159+
require.Contains(t, string(deployment), "containerPort: 9090")
160+
161+
disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{})
162+
svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml"))
163+
require.NoError(t, err)
164+
require.NotContains(t, string(svc), "grpc-port")
165+
}

0 commit comments

Comments
 (0)