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
36 changes: 36 additions & 0 deletions init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
54 changes: 38 additions & 16 deletions runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
Expand Down
Loading