diff --git a/builder.go b/builder.go index aa8940c..e93686b 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,32 @@ 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. 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.Location) + 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 +271,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 +290,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 +302,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 +311,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 +333,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 +395,40 @@ 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 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("%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 +444,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 +468,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 +495,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..3aa92ab --- /dev/null +++ b/grpc_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + "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, "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{ + "proto/api.proto", + "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") + + 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) + + 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 +// 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..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" @@ -39,8 +40,27 @@ 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 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 +// 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 +73,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 +111,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 +137,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..470af7c 100644 --- a/templates/factory/code/src/main.py.tmpl +++ b/templates/factory/code/src/main.py.tmpl @@ -51,6 +51,31 @@ 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. +_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) + + +@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/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..736a30c --- /dev/null +++ b/templates/grpc/code/src/rpc/server.py.tmpl @@ -0,0 +1,43 @@ +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) + # 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/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..35e366a --- /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.abspath(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) diff --git a/templates/grpc/proto/api.proto.tmpl b/templates/grpc/proto/api.proto.tmpl new file mode 100644 index 0000000..787ed75 --- /dev/null +++ b/templates/grpc/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/proto/buf.gen.yaml.tmpl b/templates/grpc/proto/buf.gen.yaml.tmpl new file mode 100644 index 0000000..e57c495 --- /dev/null +++ b/templates/grpc/proto/buf.gen.yaml.tmpl @@ -0,0 +1,6 @@ +version: v1 +plugins: + - plugin: buf.build/protocolbuffers/python + out: ../code/src/rpc/_generated + - plugin: buf.build/grpc/python + out: ../code/src/rpc/_generated diff --git a/templates/grpc/proto/buf.yaml.tmpl b/templates/grpc/proto/buf.yaml.tmpl new file mode 100644 index 0000000..428cfb7 --- /dev/null +++ b/templates/grpc/proto/buf.yaml.tmpl @@ -0,0 +1,9 @@ +version: v1 +lint: + use: + - DEFAULT + ignore: + - PACKAGE_UNUSED +breaking: + use: + - FILE