Skip to content

Commit 4c35bc0

Browse files
fix: a service whose faces are gRPC and Connect alone loads, syncs and starts
The builder and the runtime refused every service without a REST endpoint ("builder load failed: no rest endpoint found"): both required one to map uvicorn's port from. Under the one-Connect-face decision a module declares its gRPC endpoint and the Connect face over it, and no REST surface. - The app's HTTP listener serves the service's REST endpoint, or a plain `api: http` one (probes, no API document), or none. Two are refused by name: the process has one HTTP listener. - With none, the app still runs under uvicorn, whose lifespan starts the gRPC server and Connect face, on a loopback port the system picks and no endpoint publishes. Start waits on the Connect face, else on a gRPC HTTP/2 handshake, which an engine's proxy cannot fake. - Deploy, which probes the HTTP listener, names the missing endpoint instead of reporting missing health. - Deleted: Create's swallow of a missing OpenAPI document (the factory templates write it first) and the watcher's dead api.json check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2c266fd commit 4c35bc0

10 files changed

Lines changed: 510 additions & 95 deletions

File tree

‎builder.go‎

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ import (
3737
// plus the services.Base chain via
3838
// *pythonservice.Service.
3939
// FastAPI *Service — fastapi-specific state: richer Settings
40-
// and the REST endpoint.
40+
// and the endpoint handles.
4141
//
4242
// Inherited: Init.
43-
// Overridden: Load (fastapi puts source under ./code, discovers REST
44-
// endpoint), Update (applies builder templates), Sync (gRPC codegen for
43+
// Overridden: Load (fastapi puts source under ./code, finds the HTTP
44+
// endpoint, if any), Update (applies builder templates), Sync (gRPC codegen for
4545
// declared dependencies and the optional service-owned gRPC server), Build
4646
// (Dockerfile and build plan preparation),
4747
// Deploy (k8s), Create (two-question Communicate + REST endpoint).
@@ -61,8 +61,9 @@ func NewBuilder(svc *Service) *Builder {
6161
}
6262
}
6363

64-
// Load overrides generic to place source under <service>/code and to
65-
// discover the REST endpoint.
64+
// Load overrides generic to place source under <service>/code and to find
65+
// the endpoint the app's HTTP listener serves, which a service whose faces are
66+
// gRPC and Connect alone does not declare.
6667
func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builderv0.LoadResponse, error) {
6768
defer s.Wool.Catch()
6869

@@ -71,9 +72,8 @@ func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builde
7172
// spec into its own half of the settings alone, leaving grpc-server and
7273
// connect-endpoint unset on every loaded service, so Sync generated no
7374
// server code and Deploy rendered no gRPC or Connect port. It reports a
74-
// failure in the response's state, not the error: stop there, or the
75-
// endpoint lookup below replaces the real cause with "no rest endpoint
76-
// found".
75+
// failure in the response's state, not the error: stop there, so the
76+
// response carries that cause.
7777
resp, err := s.Base.Builder.LoadService(ctx, req, services.BuilderLoad{Settings: s.FastAPI.Settings})
7878
s.Service.SourceLocation = s.Service.ResolveSourceLocation()
7979
if err != nil || resp.GetState().GetState() == builderv0.LoadStatus_ERROR {
@@ -96,7 +96,7 @@ func (s *Builder) Load(ctx context.Context, req *builderv0.LoadRequest) (*builde
9696
return s.Base.Builder.LoadResponse()
9797
}
9898

99-
s.FastAPI.RestEndpoint, err = resources.FindRestEndpoint(ctx, s.Endpoints)
99+
s.FastAPI.HTTPEndpoint, err = findHTTPEndpoint(ctx, s.Endpoints)
100100
if err != nil {
101101
return s.Base.Builder.LoadError(err)
102102
}
@@ -552,20 +552,27 @@ func preparePodOverlay(overlay *services.PodTemplateOverlay) error {
552552
func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) (*builderv0.DeploymentResponse, error) {
553553
defer s.Wool.Catch()
554554

555+
// The rendered pod is probed on the app's HTTP listener, the container's
556+
// port 8080, which a service with no HTTP endpoint does not publish: say so
557+
// rather than report the missing endpoint as missing health.
558+
listener := s.FastAPI.HTTPEndpoint
559+
if listener == nil {
560+
return nil, fmt.Errorf("the deployment probes the HTTP endpoint (api: rest or api: http), and the service declares none: declare the endpoint its probes answer on")
561+
}
555562
// The current Core manifest profile requires all three intents. Require
556563
// an authored startup/restart policy instead of inventing one from readiness.
557-
plan := resources.PlanEndpointProbes(s.FastAPI.RestEndpoint)
564+
plan := resources.PlanEndpointProbes(listener)
558565
if plan.Startup == nil || plan.Liveness == nil {
559566
return nil, fmt.Errorf("the Kubernetes manifest profile requires explicit endpoint health.startup and health.liveness; readiness is not a restart policy")
560567
}
561568
// A restricted render is what a cell runs, and a cell's execution
562569
// admission refuses an exec probe: probe natively there.
563-
probes, err := deploymentProbesFor(s.FastAPI.RestEndpoint, req.GetDeployment().GetKubernetes().GetProfile())
570+
probes, err := deploymentProbesFor(listener, req.GetDeployment().GetKubernetes().GetProfile())
564571
if err != nil {
565572
return nil, err
566573
}
567574
for _, endpoint := range s.Endpoints {
568-
if endpoint.GetName() != s.FastAPI.RestEndpoint.GetName() && endpoint.GetHealth() != nil {
575+
if endpoint.GetName() != listener.GetName() && endpoint.GetHealth() != nil {
569576
return nil, fmt.Errorf("declared health on additional endpoint %q requires a combined deployment probe; refusing to ignore it", endpoint.GetName())
570577
}
571578
}
@@ -665,12 +672,9 @@ func (s *Builder) Create(ctx context.Context, _ *builderv0.CreateRequest) (*buil
665672
return s.Base.Builder.CreateResponse(ctx, s.FastAPI.Settings)
666673
}
667674

668-
// CreateEndpoints materializes the REST endpoint.
669-
//
670-
// openapi/api.swagger.json is generated at Runtime time by src/openapi.py
671-
// (uv run python src/openapi.py). At Create time it doesn't exist yet —
672-
// that specific error is expected and swallowed with a debug log. Any
673-
// other LoadRestAPI failure (malformed JSON, permission, …) propagates.
675+
// CreateEndpoints materializes the REST endpoint from the OpenAPI document
676+
// the factory templates have just written (openapi/api.swagger.json); Init
677+
// regenerates it with src/openapi.py from then on.
674678
func (s *Builder) CreateEndpoints(ctx context.Context) error {
675679
openapiFile := s.Local("openapi/api.swagger.json")
676680
endpoint := s.Base.BaseEndpoint(standards.REST)
@@ -694,13 +698,9 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error {
694698
endpoint.Exposure = resources.ExposurePublic
695699
}
696700

697-
rest, loadErr := resources.LoadRestAPI(ctx, shared.Pointer(openapiFile))
698-
if loadErr != nil {
699-
if !errors.Is(loadErr, os.ErrNotExist) && !isFileNotExistErr(loadErr) {
700-
return s.Wool.Wrapf(loadErr, "cannot load rest api")
701-
}
702-
s.Wool.Debug("openapi spec not generated yet (expected at Create time)",
703-
wool.Field("path", openapiFile))
701+
rest, err := resources.LoadRestAPI(ctx, shared.Pointer(openapiFile))
702+
if err != nil {
703+
return s.Wool.Wrapf(err, "cannot load rest api")
704704
}
705705

706706
api, err := resources.NewAPI(ctx, endpoint, resources.ToRestAPI(rest))
@@ -711,8 +711,8 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error {
711711
if err != nil {
712712
return s.Wool.Wrapf(err, "cannot declare scaffold health")
713713
}
714-
s.FastAPI.RestEndpoint = api
715-
s.Endpoints = []*basev0.Endpoint{s.FastAPI.RestEndpoint}
714+
s.FastAPI.HTTPEndpoint = api
715+
s.Endpoints = []*basev0.Endpoint{api}
716716

717717
if s.FastAPI.Settings.GRPCServer.Enabled {
718718
grpcEndpoint, grpcErr := s.grpcEndpoint(ctx)
@@ -755,13 +755,6 @@ func (s *Builder) grpcEndpoint(ctx context.Context) (*basev0.Endpoint, error) {
755755
return api, nil
756756
}
757757

758-
// isFileNotExistErr matches the bespoke "file does not exist" error string
759-
// that resources.LoadRestAPI returns for missing files — it doesn't wrap
760-
// os.ErrNotExist, so errors.Is can't catch it directly.
761-
func isFileNotExistErr(err error) bool {
762-
return err != nil && err.Error() == "file does not exist"
763-
}
764-
765758
func (s *Builder) populateSettingsFromAnswers() error {
766759
var err error
767760
if s.FastAPI.Settings.HotReload, err = communicate.Confirm(s.answers, HotReload); err != nil {

‎composed_project_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func TestContainerEnvironmentCarriesAProjectOutsideTheWorkspace(t *testing.T) {
6060
runtime.Service.SourceLocation = source
6161

6262
endpoint := &basev0.Endpoint{Module: "mod", Service: runtime.Identity.Name, Name: "rest", Api: standards.REST}
63-
runtime.FastAPI.RestEndpoint = endpoint
63+
runtime.FastAPI.HTTPEndpoint = endpoint
6464
runtime.NetworkMappings = []*basev0.NetworkMapping{{
6565
Endpoint: endpoint,
6666
Instances: []*basev0.NetworkInstance{{

0 commit comments

Comments
 (0)