Skip to content

Commit 30d94eb

Browse files
Merge pull request #108 from codefly-dev/authority/core-bindings
feat: Connect face beside gRPC, and core bindings
2 parents 8bb8f58 + c4f9d5a commit 30d94eb

13 files changed

Lines changed: 1306 additions & 17 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
This repo is one codefly agent binary: the **FastAPI specialization** of the
44
generic Python agent. It composes `github.com/codefly-dev/service-python`'s
55
`pkg/*` types and adds what FastAPI needs — uvicorn supervision and hot reload,
6-
OpenAPI-derived REST endpoints, an opt-in `grpc.aio` listener, the Dockerfile
7-
and kustomize recipes, and the scaffold a new FastAPI service is created from.
6+
OpenAPI-derived REST endpoints, an opt-in `grpc.aio` listener and Connect face,
7+
the Dockerfile and kustomize recipes, and the scaffold a new FastAPI service is created from.
88
Dependency management is `uv`; poetry is not supported.
99

1010
It does **not** own:

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
publisher: codefly.dev
22
kind: codefly:service
33
name: python-fastapi
4-
version: 0.0.120
4+
version: 0.0.121
55
release:
66
owner: workflow
77
workflow: releaser.yml

‎builder.go‎

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,17 @@ func NewBuilder(svc *Service) *Builder {
6565
func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builderv0.LoadResponse, error) {
6666
defer s.Wool.Catch()
6767

68-
// Call generic first — it loads Settings, Endpoints, handles CreationMode.
69-
resp, err := s.Builder.Load(ctx, req)
70-
if err != nil {
68+
// The generic load with this agent's Settings: it loads them, the
69+
// Endpoints, and handles CreationMode. The generic Load would decode the
70+
// spec into its own half of the settings alone, leaving grpc-server and
71+
// connect-endpoint unset on every loaded service, so Sync generated no
72+
// server code and Deploy rendered no gRPC or Connect port. It reports a
73+
// failure in the response's state, not the error: stop there, or the
74+
// endpoint lookup below replaces the real cause with "no rest endpoint
75+
// found".
76+
resp, err := s.Base.Builder.LoadService(ctx, req, services.BuilderLoad{Settings: s.FastAPI.Settings})
77+
s.Service.SourceLocation = s.Service.ResolveSourceLocation()
78+
if err != nil || resp.GetState().GetState() == builderv0.LoadStatus_ERROR {
7179
return resp, err
7280
}
7381
requirements.Localize(s.Location)
@@ -91,6 +99,9 @@ func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builde
9199
if err != nil {
92100
return s.Base.Builder.LoadError(err)
93101
}
102+
if err := s.FastAPI.Settings.connectSettingError(); err != nil {
103+
return s.Base.Builder.LoadError(err)
104+
}
94105

95106
return s.Base.Builder.LoadResponse()
96107
}
@@ -139,9 +150,42 @@ func (s *Builder) Sync(ctx context.Context, _ *builderv0.SyncRequest) (*builderv
139150
return s.Base.Builder.SyncError(err)
140151
}
141152
}
153+
if s.FastAPI.Settings.ConnectEndpoint {
154+
if err := s.writeConnectFace(); err != nil {
155+
return s.Base.Builder.SyncError(err)
156+
}
157+
}
142158
return s.Base.Builder.SyncResponse()
143159
}
144160

161+
// connectFacePath is where the agent writes the Connect face, relative to the
162+
// service root: beside the generated gRPC bindings, importable as
163+
// src.rpc.connect_gen.
164+
const connectFacePath = "code/src/rpc/connect_gen.py"
165+
166+
// connectFace is the Connect face every service with connect-endpoint gets,
167+
// byte for byte: it reads the methods it serves from the descriptors the
168+
// service's gRPC bindings register, so nothing in it is rendered per service.
169+
//
170+
//go:embed templates/connect/connect_gen.py
171+
var connectFace []byte
172+
173+
// writeConnectFace writes the Connect face, replacing whatever is there: the
174+
// file is the agent's, and a hand edit is drift the next sync removes.
175+
func (s *Builder) writeConnectFace() error {
176+
if err := s.FastAPI.Settings.connectSettingError(); err != nil {
177+
return err
178+
}
179+
target := s.Local(connectFacePath)
180+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
181+
return s.Wool.Wrapf(err, "cannot create %s", filepath.Dir(target))
182+
}
183+
if err := os.WriteFile(target, connectFace, 0o644); err != nil {
184+
return s.Wool.Wrapf(err, "cannot write the Connect face")
185+
}
186+
return nil
187+
}
188+
145189
// syncGRPCServer regenerates the Python protobuf + grpc.aio server stubs from
146190
// the service-owned proto contract via Buf. Buf reads proto/ under the service
147191
// root (matching the endpoint contract location) and writes the stubs into the
@@ -429,6 +473,11 @@ type Parameters struct {
429473
// binds (the app defaults to it when CODEFLY_GRPC_PORT is unset).
430474
GRPCEnabled bool
431475
GRPCPort int
476+
// ConnectEnabled adds the Connect face's containerPort and Service port.
477+
// ConnectPort is the fixed in-cluster port it binds (the app defaults to it
478+
// when CODEFLY_CONNECT_PORT is unset).
479+
ConnectEnabled bool
480+
ConnectPort int
432481
// AutomountServiceAccountToken projects the ServiceAccount token into the
433482
// pod. It stays false because the identity path is the metadata server or a
434483
// webhook-projected token and the app does not call the API server. Setting
@@ -509,9 +558,11 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest)
509558
DependencyConfigurations: true,
510559
},
511560
Parameters: Parameters{
512-
Probes: probes,
513-
GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled,
514-
GRPCPort: int(standards.Port(standards.GRPC)),
561+
Probes: probes,
562+
GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled,
563+
GRPCPort: int(standards.Port(standards.GRPC)),
564+
ConnectEnabled: s.FastAPI.Settings.ConnectEndpoint,
565+
ConnectPort: int(standards.Port(standards.CONNECT)),
515566
},
516567
})
517568
}

‎connect_test.go‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
10+
agenttesting "github.com/codefly-dev/core/agents/testing"
11+
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
12+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
13+
"github.com/stretchr/testify/require"
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
// TestSettingsParseConnectEndpoint proves the setting reads at go-grpc's
18+
// spelling, defaults to off, and is refused without a gRPC server to transcode
19+
// onto.
20+
func TestSettingsParseConnectEndpoint(t *testing.T) {
21+
var on Settings
22+
require.NoError(t, yaml.Unmarshal([]byte(`
23+
grpc-server:
24+
enabled: true
25+
connect-endpoint: true
26+
`), &on))
27+
require.True(t, on.ConnectEndpoint)
28+
require.NoError(t, on.connectSettingError())
29+
30+
var alone Settings
31+
require.NoError(t, yaml.Unmarshal([]byte(`connect-endpoint: true`), &alone))
32+
require.ErrorContains(t, alone.connectSettingError(), "grpc-server.enabled")
33+
34+
var absent Settings
35+
require.NoError(t, yaml.Unmarshal([]byte(`python-version: "3.12"`), &absent))
36+
require.False(t, absent.ConnectEndpoint)
37+
}
38+
39+
// TestSyncWritesTheConnectFace proves the face lands where the app imports it,
40+
// byte for byte, replacing a hand edit.
41+
func TestSyncWritesTheConnectFace(t *testing.T) {
42+
builder := NewBuilder(NewService())
43+
builder.Location = t.TempDir()
44+
builder.FastAPI.Settings.GRPCServer.Enabled = true
45+
builder.FastAPI.Settings.ConnectEndpoint = true
46+
47+
target := filepath.Join(builder.Location, connectFacePath)
48+
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
49+
require.NoError(t, os.WriteFile(target, []byte("# edited by hand\n"), 0o644))
50+
51+
require.NoError(t, builder.writeConnectFace())
52+
written, err := os.ReadFile(target)
53+
require.NoError(t, err)
54+
require.Equal(t, connectFace, written)
55+
require.Contains(t, string(written), "Do not edit this file!")
56+
57+
builder.FastAPI.Settings.GRPCServer.Enabled = false
58+
require.Error(t, builder.writeConnectFace())
59+
}
60+
61+
// TestDeploymentRendersConnectPort proves the Connect container and Service
62+
// ports appear only when the service opts in, declared as HTTP/1.1.
63+
func TestDeploymentRendersConnectPort(t *testing.T) {
64+
enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090, ConnectEnabled: true, ConnectPort: 8081}))
65+
svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml"))
66+
require.NoError(t, err)
67+
require.Contains(t, string(svc), "name: connect-port")
68+
require.Contains(t, string(svc), "appProtocol: http\n")
69+
require.Contains(t, string(svc), "port: 8081")
70+
deployment, err := os.ReadFile(filepath.Join(enabled, "base", "deployment.yaml"))
71+
require.NoError(t, err)
72+
require.Contains(t, string(deployment), "containerPort: 8081")
73+
74+
disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090}))
75+
svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml"))
76+
require.NoError(t, err)
77+
require.NotContains(t, string(svc), "connect-port")
78+
}
79+
80+
// TestConnectFaceServesEveryProtocol runs the face's own suite
81+
// (testdata/connect) against a real grpc.aio server: Connect unary and server
82+
// streams in both codecs, gRPC-Web, gRPC relayed on the same port, metadata
83+
// both ways, the service's interceptors on every protocol, deadlines and the
84+
// HTTP refusals. The Python pins are the ones a service's lock resolves today.
85+
func TestConnectFaceServesEveryProtocol(t *testing.T) {
86+
if _, err := exec.LookPath("uv"); err != nil {
87+
t.Skip("uv is not installed: the Connect face's Python suite cannot run")
88+
}
89+
cmd := exec.Command("uv", "run", "--no-project", "--python", "3.12",
90+
"--with", "grpcio==1.84.0", "--with", "protobuf==6.33.6", "--with", "h11==0.16.0",
91+
"--with", "httpx==0.28.1", "--with", "pytest==8.4.2", "--with", "pytest-asyncio==1.2.0",
92+
"pytest", "-q", "-p", "no:cacheprovider",
93+
"-o", "asyncio_mode=auto", "-o", "asyncio_default_fixture_loop_scope=function",
94+
filepath.Join("testdata", "connect"))
95+
cmd.Env = append(os.Environ(), "PYTHONPATH="+filepath.Join("templates", "connect"), "PYTHONDONTWRITEBYTECODE=1")
96+
out, err := cmd.CombinedOutput()
97+
require.NoError(t, err, "%s", out)
98+
}
99+
100+
// TestBuilderLoadReadsTheAgentsSettings is the regression guard for a loaded
101+
// (not created) service: the builder must see grpc-server and
102+
// connect-endpoint, or Sync writes neither the gRPC stubs nor the Connect face
103+
// and Deploy renders neither port.
104+
func TestBuilderLoadReadsTheAgentsSettings(t *testing.T) {
105+
created, _ := createServiceForTest(t, map[string]*agentv0.Answer{
106+
HotReload: confirmAnswer(false),
107+
PublicEndpoint: confirmAnswer(false),
108+
GRPCServer: confirmAnswer(true),
109+
})
110+
ctx := context.Background()
111+
manifest := filepath.Join(created.Location, "service.codefly.yaml")
112+
content, err := os.ReadFile(manifest)
113+
require.NoError(t, err)
114+
var document map[string]any
115+
require.NoError(t, yaml.Unmarshal(content, &document))
116+
spec, _ := document["spec"].(map[string]any)
117+
require.Equal(t, true, spec["grpc-server"].(map[string]any)["enabled"])
118+
spec["connect-endpoint"] = true
119+
content, err = yaml.Marshal(document)
120+
require.NoError(t, err)
121+
require.NoError(t, os.WriteFile(manifest, content, 0o644))
122+
123+
identity, err := created.Identity.Proto()
124+
require.NoError(t, err)
125+
loaded := NewBuilder(NewService())
126+
resp, err := loaded.Load(ctx, &builderv0.LoadRequest{Identity: identity})
127+
require.NoError(t, err)
128+
require.NotEqual(t, builderv0.LoadStatus_ERROR, resp.GetState().GetState(), resp.GetState().GetMessage())
129+
require.True(t, loaded.FastAPI.Settings.GRPCServer.Enabled)
130+
require.True(t, loaded.FastAPI.Settings.ConnectEndpoint)
131+
require.Equal(t, filepath.Join(created.Location, "code"), loaded.Service.SourceLocation)
132+
}

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/codefly-dev/service-python-fastapi
33
go 1.27.3
44

55
require (
6-
github.com/codefly-dev/core v0.19.6
6+
github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55
77
github.com/codefly-dev/service-python v0.0.66
88
github.com/pelletier/go-toml/v2 v2.4.3
99
github.com/stretchr/testify v1.12.1

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
4343
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
4444
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
4545
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
46-
github.com/codefly-dev/core v0.19.6 h1:ZkCChK/BioK+B7zZ0fp+/rXDCy0k+lsdFqHjyc5xFIg=
47-
github.com/codefly-dev/core v0.19.6/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A=
46+
github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55 h1:U9rzO6zSCigIUHuiiUTKMEtNzyYZ3e6il3A+sEv8jJk=
47+
github.com/codefly-dev/core v0.19.7-0.20261025070021-a3bf508a5e55/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A=
4848
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
4949
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
5050
github.com/codefly-dev/service-python v0.0.66 h1:Y74QvKAYMaOnZcsfRxuD9dpQo3//7Mjo12RYELyrp5o=

‎load_error_test.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package main
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
8+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// The generic Load reports its failure in the response's state. The fastapi
13+
// Load must hand that failure back as it is, never go on to look for a REST
14+
// endpoint in a service it could not load and report that one is missing.
15+
func TestBuilderLoadSurfacesTheGenericLoadFailure(t *testing.T) {
16+
for name, tc := range map[string]struct {
17+
request *builderv0.LoadRequest
18+
cause string
19+
}{
20+
"no identity": {request: &builderv0.LoadRequest{}, cause: "requires a service identity"},
21+
"no service file": {request: &builderv0.LoadRequest{Identity: &basev0.ServiceIdentity{
22+
Name: "absent", Version: "0.0.0", Module: "mod", Workspace: "test",
23+
WorkspacePath: t.TempDir(), RelativeToWorkspace: filepath.Join("mod", "absent"),
24+
}}, cause: "absent"},
25+
} {
26+
t.Run(name, func(t *testing.T) {
27+
resp, err := NewBuilder(NewService()).Load(t.Context(), tc.request)
28+
require.NoError(t, err)
29+
require.Equal(t, builderv0.LoadStatus_ERROR, resp.GetState().GetState())
30+
require.Contains(t, resp.GetState().GetMessage(), tc.cause)
31+
require.NotContains(t, resp.GetState().GetMessage(), "rest endpoint")
32+
})
33+
}
34+
}

‎main.go‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package main
88
import (
99
"context"
1010
"embed"
11+
"fmt"
1112

1213
"github.com/codefly-dev/core/agents"
1314
"github.com/codefly-dev/core/agents/contract"
@@ -42,6 +43,9 @@ const (
4243
HotReload = "hot-reload"
4344
PublicEndpoint = "public-endpoint"
4445
GRPCServer = "grpc-server"
46+
// ConnectEndpointSetting is go-grpc's spelling for the same face: a
47+
// listener serving Connect, gRPC-Web and gRPC over the service's gRPC server.
48+
ConnectEndpointSetting = "connect-endpoint"
4549
)
4650

4751
// defaultProtoPath is where the service-owned proto contract lives, relative
@@ -78,6 +82,15 @@ type Settings struct {
7882
// process as the FastAPI app (see grpc-server:). Disabled by default.
7983
GRPCServer GRPCServerSettings `yaml:"grpc-server"`
8084

85+
// ConnectEndpoint serves the service's gRPC API on a second listener that
86+
// speaks Connect and gRPC-Web (HTTP/1.1) and gRPC (HTTP/2) on one port:
87+
// the face a browser reaches through an L7 hop. Every call is transcoded
88+
// onto the grpc.aio server, so it needs grpc-server and adds no API of its
89+
// own. The agent writes the face (code/src/rpc/connect_gen.py) at sync and
90+
// hands the process its port as CODEFLY_CONNECT_PORT; the app's lifespan
91+
// starts it beside the gRPC server. Off by default.
92+
ConnectEndpoint bool `yaml:"connect-endpoint"`
93+
8194
// RuntimeImage overrides the default codefly-built runtime image.
8295
// Format: "name:tag". Plain "name" and ":latest" are rejected —
8396
// pinning is enforced. Leave empty to use ghcr.io/codefly-dev/python:<ver>
@@ -155,6 +168,19 @@ type Service struct {
155168
// GRPCEndpoint is the service-owned gRPC endpoint, present only when
156169
// Settings.GRPCServer.Enabled. Nil keeps the REST-only path untouched.
157170
GRPCEndpoint *v0.Endpoint
171+
172+
// ConnectEndpoint is the Connect-family endpoint, present only when
173+
// Settings.ConnectEndpoint.
174+
ConnectEndpoint *v0.Endpoint
175+
}
176+
177+
// connectSettingError refuses a Connect face with no gRPC server to transcode
178+
// onto: the face serves the gRPC server's API, never one of its own.
179+
func (s *Settings) connectSettingError() error {
180+
if s.ConnectEndpoint && !s.GRPCServer.Enabled {
181+
return fmt.Errorf("%s needs %s.enabled: the Connect face serves the gRPC server's API", ConnectEndpointSetting, GRPCServer)
182+
}
183+
return nil
158184
}
159185

160186
// GetAgentInformation overrides the generic info to advertise HTTP protocol
@@ -178,8 +204,8 @@ func (s *Service) GetAgentInformation(ctx context.Context, _ *agentv0.AgentInfor
178204
HotReload: true,
179205
Languages: []agentv0.Language_Type{agentv0.Language_PYTHON},
180206
// The agent can serve HTTP always and gRPC when a service opts in via
181-
// grpc-server; advertising both declares the capability, not that every
182-
// service exposes both.
207+
// grpc-server (and Connect beside it, over HTTP); advertising both
208+
// declares the capability, not that every service exposes both.
183209
Protocols: []agentv0.Protocol_Type{agentv0.Protocol_HTTP, agentv0.Protocol_GRPC},
184210
ReadMe: readme,
185211
}.Build()

0 commit comments

Comments
 (0)