Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion agent.codefly.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
63 changes: 57 additions & 6 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
},
})
}
Expand Down
132 changes: 132 additions & 0 deletions connect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
34 changes: 34 additions & 0 deletions load_error_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
30 changes: 28 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package main
import (
"context"
"embed"
"fmt"

"github.com/codefly-dev/core/agents"
"github.com/codefly-dev/core/agents/contract"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<ver>
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
Loading
Loading