diff --git a/builder.go b/builder.go index b3a4efe..651aa02 100644 --- a/builder.go +++ b/builder.go @@ -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) } @@ -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) { @@ -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 diff --git a/connect_only_test.go b/connect_only_test.go index faa60e8..ce12c8a 100644 --- a/connect_only_test.go +++ b/connect_only_test.go @@ -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") +} diff --git a/main.go b/main.go index 81b6712..62faa8d 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/runtime.go b/runtime.go index 6f3982c..9e4ef55 100644 --- a/runtime.go +++ b/runtime.go @@ -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) @@ -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 { diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index fb84146..0f888f2 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -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}}