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
8 changes: 8 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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.unservedError(ctx, s.Endpoints); err != nil {
return s.Base.Builder.LoadError(err)
}
if err := s.FastAPI.Settings.connectSettingError(); err != nil {
return s.Base.Builder.LoadError(err)
}
Expand Down Expand Up @@ -267,6 +270,10 @@ type DockerTemplating struct {
// GitSources are the repositories of the git commits the lock pins, which
// the builder stage reads from the caller's mirrors (uvgitsources.go).
GitSources []uvGitMirror
// NoHTTPEndpoint starts uvicorn on a loopback port the system picks, as a
// native start does (uvicornArgs): a service with no HTTP endpoint serves
// no HTTP in the image either.
NoHTTPEndpoint bool
}

func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
Expand Down Expand Up @@ -342,6 +349,7 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
UVImage: uvImage,
ProjectWorkdir: defaultProjectWorkdir,
GitSources: uvGitMirrors(gitSources),
NoHTTPEndpoint: s.FastAPI.HTTPEndpoint == nil,
}
if assembled != nil {
docker.ServicePrefix = assembled.ServicePrefix
Expand Down
134 changes: 134 additions & 0 deletions connect_only_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,137 @@ func TestConnectOnlyServiceStarts(t *testing.T) {
_, err = runtime.Stop(ctx, &runtimev0.StopRequest{})
require.NoError(t, err)
}

// rewriteManifest applies edit to a scaffolded service's manifest.
func rewriteManifest(t *testing.T, root string, edit func(document map[string]any)) {
t.Helper()
manifest := filepath.Join(root, "service.codefly.yaml")
content, err := os.ReadFile(manifest)
require.NoError(t, err)
var document map[string]any
require.NoError(t, yaml.Unmarshal(content, &document))
edit(document)
content, err = yaml.Marshal(document)
require.NoError(t, err)
require.NoError(t, os.WriteFile(manifest, content, 0o644))
}

// TestUnservedServiceIsRefusedAtLoad: a manifest that lost its endpoints, or
// whose gRPC server has no endpoint to serve, is refused by both Loads by name
// instead of starting a process nothing reaches.
func TestUnservedServiceIsRefusedAtLoad(t *testing.T) {
ctx := context.Background()
for name, tc := range map[string]struct {
answers map[string]*agentv0.Answer
}{
"no endpoints, no gRPC server": {answers: nil},
"gRPC server with no gRPC endpoint": {answers: map[string]*agentv0.Answer{
HotReload: confirmAnswer(false), PublicEndpoint: confirmAnswer(false), GRPCServer: confirmAnswer(true),
}},
} {
t.Run(name, func(t *testing.T) {
created, root := createServiceForTest(t, tc.answers)
rewriteManifest(t, root, func(document map[string]any) { delete(document, "endpoints") })
identity, err := created.Identity.Proto()
require.NoError(t, err)

loaded, err := NewBuilder(NewService()).Load(ctx, &builderv0.LoadRequest{Identity: identity})
require.NoError(t, err)
require.Equal(t, builderv0.LoadStatus_ERROR, loaded.GetState().GetState())
require.Contains(t, loaded.GetState().GetMessage(), "nothing would serve this service")

runtime := NewRuntime(NewService())
t.Cleanup(runtime.Base.StopWatcher)
resp, err := runtime.Load(ctx, &runtimev0.LoadRequest{Identity: identity, Environment: localEnvironment(t), DisableCatch: true})
require.NoError(t, err)
require.Equal(t, runtimev0.LoadStatus_ERROR, resp.GetStatus().GetState())
require.Contains(t, resp.GetStatus().GetMessage(), "nothing would serve this service")
})
}
}

// grpcOnlyInit stands a runtime up at Init for a service whose one face is its
// gRPC server, with the proposed mapping Init resolves it from.
func grpcOnlyInit(t *testing.T) (*Runtime, *fakeRunnerEnvironment, *runtimev0.InitRequest, string) {
t.Helper()
runtime, req := initTestRuntime(t)
env := &fakeRunnerEnvironment{}
runtime.runnerEnvironment = env
runtime.cacheLocation = t.TempDir()
grpcEndpoint := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "grpc", Api: standards.GRPC}
connectEndpoint := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "connect", Api: standards.CONNECT}
runtime.FastAPI.HTTPEndpoint = nil
runtime.FastAPI.GRPCEndpoint = grpcEndpoint
mappings, _ := connectOnlyMappings(t, grpcEndpoint, connectEndpoint)
req.ProposedNetworkMappings = mappings[:1]
return runtime, env, req, mappings[0].GetInstances()[0].GetAddress()
}

// TestGRPCOnlyStartWaitsOnTheGRPCServer: with neither an HTTP endpoint nor a
// Connect face, Start's readiness is the gRPC server's handshake at the address
// Init resolved for it.
func TestGRPCOnlyStartWaitsOnTheGRPCServer(t *testing.T) {
isolateWorkingDir(t)
ctx := context.Background()
runtime, _, req, grpcAddress := grpcOnlyInit(t)
var asked []string
runtime.answers = func(_ context.Context, target string) error {
asked = append(asked, target)
return nil
}
initialized, err := runtime.Init(ctx, req)
require.NoError(t, err)
require.Equal(t, runtimev0.InitStatus_READY, initialized.GetStatus().GetState(), initialized.GetStatus().GetMessage())
started, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_STARTED, started.GetStatus().GetState(), started.GetStatus().GetMessage())
t.Cleanup(func() { _, _ = runtime.Stop(context.Background(), &runtimev0.StopRequest{}) })
require.Equal(t, []string{grpcAddress}, asked)
}

// TestInitDropsTheListenersAReloadRemoved: an Init after a reload that dropped
// the Connect endpoint publishes no Connect port or address, so Start neither
// hands the process the old port nor waits on the old face.
func TestInitDropsTheListenersAReloadRemoved(t *testing.T) {
isolateWorkingDir(t)
ctx := context.Background()
runtime, env, req, grpcAddress := grpcOnlyInit(t)
connectEndpoint := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "connect", Api: standards.CONNECT}
runtime.FastAPI.ConnectEndpoint = connectEndpoint
withConnect, _ := connectOnlyMappings(t, runtime.FastAPI.GRPCEndpoint, connectEndpoint)
req.ProposedNetworkMappings = append(req.ProposedNetworkMappings, withConnect[1])
initialized, err := runtime.Init(ctx, req)
require.NoError(t, err)
require.Equal(t, runtimev0.InitStatus_READY, initialized.GetStatus().GetState(), initialized.GetStatus().GetMessage())
require.NotEmpty(t, runtime.connectAddress)

runtime.FastAPI.ConnectEndpoint = nil
req.ProposedNetworkMappings = req.ProposedNetworkMappings[:1]
initialized, err = runtime.Init(ctx, req)
require.NoError(t, err)
require.Equal(t, runtimev0.InitStatus_READY, initialized.GetStatus().GetState(), initialized.GetStatus().GetMessage())

var asked []string
runtime.answers = func(_ context.Context, target string) error {
asked = append(asked, target)
return nil
}
started, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_STARTED, started.GetStatus().GetState(), started.GetStatus().GetMessage())
t.Cleanup(func() { _, _ = runtime.Stop(context.Background(), &runtimev0.StopRequest{}) })
require.Equal(t, []string{grpcAddress}, asked)
require.Empty(t, processEnv(uvicornProc(t, env), "CODEFLY_CONNECT_PORT"))
}

// TestImageServesNoHTTPWithoutAnHTTPEndpoint: the image of a service with no
// HTTP endpoint binds uvicorn to loopback, never 0.0.0.0:8080.
func TestImageServesNoHTTPWithoutAnHTTPEndpoint(t *testing.T) {
builder, _ := createdBuilder(t)
builder.FastAPI.HTTPEndpoint = nil
outputDir := t.TempDir()
dockerfile := dockerfileOf(t, outputDir, emitRecipe(t, builder, outputDir))
require.Contains(t, dockerfile, `CMD ["uvicorn", "src.main:app", "--host", "127.0.0.1", "--port", "0", "--log-config", "uvicorn-log-config.json"]`)
require.NotContains(t, dockerfile, "0.0.0.0")
require.NotContains(t, dockerfile, "8080")
}
14 changes: 14 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ func findHTTPEndpoint(ctx context.Context, endpoints []*v0.Endpoint) (*v0.Endpoi
return nil, fmt.Errorf("the app serves one HTTP listener, and the service declares %d HTTP endpoints (%s): keep one with api: rest or api: http", len(found), strings.Join(names, ", "))
}

// unservedError refuses a service nothing would serve: no HTTP endpoint, and
// no gRPC endpoint for a gRPC server to serve. A manifest that lost its
// endpoints would otherwise start a process nobody can reach, and Start would
// have nothing to wait on before it answered STARTED.
func (s *Service) unservedError(ctx context.Context, endpoints []*v0.Endpoint) error {
if s.HTTPEndpoint != nil {
return nil
}
if s.Settings.GRPCServer.Enabled && len(resources.FindEndpointsByAPI(ctx, standards.GRPC, endpoints)) > 0 {
return nil
}
return fmt.Errorf("nothing would serve this service: it declares no HTTP endpoint (api: rest or api: http) and no gRPC endpoint with %s.enabled", GRPCServer)
}

// 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 {
Expand Down
7 changes: 7 additions & 0 deletions runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim
if err != nil {
return s.Base.Runtime.LoadError(err)
}
if err := s.FastAPI.unservedError(ctx, s.Endpoints); err != nil {
return s.Base.Runtime.LoadError(err)
}

if s.FastAPI.Settings.GRPCServer.Enabled {
s.FastAPI.GRPCEndpoint, err = resources.FindGRPCEndpoint(ctx, s.Endpoints)
Expand Down Expand Up @@ -499,7 +502,11 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim
return s.Base.Runtime.InitError(err)
}

// Every listener Init publishes is reset first: a reload that dropped an
// endpoint must not leave Start binding or waiting on the old one.
s.port, s.serveAddress = 0, ""
s.grpcPort, s.grpcAddress = 0, ""
s.connectPort, s.connectAddress = 0, ""
if s.FastAPI.HTTPEndpoint != nil {
net, err := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.HTTPEndpoint, resources.NewNativeNetworkAccess())
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions templates/builder/Dockerfile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,11 @@ WORKDIR /app/code
# writes it. The builder writes both into the project before the build.
COPY --chown=appuser {{.ServicePrefix}}code/uvicorn-log-config.json {{.ServicePrefix}}code/codefly_uvicorn_logging.py ./

{{if .NoHTTPEndpoint -}}
# No HTTP endpoint: the app runs under uvicorn for its lifespan (the gRPC
# server, the Connect face) on a loopback port the system picks.
CMD ["uvicorn", "src.main:app", "--host", "127.0.0.1", "--port", "0", "--log-config", "uvicorn-log-config.json"]
{{- else -}}
# Default REST port (overridden by codefly at runtime).
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080", "--log-config", "uvicorn-log-config.json"]
{{- end}}
Loading