From 1d04001f12a7481e09925d941d0f8f442d37e747 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:51:09 -0400 Subject: [PATCH] fix(runtime): a service without src/openapi.py starts without one Init ran `uv run python src/openapi.py` whenever src/main.py changed, so a service whose API is on its gRPC and Connect faces alone, and which ships no OpenAPI generator, failed Init on a fresh checkout ("cannot generate Open API document"). The model gateway deleted the generator with its REST API. Init now runs the generator only when the project ships it: the scaffold does, and its document describes the app's REST routes. Without it the HTTP endpoint publishes no OpenAPI document, and nothing is run. TestInitWithoutAnOpenAPIGeneratorRunsNone (gRPC + Connect service, no generator: Init READY, only `uv sync` runs) fails on main. Co-Authored-By: Claude Opus 5.5 --- init_test.go | 36 +++++++++++++++++++++++++++++++++++ runtime.go | 54 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/init_test.go b/init_test.go index 060b1d8..d3112f8 100644 --- a/init_test.go +++ b/init_test.go @@ -54,6 +54,7 @@ func initTestRuntime(t *testing.T) (*Runtime, *runtimev0.InitRequest) { require.NoError(t, os.MkdirAll(filepath.Join(source, "src"), 0o700)) require.NoError(t, os.WriteFile(filepath.Join(source, "pyproject.toml"), []byte(probeProject), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(source, "src", "main.py"), []byte("app = None\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(source, openAPIScript), []byte("# the scaffold's generator\n"), 0o600)) runtime.Service.SourceLocation = source endpoint := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "rest", Api: standards.REST} @@ -122,6 +123,41 @@ func TestInitWorksAgainstTheEnvironmentItCaptured(t *testing.T) { require.NotEmpty(t, entries, "the dependency hashes must land in the captured cache location") } +// TestInitWithoutAnOpenAPIGeneratorRunsNone is a service whose API is on its +// gRPC and Connect faces alone: its HTTP endpoint serves the probes, and it +// ships no src/openapi.py. Init must make it ready without running one, or a +// fresh checkout of such a service cannot start. +func TestInitWithoutAnOpenAPIGeneratorRunsNone(t *testing.T) { + isolateWorkingDir(t) + ctx := context.Background() + runtime, req := initTestRuntime(t) + require.NoError(t, os.Remove(filepath.Join(runtime.Service.SourceLocation, openAPIScript))) + + connect := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "connect", Api: standards.CONNECT} + runtime.FastAPI.Settings.GRPCServer.Enabled = true + runtime.FastAPI.Settings.ConnectEndpoint = true + runtime.FastAPI.ConnectEndpoint = connect + req.ProposedNetworkMappings = append(req.ProposedNetworkMappings, &basev0.NetworkMapping{ + Endpoint: connect, + Instances: []*basev0.NetworkInstance{{ + Access: resources.NewNativeNetworkAccess(), Hostname: "localhost", Port: 9998, Address: "localhost:9998", + }}, + }) + + env := &fakeRunnerEnvironment{} + runtime.runnerEnvironment = env + runtime.cacheLocation = t.TempDir() + + resp, err := runtime.Init(ctx, req) + require.NoError(t, err) + require.Equal(t, runtimev0.InitStatus_READY, resp.GetStatus().GetState(), resp.GetStatus().GetMessage()) + var commands [][]string + for _, proc := range env.started() { + commands = append(commands, append([]string{proc.bin}, proc.args...)) + } + require.Equal(t, [][]string{{"uv", "sync"}}, commands, "no OpenAPI generator, so none runs") +} + // TestInitIsSafeWithConcurrentTeardown overlaps Init with the calls that own // the same fields. Under `-race` this is what catches Init publishing or // reading the runner state outside runnerMu; without it, it still pins that diff --git a/runtime.go b/runtime.go index ddc7078..794262d 100644 --- a/runtime.go +++ b/runtime.go @@ -552,25 +552,47 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim } s.Wool.Debug("successful init of runner") + if err := s.initOpenAPI(ctx, env, cacheLocation); err != nil { + return s.Base.Runtime.InitErrorf(err, "cannot generate Open API document") + } + return s.Base.Runtime.InitResponse() +} + +// openAPIScript is the project's OpenAPI generator, relative to its source +// location: the scaffold ships it, and it dumps the schema of the app's REST +// routes to openapi/api.swagger.json. +const openAPIScript = "src/openapi.py" + +// initOpenAPI regenerates the OpenAPI document when the project ships the +// generator and src/main.py changed since the last run. A project without the +// generator publishes no OpenAPI document: its HTTP endpoint serves the probes +// and its API is on another face (gRPC, Connect), so there is nothing to +// generate and nothing is run. +func (s *Runtime) initOpenAPI(ctx context.Context, env runners.RunnerEnvironment, cacheLocation string) error { + script := path.Join(s.Service.SourceLocation, openAPIScript) + hasScript, err := shared.FileExists(ctx, script) + if err != nil { + return err + } + if !hasScript { + s.Wool.Debug("no OpenAPI generator: the HTTP endpoint publishes no OpenAPI document", wool.Field("path", script)) + return nil + } openAPI := builders.NewDependencies("api", builders.NewDependency(path.Join(s.Service.SourceLocation, "src/main.py"))).WithCache(cacheLocation) - openApiUpdate, err := openAPI.Updated(ctx) - if err != nil { - return s.Base.Runtime.InitError(err) + openAPIUpdate, err := openAPI.Updated(ctx) + if err != nil || !openAPIUpdate { + return err } - - if openApiUpdate { - s.Infof("generating Open API document") - if err := s.GenerateOpenAPI(ctx, env); err != nil { - return s.Base.Runtime.InitErrorf(err, "cannot generate Open API document") - } - if err := openAPI.UpdateCache(ctx); err != nil { - s.Wool.Warn("cannot update openapi cache", wool.ErrField(err)) - } - s.Wool.Debug("generate Open API done") + s.Infof("generating Open API document") + if err := s.GenerateOpenAPI(ctx, env); err != nil { + return err } - - return s.Base.Runtime.InitResponse() + if err := openAPI.UpdateCache(ctx); err != nil { + s.Wool.Warn("cannot update openapi cache", wool.ErrField(err)) + } + s.Wool.Debug("generate Open API done") + return nil } func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runtimev0.StartResponse, error) { @@ -1288,7 +1310,7 @@ func (s *Runtime) EventHandler(event code.Change) error { // the OpenAPI spec. Convention: the project ships a small openapi.py that // imports src.main and dumps the schema. See templates/factory. func (s *Runtime) GenerateOpenAPI(ctx context.Context, env runners.RunnerEnvironment) error { - proc, err := env.NewProcess("uv", "run", "python", "src/openapi.py") + proc, err := env.NewProcess("uv", "run", "python", openAPIScript) if err != nil { return s.Wool.Wrapf(err, "cannot create openapi runner") }